[WIKI-788] fix: editor markdown copy rules (#8140)

This commit is contained in:
Aaryan Khandelwal 2025-11-20 14:46:12 +05:30 committed by GitHub
parent f510020daa
commit d462546055
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1467 additions and 189 deletions

View file

@ -30,6 +30,7 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
fileHandler,
flaggedExtensions,
forwardedRef,
getEditorMetaData,
handleEditorReady,
id,
dragDropEnabled = true,
@ -57,6 +58,7 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
extensions,
fileHandler,
flaggedExtensions,
getEditorMetaData,
forwardedRef,
handleEditorReady,
id,

View file

@ -30,9 +30,10 @@ const DocumentEditor = (props: IDocumentEditorProps) => {
fileHandler,
flaggedExtensions,
forwardedRef,
getEditorMetaData,
handleEditorReady,
id,
isTouchDevice,
handleEditorReady,
mentionHandler,
onChange,
user,
@ -72,6 +73,7 @@ const DocumentEditor = (props: IDocumentEditorProps) => {
fileHandler,
flaggedExtensions,
forwardedRef,
getEditorMetaData,
handleEditorReady,
id,
initialValue: value,

View file

@ -27,6 +27,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
editorProps,
extendedEditorProps,
extensions,
getEditorMetaData,
id,
initialValue,
isTouchDevice,
@ -55,6 +56,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
fileHandler,
flaggedExtensions,
forwardedRef,
getEditorMetaData,
id,
isTouchDevice,
initialValue,

View file

@ -43,6 +43,7 @@ type TArguments = Pick<
| "disabledExtensions"
| "flaggedExtensions"
| "fileHandler"
| "getEditorMetaData"
| "isTouchDevice"
| "mentionHandler"
| "placeholder"
@ -60,6 +61,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
enableHistory,
fileHandler,
flaggedExtensions,
getEditorMetaData,
isTouchDevice = false,
mentionHandler,
placeholder,
@ -114,6 +116,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
UtilityExtension({
disabledExtensions,
fileHandler,
getEditorMetaData,
isEditable: editable,
isTouchDevice,
}),

View file

@ -1,7 +1,7 @@
import { Extension } from "@tiptap/core";
import codemark from "prosemirror-codemark";
// helpers
import type { CORE_EXTENSIONS } from "@/constants/extension";
import { CORE_EXTENSIONS } from "@/constants/extension";
import { restorePublicImages } from "@/helpers/image-helpers";
// plugins
import type { TAdditionalActiveDropbarExtensions } from "@/plane-editor/types/utils";
@ -50,18 +50,18 @@ export type UtilityExtensionStorage = {
isTouchDevice: boolean;
};
type Props = Pick<IEditorProps, "disabledExtensions"> & {
type Props = Pick<IEditorProps, "disabledExtensions" | "getEditorMetaData"> & {
fileHandler: TFileHandler;
isEditable: boolean;
isTouchDevice: boolean;
};
export const UtilityExtension = (props: Props) => {
const { disabledExtensions, fileHandler, isEditable, isTouchDevice } = props;
const { disabledExtensions, fileHandler, getEditorMetaData, isEditable, isTouchDevice } = props;
const { restore } = fileHandler;
return Extension.create<Record<string, unknown>, UtilityExtensionStorage>({
name: "utility",
name: CORE_EXTENSIONS.UTILITY,
priority: 1000,
addProseMirrorPlugins() {
@ -72,7 +72,10 @@ export const UtilityExtension = (props: Props) => {
fileHandler,
}),
...codemark({ markType: this.editor.schema.marks.code }),
MarkdownClipboardPlugin(this.editor),
MarkdownClipboardPlugin({
editor: this.editor,
getEditorMetaData,
}),
DropHandlerPlugin({
disabledExtensions,
editor: this.editor,

View file

@ -24,6 +24,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
extensions = [],
fileHandler,
flaggedExtensions,
getEditorMetaData,
forwardedRef,
handleEditorReady,
id,
@ -109,6 +110,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
fileHandler,
flaggedExtensions,
forwardedRef,
getEditorMetaData,
handleEditorReady,
isTouchDevice,
mentionHandler,

View file

@ -29,6 +29,7 @@ export const useEditor = (props: TEditorHookProps) => {
fileHandler,
flaggedExtensions,
forwardedRef,
getEditorMetaData,
handleEditorReady,
id = "",
initialValue,
@ -65,6 +66,7 @@ export const useEditor = (props: TEditorHookProps) => {
extendedEditorProps,
fileHandler,
flaggedExtensions,
getEditorMetaData,
isTouchDevice,
mentionHandler,
placeholder,

View file

@ -1,81 +1,44 @@
import type { Editor } from "@tiptap/core";
import type { Fragment } from "@tiptap/pm/model";
import { Node } from "@tiptap/pm/model";
import { Plugin, PluginKey } from "@tiptap/pm/state";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// plane imports
import { convertHTMLToMarkdown } from "@plane/utils";
import type { TCustomComponentsMetaData } from "@plane/utils";
export const MarkdownClipboardPlugin = (editor: Editor): Plugin =>
new Plugin({
type TArgs = {
editor: Editor;
getEditorMetaData: (htmlContent: string) => TCustomComponentsMetaData;
};
export const MarkdownClipboardPlugin = (args: TArgs): Plugin => {
const { editor, getEditorMetaData } = args;
return new Plugin({
key: new PluginKey("markdownClipboard"),
props: {
clipboardTextSerializer: (slice) => {
const markdownSerializer = editor.storage.markdown.serializer;
const isTableRow = slice.content.firstChild?.type?.name === CORE_EXTENSIONS.TABLE_ROW;
const nodeSelect = slice.openStart === 0 && slice.openEnd === 0;
if (nodeSelect) {
return markdownSerializer.serialize(slice.content);
}
const processTableContent = (tableNode: Node | Fragment) => {
let result = "";
tableNode.content?.forEach?.((tableRowNode: Node | Fragment) => {
tableRowNode.content?.forEach?.((cell: Node) => {
const cellContent = cell.content ? markdownSerializer.serialize(cell.content) : "";
result += cellContent + "\n";
handleDOMEvents: {
copy: (view, event) => {
try {
event.preventDefault();
event.clipboardData?.clearData();
// editor meta data
const editorHTML = editor.getHTML();
const metaData = getEditorMetaData(editorHTML);
// meta data from selection
const clipboardHTML = view.serializeForClipboard(view.state.selection.content()).dom.innerHTML;
// convert to markdown
const markdown = convertHTMLToMarkdown({
description_html: clipboardHTML,
metaData,
});
});
return result;
};
if (isTableRow) {
const rowsCount = slice.content?.childCount || 0;
const cellsCount = slice.content?.firstChild?.content?.childCount || 0;
if (rowsCount === 1 || cellsCount === 1) {
return processTableContent(slice.content);
} else {
return markdownSerializer.serialize(slice.content);
event.clipboardData?.setData("text/plain", markdown);
event.clipboardData?.setData("text/html", clipboardHTML);
return true;
} catch (error) {
console.error("Failed to copy markdown content to clipboard:", error);
return false;
}
}
const traverseToParentOfLeaf = (node: Node | null, parent: Fragment | Node, depth: number): Node | Fragment => {
let currentNode = node;
let currentParent = parent;
let currentDepth = depth;
while (currentNode && currentDepth > 1 && currentNode.content?.firstChild) {
if (currentNode.content?.childCount > 1) {
if (currentNode.content.firstChild?.type?.name === CORE_EXTENSIONS.LIST_ITEM) {
return currentParent;
} else {
return currentNode.content;
}
}
currentParent = currentNode;
currentNode = currentNode.content?.firstChild || null;
currentDepth--;
}
return currentParent;
};
if (slice.content.childCount > 1) {
return markdownSerializer.serialize(slice.content);
} else {
const targetNode = traverseToParentOfLeaf(slice.content.firstChild, slice.content, slice.openStart);
let currentNode = targetNode;
while (currentNode && currentNode.content && currentNode.childCount === 1 && currentNode.firstChild) {
currentNode = currentNode.firstChild;
}
if (currentNode instanceof Node && currentNode.isText) {
return currentNode.text;
}
return markdownSerializer.serialize(targetNode);
}
},
},
},
});
};

View file

@ -3,6 +3,8 @@ import type { MarkType, NodeType } from "@tiptap/pm/model";
import type { Selection } from "@tiptap/pm/state";
import type { EditorProps, EditorView } from "@tiptap/pm/view";
import type { NodeViewProps as TNodeViewProps } from "@tiptap/react";
// plane imports
import type { TCustomComponentsMetaData } from "@plane/utils";
// extension types
import type { TTextAlign } from "@/extensions";
// plane editor imports
@ -19,7 +21,6 @@ import type {
TDocumentEventEmitter,
TDocumentEventsServer,
TEditorAsset,
TEmbedConfig,
TExtensions,
TFileHandler,
TMentionHandler,
@ -151,6 +152,7 @@ export type IEditorProps = {
flaggedExtensions: TExtensions[];
fileHandler: TFileHandler;
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
getEditorMetaData: (htmlContent: string) => TCustomComponentsMetaData;
handleEditorReady?: (value: boolean) => void;
id: string;
initialValue: string;

View file

@ -11,6 +11,7 @@ type TCoreHookProps = Pick<
| "extendedEditorProps"
| "extensions"
| "flaggedExtensions"
| "getEditorMetaData"
| "handleEditorReady"
| "isTouchDevice"
| "onEditorFocus"

View file

@ -28,16 +28,26 @@
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dompurify": "3.2.7",
"hast": "^1.0.0",
"hast-util-to-mdast": "^10.1.2",
"lodash-es": "catalog:",
"lucide-react": "catalog:",
"mdast": "^3.0.0",
"react": "catalog:",
"rehype-parse": "^9.0.1",
"rehype-remark": "^10.0.1",
"remark-gfm": "^4.0.1",
"remark-stringify": "^11.0.0",
"tailwind-merge": "^2.5.5",
"unified": "^11.0.5",
"uuid": "catalog:"
},
"devDependencies": {
"@plane/eslint-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@types/hast": "^3.0.4",
"@types/lodash-es": "catalog:",
"@types/mdast": "^4.0.4",
"@types/node": "catalog:",
"@types/react": "catalog:",
"tsdown": "catalog:",

View file

@ -1,5 +1,5 @@
// local imports
import { getFileURL } from "./file";
import { getFileURL } from "../file";
type TEditorSrcArgs = {
assetId: string;

View file

@ -0,0 +1,2 @@
export * from "./common";
export * from "./markdown-parser";

View file

@ -0,0 +1,6 @@
import type { Text as MDASTText } from "mdast";
export const createTextNode = (value: string): MDASTText => ({
type: "text",
value,
});

View file

@ -0,0 +1,41 @@
import type { Handle } from "hast-util-to-mdast";
// local imports
import { createTextNode } from "./common";
import type { TCustomComponentsMetaData } from "./types";
type TArgs = {
metaData: TCustomComponentsMetaData;
};
export const parseCustomComponents = (args: TArgs): Record<string, Handle> => {
const { metaData } = args;
const getFileAssetDetails = (id: string) => metaData.file_assets.find((asset) => asset.id === id);
return {
"image-component": (_state, node) => {
const properties = node.properties || {};
const src = String(properties.src);
const fileAssetDetails = getFileAssetDetails(src);
if (!src || !fileAssetDetails) return createTextNode("");
return createTextNode(`![${fileAssetDetails.name}](${fileAssetDetails.url})`);
},
img: (_state, node) => {
const properties = node.properties || {};
const src = String(properties.src);
const alt = String(properties.alt);
if (!src || !alt) return createTextNode("");
return createTextNode(`![${alt || "Image"}](${src})`);
},
"mention-component": (_state, node) => {
const properties = node.properties || {};
const userId = String(properties.entity_identifier);
const userDetails = metaData.user_mentions.find((user) => user.id === userId);
if (!userDetails) return createTextNode("");
return createTextNode(`[@${userDetails.display_name || "Unknown user"}](${userDetails.url || ""}) `);
},
...parseExtendedCustomComponents({ metaData }),
};
};
export const parseExtendedCustomComponents = (_args: TArgs): Record<string, Handle> => ({});

View file

@ -0,0 +1,2 @@
export * from "./types";
export * from "./root";

View file

@ -0,0 +1,42 @@
import type { Handle } from "hast-util-to-mdast";
import type { PhrasingContent, Text as MDASTText } from "mdast";
// local imports
import { createTextNode } from "./common";
const processMarkElement = (state: Parameters<Handle>[0], node: Parameters<Handle>[1], wrapper: string): MDASTText => {
if (node.children && node.children.length > 0) {
// Process all children and collect their text content
const processedChildren: PhrasingContent[] = [];
for (const child of node.children) {
if (child.type === "text") {
// Direct text child - keep as is
processedChildren.push(child as MDASTText);
} else if (child.type === "element") {
// Element child - recursively process it
const processed = state.one(child, node);
if (processed) {
if (Array.isArray(processed)) {
processedChildren.push(...(processed as PhrasingContent[]));
} else {
processedChildren.push(processed as PhrasingContent);
}
}
}
}
// Concatenate all text content and wrap with the specified wrapper
const combinedText = processedChildren.map((child) => (child.type === "text" ? child.value : "")).join("");
return createTextNode(`${wrapper}${combinedText}${wrapper}`);
}
// Empty element - return empty text
return createTextNode("");
};
export const parseMarks: Record<string, Handle> = {
u: (state, node) => processMarkElement(state, node, ""),
i: (state, node) => processMarkElement(state, node, "_"),
em: (state, node) => processMarkElement(state, node, "_"),
};

View file

@ -0,0 +1,143 @@
// - Parses TipTap/ProseMirror HTML fragments
// - Removes <u> tags (Markdown has no underline)
// - Adds a space after checkbox inputs for correct GFM task list rendering
// - Converts to Markdown using rehype→remark, GFM, and remark-stringify
import type { Element as HASTElement, ElementContent, Parent as HASTParent } from "hast";
import rehypeParse from "rehype-parse";
import rehypeRemark from "rehype-remark";
import remarkGfm from "remark-gfm";
import remarkStringify from "remark-stringify";
import { unified } from "unified";
// local imports
import { parseCustomComponents } from "./custom-components-handler";
import { parseMarks } from "./marks-handler";
import type { TCustomComponentsMetaData } from "./types";
// Rehype plugin to handle TipTap task lists and convert them to GFM-compatible format
// TipTap structure: <li data-type="taskItem"><label><input><span></span></label><div><p>text</p></div></li>
// We need: <li><input> text (with space after checkbox for proper GFM rendering)
function addSpacesToCheckboxes() {
return (tree: HASTParent) => {
const helper = (node: HASTParent): void => {
if (!Array.isArray(node.children) || node.children.length === 0) return;
for (let i = 0; i < node.children.length; i++) {
const child = node.children[i];
// Check if this is a task list item
if (
child &&
child.type === "element" &&
child.tagName === "li" &&
child.properties &&
child.properties["data-type"] === "taskItem"
) {
const liElement = child as HASTElement;
// Find the label and div elements
const label = liElement.children?.find(
(c) => c.type === "element" && (c as HASTElement).tagName === "label"
) as HASTElement | undefined;
const contentDiv = liElement.children?.find(
(c) => c.type === "element" && (c as HASTElement).tagName === "div"
) as HASTElement | undefined;
if (label && contentDiv) {
// Find the checkbox input
const checkbox = label.children?.find(
(c) =>
c.type === "element" &&
(c as HASTElement).tagName === "input" &&
(c as HASTElement).properties?.type === "checkbox"
) as HASTElement | undefined;
if (checkbox) {
// Extract text content from the div, unwrapping any paragraph tags
const textContent: ElementContent[] = [];
if (contentDiv.children) {
for (const child of contentDiv.children) {
if (child.type === "element" && (child as HASTElement).tagName === "p") {
// Unwrap paragraph - add its children directly
const pElement = child as HASTElement;
if (pElement.children) {
textContent.push(...pElement.children);
}
} else {
// Keep other elements as-is
textContent.push(child);
}
}
}
// Flatten the structure: move checkbox and content to be direct children of li
liElement.children = [
checkbox,
{ type: "text", value: " " }, // Add space after checkbox
...textContent,
];
}
}
} else if (child && child.type === "element") {
helper(child as HASTElement);
}
}
};
helper(tree);
};
}
type TArgs = {
description_html: string;
metaData: TCustomComponentsMetaData;
name?: string;
};
/**
* Sanitizes a string by escaping HTML entities to prevent XSS attacks
* @param str - The string to sanitize
* @returns The sanitized string with escaped HTML entities
*/
function sanitizeHTML(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#x27;");
}
export function convertHTMLToMarkdown(args: TArgs): string {
const { description_html, metaData, name } = args;
let updatedDescriptionHtml = description_html;
if (name) {
const sanitizedName = sanitizeHTML(name);
updatedDescriptionHtml = `<h1>${sanitizedName}</h1>\n\n${description_html}`;
}
const result = unified()
.use(rehypeParse, { fragment: true })
.use(addSpacesToCheckboxes)
.use(rehypeRemark, {
handlers: {
...parseCustomComponents({
metaData,
}),
...parseMarks,
},
})
.use(remarkGfm)
.use(remarkStringify, {
handlers: {
text: (node: { value: string }): string => node.value,
},
})
.processSync(updatedDescriptionHtml);
const markdown = String(result.value ?? result);
return markdown;
}

View file

@ -0,0 +1,16 @@
export type TCoreCustomComponentsMetaData = {
file_assets: {
id: string;
name: string;
url: string;
}[];
user_mentions: {
id: string;
display_name: string;
url: string;
}[];
};
export type TExtendedCustomComponentsMetaData = unknown;
export type TCustomComponentsMetaData = TCoreCustomComponentsMetaData & TExtendedCustomComponentsMetaData;