From 0bfb74d4c00e004d17b454ec2a99a5c4c1d3efd9 Mon Sep 17 00:00:00 2001 From: Vipin Chaudhary Date: Wed, 10 Dec 2025 00:24:36 +0530 Subject: [PATCH] [WIKI-830] fix: copy clipboard functionality in the editor (#8229) * feat: enhance clipboard functionality for markdown and HTML content * fix: improve error handling and state management in CustomImageNodeView component * fix: correct asset retrieval query by removing workspace filter in DuplicateAssetEndpoint * fix: update meta tag creation in PasteAssetPlugin for clipboard HTML content * feat: implement copyMarkdownToClipboard utility for enhanced clipboard functionality * refactor: replace copyMarkdownToClipboard utility with copyTextToClipboard for simplified clipboard operations * refactor: streamline clipboard operations by replacing copyTextToClipboard with copyMarkdownToClipboard in editor components * refactor: simplify PasteAssetPlugin by removing unnecessary meta tag handling and streamlining HTML processing * feat: implement asset duplication processing on paste for enhanced clipboard functionality * chore:remove async from copy markdown method * chore: add paste html * remove:prevent default * refactor: remove hasChanges from processAssetDuplication return type for simplified asset processing * fix: format options-dropdown.tsx --- apps/api/plane/app/views/asset/v2.py | 4 +- .../core/description-versions/modal.tsx | 13 ++-- .../pages/editor/toolbar/options-dropdown.tsx | 13 ++-- .../custom-image/components/node-view.tsx | 29 ++++--- .../editor/src/core/extensions/utility.ts | 3 - .../editor/src/core/helpers/editor-ref.ts | 21 +++++ .../editor/src/core/helpers/paste-asset.ts | 28 +++++++ .../src/core/plugins/markdown-clipboard.ts | 1 + .../editor/src/core/plugins/paste-asset.ts | 77 ------------------- packages/editor/src/core/props.ts | 13 ++++ packages/editor/src/core/types/editor.ts | 1 + 11 files changed, 96 insertions(+), 107 deletions(-) create mode 100644 packages/editor/src/core/helpers/paste-asset.ts delete mode 100644 packages/editor/src/core/plugins/paste-asset.ts diff --git a/apps/api/plane/app/views/asset/v2.py b/apps/api/plane/app/views/asset/v2.py index c0580c1149..b8b27eeae0 100644 --- a/apps/api/plane/app/views/asset/v2.py +++ b/apps/api/plane/app/views/asset/v2.py @@ -766,7 +766,7 @@ class DuplicateAssetEndpoint(BaseAPIView): return {} - @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def post(self, request, slug, asset_id): project_id = request.data.get("project_id", None) entity_id = request.data.get("entity_id", None) @@ -792,7 +792,7 @@ class DuplicateAssetEndpoint(BaseAPIView): storage = S3Storage(request=request) original_asset = FileAsset.objects.filter( - workspace=workspace, id=asset_id, is_uploaded=True + id=asset_id, is_uploaded=True ).first() if not original_asset: diff --git a/apps/web/core/components/core/description-versions/modal.tsx b/apps/web/core/components/core/description-versions/modal.tsx index fbb04b5c09..b393c80311 100644 --- a/apps/web/core/components/core/description-versions/modal.tsx +++ b/apps/web/core/components/core/description-versions/modal.tsx @@ -59,13 +59,12 @@ export const DescriptionVersionsModal = observer(function DescriptionVersionsMod const handleCopyMarkdown = useCallback(() => { if (!editorRef.current) return; - copyTextToClipboard(editorRef.current.getMarkDown()).then(() => - setToast({ - type: TOAST_TYPE.SUCCESS, - title: t("toast.success"), - message: "Markdown copied to clipboard.", - }) - ); + editorRef.current.copyMarkdownToClipboard(); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("toast.success"), + message: "Markdown copied to clipboard.", + }); }, [t]); if (!workspaceId) return null; diff --git a/apps/web/core/components/pages/editor/toolbar/options-dropdown.tsx b/apps/web/core/components/pages/editor/toolbar/options-dropdown.tsx index 572f0babec..5655469ccd 100644 --- a/apps/web/core/components/pages/editor/toolbar/options-dropdown.tsx +++ b/apps/web/core/components/pages/editor/toolbar/options-dropdown.tsx @@ -71,13 +71,12 @@ export const PageOptionsDropdown = observer(function PageOptionsDropdown(props: key: "copy-markdown", action: () => { if (!editorRef) return; - copyTextToClipboard(editorRef.getMarkDown()).then(() => - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Success!", - message: "Markdown copied to clipboard.", - }) - ); + editorRef.copyMarkdownToClipboard(); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Markdown copied to clipboard.", + }); }, title: "Copy markdown", icon: Clipboard, diff --git a/packages/editor/src/core/extensions/custom-image/components/node-view.tsx b/packages/editor/src/core/extensions/custom-image/components/node-view.tsx index f8143c52c8..3ad4e46187 100644 --- a/packages/editor/src/core/extensions/custom-image/components/node-view.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/node-view.tsx @@ -56,16 +56,24 @@ export function CustomImageNodeView(props: CustomImageNodeViewProps) { return; } + setResolvedSrc(undefined); + setResolvedDownloadSrc(undefined); + setFailedToLoadImage(false); + const getImageSource = async () => { - const url = await extension.options.getImageSource?.(imgNodeSrc); - setResolvedSrc(url); - const downloadUrl = await extension.options.getImageDownloadSource?.(imgNodeSrc); - setResolvedDownloadSrc(downloadUrl); + try { + const url = await extension.options.getImageSource?.(imgNodeSrc); + setResolvedSrc(url); + const downloadUrl = await extension.options.getImageDownloadSource?.(imgNodeSrc); + setResolvedDownloadSrc(downloadUrl); + } catch (error) { + console.error("Error fetching image source:", error); + setFailedToLoadImage(true); + } }; getImageSource(); }, [imgNodeSrc, extension.options]); - // Handle image duplication when status is duplicating useEffect(() => { const handleDuplication = async () => { if (status !== ECustomImageStatus.DUPLICATING || !extension.options.duplicateImage || !imgNodeSrc) { @@ -87,11 +95,8 @@ export function CustomImageNodeView(props: CustomImageNodeViewProps) { throw new Error("Duplication returned invalid asset ID"); } - // Update node with new source and success status - updateAttributes({ - src: newAssetId, - status: ECustomImageStatus.UPLOADED, - }); + setFailedToLoadImage(false); + updateAttributes({ src: newAssetId, status: ECustomImageStatus.UPLOADED }); } catch (error: unknown) { console.error("Failed to duplicate image:", error); // Update status to failed @@ -115,11 +120,13 @@ export function CustomImageNodeView(props: CustomImageNodeViewProps) { useEffect(() => { if (status === ECustomImageStatus.UPLOADED) { hasRetriedOnMount.current = false; + setFailedToLoadImage(false); } }, [status]); const hasDuplicationFailed = hasImageDuplicationFailed(status); - const shouldShowBlock = (isUploaded || imageFromFileSystem) && !failedToLoadImage; + const hasValidImageSource = imageFromFileSystem || (isUploaded && resolvedSrc); + const shouldShowBlock = hasValidImageSource && !failedToLoadImage && !hasDuplicationFailed; return ( diff --git a/packages/editor/src/core/extensions/utility.ts b/packages/editor/src/core/extensions/utility.ts index 167ba298e0..558136347e 100644 --- a/packages/editor/src/core/extensions/utility.ts +++ b/packages/editor/src/core/extensions/utility.ts @@ -8,8 +8,6 @@ import type { TAdditionalActiveDropbarExtensions } from "@/plane-editor/types/ut import { DropHandlerPlugin } from "@/plugins/drop"; import { FilePlugins } from "@/plugins/file/root"; import { MarkdownClipboardPlugin } from "@/plugins/markdown-clipboard"; -// types -import { PasteAssetPlugin } from "@/plugins/paste-asset"; import type { IEditorProps, TEditorAsset, TFileHandler } from "@/types"; type TActiveDropbarExtensions = @@ -82,7 +80,6 @@ export const UtilityExtension = (props: Props) => { flaggedExtensions, editor: this.editor, }), - PasteAssetPlugin(), ]; }, diff --git a/packages/editor/src/core/helpers/editor-ref.ts b/packages/editor/src/core/helpers/editor-ref.ts index bc81364af1..c7be617bcc 100644 --- a/packages/editor/src/core/helpers/editor-ref.ts +++ b/packages/editor/src/core/helpers/editor-ref.ts @@ -89,6 +89,27 @@ export const getEditorRefHelpers = (args: TArgs): EditorRefApi => { }); return markdown; }, + copyMarkdownToClipboard: () => { + if (!editor) return; + + const html = editor.getHTML(); + const metaData = getEditorMetaData(html); + const markdown = convertHTMLToMarkdown({ + description_html: html, + metaData, + }); + + const copyHandler = (event: ClipboardEvent) => { + event.preventDefault(); + event.clipboardData?.setData("text/plain", markdown); + event.clipboardData?.setData("text/html", html); + event.clipboardData?.setData("text/plane-editor-html", html); + document.removeEventListener("copy", copyHandler); + }; + + document.addEventListener("copy", copyHandler); + document.execCommand("copy"); + }, isAnyDropbarOpen: () => { if (!editor) return false; const utilityStorage = editor.storage.utility; diff --git a/packages/editor/src/core/helpers/paste-asset.ts b/packages/editor/src/core/helpers/paste-asset.ts new file mode 100644 index 0000000000..c60e23aaa5 --- /dev/null +++ b/packages/editor/src/core/helpers/paste-asset.ts @@ -0,0 +1,28 @@ +import { assetDuplicationHandlers } from "@/plane-editor/helpers/asset-duplication"; + +// Utility function to process HTML content with all registered handlers +export const processAssetDuplication = (htmlContent: string): { processedHtml: string } => { + const tempDiv = document.createElement("div"); + tempDiv.innerHTML = htmlContent; + + let processedHtml = htmlContent; + + // Process each registered component type + for (const [componentName, handler] of Object.entries(assetDuplicationHandlers)) { + const elements = tempDiv.querySelectorAll(componentName); + + if (elements.length > 0) { + elements.forEach((element) => { + const result = handler({ element, originalHtml: processedHtml }); + if (result.shouldProcess) { + processedHtml = result.modifiedHtml; + } + }); + + // Update tempDiv with processed HTML for next iteration + tempDiv.innerHTML = processedHtml; + } + } + + return { processedHtml }; +}; diff --git a/packages/editor/src/core/plugins/markdown-clipboard.ts b/packages/editor/src/core/plugins/markdown-clipboard.ts index de34a027b4..a97e065890 100644 --- a/packages/editor/src/core/plugins/markdown-clipboard.ts +++ b/packages/editor/src/core/plugins/markdown-clipboard.ts @@ -32,6 +32,7 @@ export const MarkdownClipboardPlugin = (args: TArgs): Plugin => { }); event.clipboardData?.setData("text/plain", markdown); event.clipboardData?.setData("text/html", clipboardHTML); + event.clipboardData?.setData("text/plane-editor-html", clipboardHTML); return true; } catch (error) { console.error("Failed to copy markdown content to clipboard:", error); diff --git a/packages/editor/src/core/plugins/paste-asset.ts b/packages/editor/src/core/plugins/paste-asset.ts deleted file mode 100644 index 67ab9056d3..0000000000 --- a/packages/editor/src/core/plugins/paste-asset.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Plugin, PluginKey } from "@tiptap/pm/state"; -import { assetDuplicationHandlers } from "@/plane-editor/helpers/asset-duplication"; - -export const PasteAssetPlugin = (): Plugin => - new Plugin({ - key: new PluginKey("paste-asset-duplication"), - props: { - handlePaste: (view, event) => { - if (!event.clipboardData) return false; - - const htmlContent = event.clipboardData.getData("text/html"); - if (!htmlContent || htmlContent.includes('data-uploaded="true"')) return false; - - // Process the HTML content using the registry - const { processedHtml, hasChanges } = processAssetDuplication(htmlContent); - - if (!hasChanges) return false; - - event.preventDefault(); - event.stopPropagation(); - - // Mark the content as already processed to avoid infinite loops - const tempDiv = document.createElement("div"); - tempDiv.innerHTML = processedHtml; - const metaTag = tempDiv.querySelector("meta[charset='utf-8']"); - if (metaTag) { - metaTag.setAttribute("data-uploaded", "true"); - } - const finalHtml = tempDiv.innerHTML; - - const newDataTransfer = new DataTransfer(); - newDataTransfer.setData("text/html", finalHtml); - if (event.clipboardData) { - newDataTransfer.setData("text/plain", event.clipboardData.getData("text/plain")); - } - - const pasteEvent = new ClipboardEvent("paste", { - clipboardData: newDataTransfer, - bubbles: true, - cancelable: true, - }); - - view.dom.dispatchEvent(pasteEvent); - - return true; - }, - }, - }); - -// Utility function to process HTML content with all registered handlers -const processAssetDuplication = (htmlContent: string): { processedHtml: string; hasChanges: boolean } => { - const tempDiv = document.createElement("div"); - tempDiv.innerHTML = htmlContent; - - let processedHtml = htmlContent; - let hasChanges = false; - - // Process each registered component type - for (const [componentName, handler] of Object.entries(assetDuplicationHandlers)) { - const elements = tempDiv.querySelectorAll(componentName); - - if (elements.length > 0) { - elements.forEach((element) => { - const result = handler({ element, originalHtml: processedHtml }); - if (result.shouldProcess) { - processedHtml = result.modifiedHtml; - hasChanges = true; - } - }); - - // Update tempDiv with processed HTML for next iteration - tempDiv.innerHTML = processedHtml; - } - } - - return { processedHtml, hasChanges }; -}; diff --git a/packages/editor/src/core/props.ts b/packages/editor/src/core/props.ts index 98821d67d2..d20b5372d4 100644 --- a/packages/editor/src/core/props.ts +++ b/packages/editor/src/core/props.ts @@ -1,6 +1,9 @@ +import { DOMParser } from "@tiptap/pm/model"; import type { EditorProps } from "@tiptap/pm/view"; // plane utils import { cn } from "@plane/utils"; +// helpers +import { processAssetDuplication } from "@/helpers/paste-asset"; type TArgs = { editorClassName: string; @@ -27,5 +30,15 @@ export const CoreEditorProps = (props: TArgs): EditorProps => { } }, }, + handlePaste: (view, event) => { + if (!event.clipboardData) return false; + + const htmlContent = event.clipboardData.getData("text/plane-editor-html"); + if (!htmlContent) return false; + + const { processedHtml } = processAssetDuplication(htmlContent); + view.pasteHTML(processedHtml); + return true; + }, }; }; diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index 2c7f9bbf3d..44b6388f25 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -118,6 +118,7 @@ export type EditorRefApi = { getDocumentInfo: () => TDocumentInfo; getHeadings: () => IMarking[]; getMarkDown: () => string; + copyMarkdownToClipboard: () => void; getSelectedText: () => string | null; insertText: (contentHTML: string, insertOnNextLine?: boolean) => void; isAnyDropbarOpen: () => boolean;