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:
M. Palanikannan 2024-07-01 12:49:10 +05:30 committed by GitHub
parent 96563b438e
commit 7d4bb3e12b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 176 additions and 163 deletions

View file

@ -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,
};
};

View file

@ -1,3 +1,4 @@
export * from "./editor"; export * from "./editor";
export * from "./page-renderer"; export * from "./page-renderer";
export * from "./read-only-editor"; export * from "./read-only-editor";
export * from "./helpers";

View file

@ -1,4 +1,3 @@
import Placeholder from "@tiptap/extension-placeholder";
import TaskItem from "@tiptap/extension-task-item"; import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list"; import TaskList from "@tiptap/extension-task-list";
import TextStyle from "@tiptap/extension-text-style"; import TextStyle from "@tiptap/extension-text-style";
@ -46,10 +45,7 @@ export const CoreEditorExtensionsWithoutProps = () => [
codeBlock: false, codeBlock: false,
horizontalRule: false, horizontalRule: false,
blockquote: false, blockquote: false,
dropcursor: { dropcursor: false,
color: "rgba(var(--color-text-100))",
width: 1,
},
}), }),
CustomQuoteExtension, CustomQuoteExtension,
CustomHorizontalRule.configure({ CustomHorizontalRule.configure({
@ -58,7 +54,6 @@ export const CoreEditorExtensionsWithoutProps = () => [
}, },
}), }),
CustomKeymap, CustomKeymap,
// ListKeymap,
CustomLinkExtension.configure({ CustomLinkExtension.configure({
openOnClick: true, openOnClick: true,
autolink: true, autolink: true,
@ -105,16 +100,4 @@ export const CoreEditorExtensionsWithoutProps = () => [
TableCell, TableCell,
TableRow, TableRow,
CustomMentionWithoutProps(), 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,
}),
]; ];

View file

@ -0,0 +1,3 @@
import { IssueWidgetWithoutProps } from "@/extensions/issue-embed";
export const DocumentEditorExtensionsWithoutProps = () => [IssueWidgetWithoutProps()];

View file

@ -70,6 +70,7 @@ export const CoreEditorExtensions = ({
codeBlock: false, codeBlock: false,
horizontalRule: false, horizontalRule: false,
blockquote: false, blockquote: false,
history: false,
dropcursor: { dropcursor: {
color: "rgba(var(--color-text-100))", color: "rgba(var(--color-text-100))",
width: 1, width: 1,

View file

@ -1,4 +1,6 @@
import ImageExt from "@tiptap/extension-image"; import ImageExt from "@tiptap/extension-image";
// helpers
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// plugins // plugins
import { import {
IMAGE_NODE_TYPE, IMAGE_NODE_TYPE,
@ -9,16 +11,13 @@ import {
} from "@/plugins/image"; } from "@/plugins/image";
// types // types
import { DeleteImage, RestoreImage } from "@/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) => export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreImage, cancelUploadImage?: () => void) =>
ImageExt.extend<any, ImageExtensionStorage>({ ImageExt.extend<any, ImageExtensionStorage>({
addKeyboardShortcuts() { addKeyboardShortcuts() {
return { return {
ArrowDown: insertLineBelowImageAction, ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", "image"),
ArrowUp: insertLineAboveImageAction, ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", "image"),
}; };
}, },
addProseMirrorPlugins() { addProseMirrorPlugins() {

View file

@ -1,25 +1,7 @@
import ImageExt from "@tiptap/extension-image"; 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 = () => export const ImageExtensionWithoutProps = () =>
ImageExt.extend({ 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() { addAttributes() {
return { return {
...this.parent?.(), ...this.parent?.(),

View file

@ -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;
}
};

View file

@ -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;
}
};

View file

@ -8,6 +8,7 @@ export * from "./mentions";
export * from "./table"; export * from "./table";
export * from "./typography"; export * from "./typography";
export * from "./core-without-props"; export * from "./core-without-props";
export * from "./document-without-props";
export * from "./custom-code-inline"; export * from "./custom-code-inline";
export * from "./drag-drop"; export * from "./drag-drop";
export * from "./drop"; export * from "./drop";

View file

@ -1 +1,2 @@
export * from "./widget-node"; export * from "./widget-node";
export * from "./issue-embed-without-props";

View file

@ -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)];
},
});

View file

@ -61,20 +61,3 @@ export const isValidHttpUrl = (string: string): boolean => {
return url.protocol === "http:" || url.protocol === "https:"; 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,
};
};

View file

@ -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
}
};

View file

@ -19,6 +19,7 @@ export { isCellSelection } from "@/extensions/table/table/utilities/is-cell-sele
// helpers // helpers
export * from "@/helpers/common"; export * from "@/helpers/common";
export * from "@/components/editors/document/helpers";
export * from "@/helpers/editor-commands"; export * from "@/helpers/editor-commands";
export * from "@/helpers/yjs"; export * from "@/helpers/yjs";
export * from "@/extensions/table/table"; export * from "@/extensions/table/table";

View file

@ -1,7 +1,12 @@
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
import { EditorRefApi, generateJSONfromHTML, proseMirrorJSONToBinaryString, applyUpdates } from "@plane/editor"; import {
EditorRefApi,
proseMirrorJSONToBinaryString,
applyUpdates,
generateJSONfromHTMLForDocumentEditor,
} from "@plane/editor";
// hooks // hooks
import { setToast, TOAST_TYPE } from "@plane/ui"; import { setToast, TOAST_TYPE } from "@plane/ui";
@ -71,7 +76,7 @@ export const usePageDescription = (props: Props) => {
const changeHTMLToBinary = async () => { const changeHTMLToBinary = async () => {
if (!pageDescriptionYJS || !pageDescription) return; if (!pageDescriptionYJS || !pageDescription) return;
if (pageDescriptionYJS.length === 0) { if (pageDescriptionYJS.length === 0) {
const { contentJSON, editorSchema } = generateJSONfromHTML(pageDescription ?? "<p></p>"); const { contentJSON, editorSchema } = generateJSONfromHTMLForDocumentEditor(pageDescription ?? "<p></p>");
const yDocBinaryString = proseMirrorJSONToBinaryString(contentJSON, "default", editorSchema); const yDocBinaryString = proseMirrorJSONToBinaryString(contentJSON, "default", editorSchema);
try { try {