From 7d4bb3e12b607cedb94031cf38542aab53bd4414 Mon Sep 17 00:00:00 2001 From: "M. Palanikannan" <73993394+Palanikannan1437@users.noreply.github.com> Date: Mon, 1 Jul 2024 12:49:10 +0530 Subject: [PATCH] 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 --- .../components/editors/document/helpers.ts | 19 ++++ .../core/components/editors/document/index.ts | 1 + .../core/extensions/core-without-props.tsx | 19 +--- .../extensions/document-without-props.tsx | 3 + .../editor/src/core/extensions/extensions.tsx | 1 + .../src/core/extensions/image/extension.tsx | 9 +- .../image/image-extension-without-props.tsx | 18 ---- .../utilities/insert-line-above-image.ts | 52 ---------- .../utilities/insert-line-below-image.ts | 51 ---------- packages/editor/src/core/extensions/index.ts | 1 + .../src/core/extensions/issue-embed/index.ts | 1 + .../issue-embed/issue-embed-without-props.ts | 41 ++++++++ packages/editor/src/core/helpers/common.ts | 17 ---- ...insert-empty-paragraph-at-node-boundary.ts | 96 +++++++++++++++++++ packages/editor/src/index.ts | 1 + web/core/hooks/use-page-description.ts | 9 +- 16 files changed, 176 insertions(+), 163 deletions(-) create mode 100644 packages/editor/src/core/components/editors/document/helpers.ts create mode 100644 packages/editor/src/core/extensions/document-without-props.tsx delete mode 100644 packages/editor/src/core/extensions/image/utilities/insert-line-above-image.ts delete mode 100644 packages/editor/src/core/extensions/image/utilities/insert-line-below-image.ts create mode 100644 packages/editor/src/core/extensions/issue-embed/issue-embed-without-props.ts create mode 100644 packages/editor/src/core/helpers/insert-empty-paragraph-at-node-boundary.ts diff --git a/packages/editor/src/core/components/editors/document/helpers.ts b/packages/editor/src/core/components/editors/document/helpers.ts new file mode 100644 index 000000000..b2ef91fb7 --- /dev/null +++ b/packages/editor/src/core/components/editors/document/helpers.ts @@ -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 ?? "

", extensions as Extensions); + const editorSchema = getSchema(extensions as Extensions); + return { + contentJSON, + editorSchema, + }; +}; diff --git a/packages/editor/src/core/components/editors/document/index.ts b/packages/editor/src/core/components/editors/document/index.ts index 6c48975bd..574c613be 100644 --- a/packages/editor/src/core/components/editors/document/index.ts +++ b/packages/editor/src/core/components/editors/document/index.ts @@ -1,3 +1,4 @@ export * from "./editor"; export * from "./page-renderer"; export * from "./read-only-editor"; +export * from "./helpers"; diff --git a/packages/editor/src/core/extensions/core-without-props.tsx b/packages/editor/src/core/extensions/core-without-props.tsx index be1b9ab24..101511ce0 100644 --- a/packages/editor/src/core/extensions/core-without-props.tsx +++ b/packages/editor/src/core/extensions/core-without-props.tsx @@ -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, - }), ]; diff --git a/packages/editor/src/core/extensions/document-without-props.tsx b/packages/editor/src/core/extensions/document-without-props.tsx new file mode 100644 index 000000000..2202510ec --- /dev/null +++ b/packages/editor/src/core/extensions/document-without-props.tsx @@ -0,0 +1,3 @@ +import { IssueWidgetWithoutProps } from "@/extensions/issue-embed"; + +export const DocumentEditorExtensionsWithoutProps = () => [IssueWidgetWithoutProps()]; diff --git a/packages/editor/src/core/extensions/extensions.tsx b/packages/editor/src/core/extensions/extensions.tsx index 6cabd9e3f..bb53395ac 100644 --- a/packages/editor/src/core/extensions/extensions.tsx +++ b/packages/editor/src/core/extensions/extensions.tsx @@ -70,6 +70,7 @@ export const CoreEditorExtensions = ({ codeBlock: false, horizontalRule: false, blockquote: false, + history: false, dropcursor: { color: "rgba(var(--color-text-100))", width: 1, diff --git a/packages/editor/src/core/extensions/image/extension.tsx b/packages/editor/src/core/extensions/image/extension.tsx index 04555fd45..98961b7f0 100644 --- a/packages/editor/src/core/extensions/image/extension.tsx +++ b/packages/editor/src/core/extensions/image/extension.tsx @@ -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({ addKeyboardShortcuts() { return { - ArrowDown: insertLineBelowImageAction, - ArrowUp: insertLineAboveImageAction, + ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", "image"), + ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", "image"), }; }, addProseMirrorPlugins() { diff --git a/packages/editor/src/core/extensions/image/image-extension-without-props.tsx b/packages/editor/src/core/extensions/image/image-extension-without-props.tsx index ee9d76dab..0d505000c 100644 --- a/packages/editor/src/core/extensions/image/image-extension-without-props.tsx +++ b/packages/editor/src/core/extensions/image/image-extension-without-props.tsx @@ -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 - addStorage() { - return { - images: new Map(), - uploadInProgress: false, - }; - }, - addAttributes() { return { ...this.parent?.(), diff --git a/packages/editor/src/core/extensions/image/utilities/insert-line-above-image.ts b/packages/editor/src/core/extensions/image/utilities/insert-line-above-image.ts deleted file mode 100644 index 9370f33ab..000000000 --- a/packages/editor/src/core/extensions/image/utilities/insert-line-above-image.ts +++ /dev/null @@ -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; - } -}; diff --git a/packages/editor/src/core/extensions/image/utilities/insert-line-below-image.ts b/packages/editor/src/core/extensions/image/utilities/insert-line-below-image.ts deleted file mode 100644 index 4825386e3..000000000 --- a/packages/editor/src/core/extensions/image/utilities/insert-line-below-image.ts +++ /dev/null @@ -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; - } -}; diff --git a/packages/editor/src/core/extensions/index.ts b/packages/editor/src/core/extensions/index.ts index 2fb9d8974..220a11757 100644 --- a/packages/editor/src/core/extensions/index.ts +++ b/packages/editor/src/core/extensions/index.ts @@ -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"; diff --git a/packages/editor/src/core/extensions/issue-embed/index.ts b/packages/editor/src/core/extensions/issue-embed/index.ts index f6674719e..f47619a03 100644 --- a/packages/editor/src/core/extensions/issue-embed/index.ts +++ b/packages/editor/src/core/extensions/issue-embed/index.ts @@ -1 +1,2 @@ export * from "./widget-node"; +export * from "./issue-embed-without-props"; diff --git a/packages/editor/src/core/extensions/issue-embed/issue-embed-without-props.ts b/packages/editor/src/core/extensions/issue-embed/issue-embed-without-props.ts new file mode 100644 index 000000000..bef366cba --- /dev/null +++ b/packages/editor/src/core/extensions/issue-embed/issue-embed-without-props.ts @@ -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)]; + }, + }); diff --git a/packages/editor/src/core/helpers/common.ts b/packages/editor/src/core/helpers/common.ts index 5fbff4510..98930d94f 100644 --- a/packages/editor/src/core/helpers/common.ts +++ b/packages/editor/src/core/helpers/common.ts @@ -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 ?? "

", extensions as Extensions); - const editorSchema = getSchema(extensions as Extensions); - return { - contentJSON, - editorSchema, - }; -}; diff --git a/packages/editor/src/core/helpers/insert-empty-paragraph-at-node-boundary.ts b/packages/editor/src/core/helpers/insert-empty-paragraph-at-node-boundary.ts new file mode 100644 index 000000000..ffad88d4e --- /dev/null +++ b/packages/editor/src/core/helpers/insert-empty-paragraph-at-node-boundary.ts @@ -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 + } + }; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 1dae4e8b3..828fab021 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -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"; diff --git a/web/core/hooks/use-page-description.ts b/web/core/hooks/use-page-description.ts index 49edb3d41..f7b467d4d 100644 --- a/web/core/hooks/use-page-description.ts +++ b/web/core/hooks/use-page-description.ts @@ -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 ?? "

"); + const { contentJSON, editorSchema } = generateJSONfromHTMLForDocumentEditor(pageDescription ?? "

"); const yDocBinaryString = proseMirrorJSONToBinaryString(contentJSON, "default", editorSchema); try {