[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
This commit is contained in:
Vipin Chaudhary
2025-12-10 00:24:36 +05:30
committed by GitHub
parent 362d29c7b0
commit 0bfb74d4c0
11 changed files with 96 additions and 107 deletions

View File

@@ -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:

View File

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

View File

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

View File

@@ -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 (
<NodeViewWrapper>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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