mirror of
https://github.com/makeplane/plane.git
synced 2025-12-21 05:10:24 -06:00
[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:
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
28
packages/editor/src/core/helpers/paste-asset.ts
Normal file
28
packages/editor/src/core/helpers/paste-asset.ts
Normal 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 };
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user