Fix: image insertion in node boundaries and missing extensions (#4977)
* fix: insertion of image at node boundaries fixed * fix: remove unecessary things * fix: history conflicting * fix: generate json from html properly
This commit is contained in:
parent
96563b438e
commit
7d4bb3e12b
16 changed files with 176 additions and 163 deletions
|
|
@ -0,0 +1,19 @@
|
|||
import { Extensions, generateJSON, getSchema } from "@tiptap/core";
|
||||
import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@/extensions";
|
||||
|
||||
/**
|
||||
* @description return an object with contentJSON and editorSchema
|
||||
* @description contentJSON- ProseMirror JSON from HTML content
|
||||
* @description editorSchema- editor schema from extensions
|
||||
* @param {string} html
|
||||
* @returns {object} {contentJSON, editorSchema}
|
||||
*/
|
||||
export const generateJSONfromHTMLForDocumentEditor = (html: string) => {
|
||||
const extensions = [...CoreEditorExtensionsWithoutProps(), ...DocumentEditorExtensionsWithoutProps()];
|
||||
const contentJSON = generateJSON(html ?? "<p></p>", extensions as Extensions);
|
||||
const editorSchema = getSchema(extensions as Extensions);
|
||||
return {
|
||||
contentJSON,
|
||||
editorSchema,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export * from "./editor";
|
||||
export * from "./page-renderer";
|
||||
export * from "./read-only-editor";
|
||||
export * from "./helpers";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
import TextStyle from "@tiptap/extension-text-style";
|
||||
|
|
@ -46,10 +45,7 @@ export const CoreEditorExtensionsWithoutProps = () => [
|
|||
codeBlock: false,
|
||||
horizontalRule: false,
|
||||
blockquote: false,
|
||||
dropcursor: {
|
||||
color: "rgba(var(--color-text-100))",
|
||||
width: 1,
|
||||
},
|
||||
dropcursor: false,
|
||||
}),
|
||||
CustomQuoteExtension,
|
||||
CustomHorizontalRule.configure({
|
||||
|
|
@ -58,7 +54,6 @@ export const CoreEditorExtensionsWithoutProps = () => [
|
|||
},
|
||||
}),
|
||||
CustomKeymap,
|
||||
// ListKeymap,
|
||||
CustomLinkExtension.configure({
|
||||
openOnClick: true,
|
||||
autolink: true,
|
||||
|
|
@ -105,16 +100,4 @@ export const CoreEditorExtensionsWithoutProps = () => [
|
|||
TableCell,
|
||||
TableRow,
|
||||
CustomMentionWithoutProps(),
|
||||
Placeholder.configure({
|
||||
placeholder: ({ editor, node }) => {
|
||||
if (node.type.name === "heading") return `Heading ${node.attrs.level}`;
|
||||
|
||||
const shouldHidePlaceholder =
|
||||
editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image");
|
||||
if (shouldHidePlaceholder) return "";
|
||||
|
||||
return "Press '/' for commands...";
|
||||
},
|
||||
includeChildren: true,
|
||||
}),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
import { IssueWidgetWithoutProps } from "@/extensions/issue-embed";
|
||||
|
||||
export const DocumentEditorExtensionsWithoutProps = () => [IssueWidgetWithoutProps()];
|
||||
|
|
@ -70,6 +70,7 @@ export const CoreEditorExtensions = ({
|
|||
codeBlock: false,
|
||||
horizontalRule: false,
|
||||
blockquote: false,
|
||||
history: false,
|
||||
dropcursor: {
|
||||
color: "rgba(var(--color-text-100))",
|
||||
width: 1,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import ImageExt from "@tiptap/extension-image";
|
||||
// helpers
|
||||
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
||||
// plugins
|
||||
import {
|
||||
IMAGE_NODE_TYPE,
|
||||
|
|
@ -9,16 +11,13 @@ import {
|
|||
} from "@/plugins/image";
|
||||
// types
|
||||
import { DeleteImage, RestoreImage } from "@/types";
|
||||
// helpers
|
||||
import { insertLineAboveImageAction } from "./utilities/insert-line-above-image";
|
||||
import { insertLineBelowImageAction } from "./utilities/insert-line-below-image";
|
||||
|
||||
export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreImage, cancelUploadImage?: () => void) =>
|
||||
ImageExt.extend<any, ImageExtensionStorage>({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
ArrowDown: insertLineBelowImageAction,
|
||||
ArrowUp: insertLineAboveImageAction,
|
||||
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", "image"),
|
||||
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", "image"),
|
||||
};
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
|
|
|
|||
|
|
@ -1,25 +1,7 @@
|
|||
import ImageExt from "@tiptap/extension-image";
|
||||
// helpers
|
||||
import { insertLineAboveImageAction } from "./utilities/insert-line-above-image";
|
||||
import { insertLineBelowImageAction } from "./utilities/insert-line-below-image";
|
||||
|
||||
export const ImageExtensionWithoutProps = () =>
|
||||
ImageExt.extend({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
ArrowDown: insertLineBelowImageAction,
|
||||
ArrowUp: insertLineAboveImageAction,
|
||||
};
|
||||
},
|
||||
|
||||
// storage to keep track of image states Map<src, isDeleted>
|
||||
addStorage() {
|
||||
return {
|
||||
images: new Map<string, boolean>(),
|
||||
uploadInProgress: false,
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
import { KeyboardShortcutCommand } from "@tiptap/core";
|
||||
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
|
||||
export const insertLineAboveImageAction: KeyboardShortcutCommand = ({ editor }) => {
|
||||
try {
|
||||
const { selection, doc } = editor.state;
|
||||
const { $from, $to } = selection;
|
||||
|
||||
let imageNode: ProseMirrorNode | null = null;
|
||||
let imagePos: number | null = null;
|
||||
|
||||
// Check if the selection itself is an image node
|
||||
doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
|
||||
if (node.type.name === "image") {
|
||||
imageNode = node;
|
||||
imagePos = pos;
|
||||
return false; // Stop iterating once an image node is found
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (imageNode === null || imagePos === null) return false;
|
||||
|
||||
// Since we want to insert above the image, we use the imagePos directly
|
||||
const insertPos = imagePos;
|
||||
|
||||
const docSize = editor.state.doc.content.size;
|
||||
|
||||
if (insertPos < 0 || insertPos > docSize) return false;
|
||||
|
||||
// Check for an existing node immediately before the image
|
||||
if (insertPos === 0) {
|
||||
// If the previous node doesn't exist or isn't a paragraph, create and insert a new empty node there
|
||||
editor.chain().insertContentAt(insertPos, { type: "paragraph" }).run();
|
||||
editor.chain().setTextSelection(insertPos).run();
|
||||
} else {
|
||||
const prevNode = doc.nodeAt(insertPos);
|
||||
|
||||
if (prevNode && prevNode.type.name === "paragraph") {
|
||||
// If the previous node is a paragraph, move the cursor there
|
||||
editor.chain().setTextSelection(insertPos).run();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("An error occurred while inserting a line above the image:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import { KeyboardShortcutCommand } from "@tiptap/core";
|
||||
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
|
||||
export const insertLineBelowImageAction: KeyboardShortcutCommand = ({ editor }) => {
|
||||
try {
|
||||
const { selection, doc } = editor.state;
|
||||
const { $from, $to } = selection;
|
||||
|
||||
let imageNode: ProseMirrorNode | null = null;
|
||||
let imagePos: number | null = null;
|
||||
|
||||
// Check if the selection itself is an image node
|
||||
doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
|
||||
if (node.type.name === "image") {
|
||||
imageNode = node;
|
||||
imagePos = pos;
|
||||
return false; // Stop iterating once an image node is found
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (imageNode === null || imagePos === null) return false;
|
||||
|
||||
const guaranteedImageNode: ProseMirrorNode = imageNode;
|
||||
const nextNodePos = imagePos + guaranteedImageNode.nodeSize;
|
||||
|
||||
// Check for an existing node immediately after the image
|
||||
const nextNode = doc.nodeAt(nextNodePos);
|
||||
|
||||
if (nextNode && nextNode.type.name === "paragraph") {
|
||||
// If the next node is a paragraph, move the cursor there
|
||||
const endOfParagraphPos = nextNodePos + nextNode.nodeSize - 1;
|
||||
editor.chain().setTextSelection(endOfParagraphPos).run();
|
||||
} else if (!nextNode) {
|
||||
// If the next node doesn't exist i.e. we're at the end of the document, create and insert a new empty node there
|
||||
editor.chain().insertContentAt(nextNodePos, { type: "paragraph" }).run();
|
||||
editor
|
||||
.chain()
|
||||
.setTextSelection(nextNodePos + 1)
|
||||
.run();
|
||||
} else {
|
||||
// If the next node is not a paragraph, do not proceed
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("An error occurred while inserting a line below the image:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
|
@ -8,6 +8,7 @@ export * from "./mentions";
|
|||
export * from "./table";
|
||||
export * from "./typography";
|
||||
export * from "./core-without-props";
|
||||
export * from "./document-without-props";
|
||||
export * from "./custom-code-inline";
|
||||
export * from "./drag-drop";
|
||||
export * from "./drop";
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
export * from "./widget-node";
|
||||
export * from "./issue-embed-without-props";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
|
||||
export const IssueWidgetWithoutProps = () =>
|
||||
Node.create({
|
||||
name: "issue-embed-component",
|
||||
group: "block",
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
entity_identifier: {
|
||||
default: undefined,
|
||||
},
|
||||
project_identifier: {
|
||||
default: undefined,
|
||||
},
|
||||
workspace_identifier: {
|
||||
default: undefined,
|
||||
},
|
||||
id: {
|
||||
default: undefined,
|
||||
},
|
||||
entity_name: {
|
||||
default: undefined,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "issue-embed-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["issue-embed-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
});
|
||||
|
|
@ -61,20 +61,3 @@ export const isValidHttpUrl = (string: string): boolean => {
|
|||
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
};
|
||||
|
||||
/**
|
||||
* @description return an object with contentJSON and editorSchema
|
||||
* @description contentJSON- ProseMirror JSON from HTML content
|
||||
* @description editorSchema- editor schema from extensions
|
||||
* @param {string} html
|
||||
* @returns {object} {contentJSON, editorSchema}
|
||||
*/
|
||||
export const generateJSONfromHTML = (html: string) => {
|
||||
const extensions = CoreEditorExtensionsWithoutProps();
|
||||
const contentJSON = generateJSON(html ?? "<p></p>", extensions as Extensions);
|
||||
const editorSchema = getSchema(extensions as Extensions);
|
||||
return {
|
||||
contentJSON,
|
||||
editorSchema,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
import { KeyboardShortcutCommand } from "@tiptap/core";
|
||||
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
|
||||
type Direction = "up" | "down";
|
||||
|
||||
export const insertEmptyParagraphAtNodeBoundaries: (
|
||||
direction: Direction,
|
||||
nodeType: string
|
||||
) => KeyboardShortcutCommand =
|
||||
(direction, nodeType) =>
|
||||
({ editor }) => {
|
||||
try {
|
||||
const { selection, doc } = editor.state;
|
||||
const { $from, $to } = selection;
|
||||
|
||||
let targetNode: ProseMirrorNode | null = null;
|
||||
let targetNodePos: number | null = null;
|
||||
|
||||
// Check if the selection itself is the target node
|
||||
doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
|
||||
if (node.type.name === nodeType) {
|
||||
targetNode = node;
|
||||
targetNodePos = pos;
|
||||
return false; // Stop iterating once the target node is found
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (targetNode === null || targetNodePos === null) return false;
|
||||
|
||||
const docSize = doc.content.size; // Get the size of the document
|
||||
|
||||
switch (direction) {
|
||||
case "up": {
|
||||
const insertPosUp = targetNodePos;
|
||||
|
||||
// Ensure the insert position is within the document boundaries
|
||||
if (insertPosUp < 0 || insertPosUp > docSize) return false;
|
||||
|
||||
if (insertPosUp === 0) {
|
||||
// If at the very start of the document, insert a new paragraph at the start
|
||||
editor.chain().insertContentAt(insertPosUp, { type: "paragraph" }).run();
|
||||
editor.chain().setTextSelection(insertPosUp).run(); // Set the cursor to the new paragraph
|
||||
} else {
|
||||
// Otherwise, check the node immediately before the target node
|
||||
const prevNode = doc.nodeAt(insertPosUp - 1);
|
||||
|
||||
if (prevNode && prevNode.type.name === "paragraph") {
|
||||
// If the previous node is a paragraph, move the cursor there
|
||||
editor
|
||||
.chain()
|
||||
.setTextSelection(insertPosUp - 1)
|
||||
.run();
|
||||
} else {
|
||||
return false; // If the previous node is not a paragraph, do not proceed
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "down": {
|
||||
const insertPosDown = targetNodePos + (targetNode as ProseMirrorNode).nodeSize;
|
||||
|
||||
// Ensure the insert position is within the document boundaries
|
||||
if (insertPosDown < 0 || insertPosDown > docSize) return false;
|
||||
|
||||
// Check the node immediately after the target node
|
||||
const nextNode = doc.nodeAt(insertPosDown);
|
||||
|
||||
if (nextNode && nextNode.type.name === "paragraph") {
|
||||
// If the next node is a paragraph, move the cursor to the end of it
|
||||
const endOfParagraphPos = insertPosDown + nextNode.nodeSize - 1;
|
||||
editor.chain().setTextSelection(endOfParagraphPos).run();
|
||||
} else if (!nextNode) {
|
||||
// If there is no next node (end of document), insert a new paragraph
|
||||
editor.chain().insertContentAt(insertPosDown, { type: "paragraph" }).run();
|
||||
editor
|
||||
.chain()
|
||||
.setTextSelection(insertPosDown + 1)
|
||||
.run(); // Set the cursor to the new paragraph
|
||||
} else {
|
||||
return false; // If the next node is not a paragraph, do not proceed
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
return false; // If the direction is not recognized, do not proceed
|
||||
}
|
||||
|
||||
return true; // Return true if the operation was successful
|
||||
} catch (error) {
|
||||
console.error(`An error occurred while inserting a line ${direction} the ${nodeType}:`, error);
|
||||
return false; // Return false if an error occurred
|
||||
}
|
||||
};
|
||||
|
|
@ -19,6 +19,7 @@ export { isCellSelection } from "@/extensions/table/table/utilities/is-cell-sele
|
|||
|
||||
// helpers
|
||||
export * from "@/helpers/common";
|
||||
export * from "@/components/editors/document/helpers";
|
||||
export * from "@/helpers/editor-commands";
|
||||
export * from "@/helpers/yjs";
|
||||
export * from "@/extensions/table/table";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { EditorRefApi, generateJSONfromHTML, proseMirrorJSONToBinaryString, applyUpdates } from "@plane/editor";
|
||||
import {
|
||||
EditorRefApi,
|
||||
proseMirrorJSONToBinaryString,
|
||||
applyUpdates,
|
||||
generateJSONfromHTMLForDocumentEditor,
|
||||
} from "@plane/editor";
|
||||
|
||||
// hooks
|
||||
import { setToast, TOAST_TYPE } from "@plane/ui";
|
||||
|
|
@ -71,7 +76,7 @@ export const usePageDescription = (props: Props) => {
|
|||
const changeHTMLToBinary = async () => {
|
||||
if (!pageDescriptionYJS || !pageDescription) return;
|
||||
if (pageDescriptionYJS.length === 0) {
|
||||
const { contentJSON, editorSchema } = generateJSONfromHTML(pageDescription ?? "<p></p>");
|
||||
const { contentJSON, editorSchema } = generateJSONfromHTMLForDocumentEditor(pageDescription ?? "<p></p>");
|
||||
const yDocBinaryString = proseMirrorJSONToBinaryString(contentJSON, "default", editorSchema);
|
||||
|
||||
try {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue