[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:
parent
362d29c7b0
commit
0bfb74d4c0
11 changed files with 96 additions and 107 deletions
|
|
@ -766,7 +766,7 @@ class DuplicateAssetEndpoint(BaseAPIView):
|
||||||
|
|
||||||
return {}
|
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):
|
def post(self, request, slug, asset_id):
|
||||||
project_id = request.data.get("project_id", None)
|
project_id = request.data.get("project_id", None)
|
||||||
entity_id = request.data.get("entity_id", None)
|
entity_id = request.data.get("entity_id", None)
|
||||||
|
|
@ -792,7 +792,7 @@ class DuplicateAssetEndpoint(BaseAPIView):
|
||||||
|
|
||||||
storage = S3Storage(request=request)
|
storage = S3Storage(request=request)
|
||||||
original_asset = FileAsset.objects.filter(
|
original_asset = FileAsset.objects.filter(
|
||||||
workspace=workspace, id=asset_id, is_uploaded=True
|
id=asset_id, is_uploaded=True
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not original_asset:
|
if not original_asset:
|
||||||
|
|
|
||||||
|
|
@ -59,13 +59,12 @@ export const DescriptionVersionsModal = observer(function DescriptionVersionsMod
|
||||||
|
|
||||||
const handleCopyMarkdown = useCallback(() => {
|
const handleCopyMarkdown = useCallback(() => {
|
||||||
if (!editorRef.current) return;
|
if (!editorRef.current) return;
|
||||||
copyTextToClipboard(editorRef.current.getMarkDown()).then(() =>
|
editorRef.current.copyMarkdownToClipboard();
|
||||||
setToast({
|
setToast({
|
||||||
type: TOAST_TYPE.SUCCESS,
|
type: TOAST_TYPE.SUCCESS,
|
||||||
title: t("toast.success"),
|
title: t("toast.success"),
|
||||||
message: "Markdown copied to clipboard.",
|
message: "Markdown copied to clipboard.",
|
||||||
})
|
});
|
||||||
);
|
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
if (!workspaceId) return null;
|
if (!workspaceId) return null;
|
||||||
|
|
|
||||||
|
|
@ -71,13 +71,12 @@ export const PageOptionsDropdown = observer(function PageOptionsDropdown(props:
|
||||||
key: "copy-markdown",
|
key: "copy-markdown",
|
||||||
action: () => {
|
action: () => {
|
||||||
if (!editorRef) return;
|
if (!editorRef) return;
|
||||||
copyTextToClipboard(editorRef.getMarkDown()).then(() =>
|
editorRef.copyMarkdownToClipboard();
|
||||||
setToast({
|
setToast({
|
||||||
type: TOAST_TYPE.SUCCESS,
|
type: TOAST_TYPE.SUCCESS,
|
||||||
title: "Success!",
|
title: "Success!",
|
||||||
message: "Markdown copied to clipboard.",
|
message: "Markdown copied to clipboard.",
|
||||||
})
|
});
|
||||||
);
|
|
||||||
},
|
},
|
||||||
title: "Copy markdown",
|
title: "Copy markdown",
|
||||||
icon: Clipboard,
|
icon: Clipboard,
|
||||||
|
|
|
||||||
|
|
@ -56,16 +56,24 @@ export function CustomImageNodeView(props: CustomImageNodeViewProps) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setResolvedSrc(undefined);
|
||||||
|
setResolvedDownloadSrc(undefined);
|
||||||
|
setFailedToLoadImage(false);
|
||||||
|
|
||||||
const getImageSource = async () => {
|
const getImageSource = async () => {
|
||||||
|
try {
|
||||||
const url = await extension.options.getImageSource?.(imgNodeSrc);
|
const url = await extension.options.getImageSource?.(imgNodeSrc);
|
||||||
setResolvedSrc(url);
|
setResolvedSrc(url);
|
||||||
const downloadUrl = await extension.options.getImageDownloadSource?.(imgNodeSrc);
|
const downloadUrl = await extension.options.getImageDownloadSource?.(imgNodeSrc);
|
||||||
setResolvedDownloadSrc(downloadUrl);
|
setResolvedDownloadSrc(downloadUrl);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching image source:", error);
|
||||||
|
setFailedToLoadImage(true);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
getImageSource();
|
getImageSource();
|
||||||
}, [imgNodeSrc, extension.options]);
|
}, [imgNodeSrc, extension.options]);
|
||||||
|
|
||||||
// Handle image duplication when status is duplicating
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleDuplication = async () => {
|
const handleDuplication = async () => {
|
||||||
if (status !== ECustomImageStatus.DUPLICATING || !extension.options.duplicateImage || !imgNodeSrc) {
|
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");
|
throw new Error("Duplication returned invalid asset ID");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update node with new source and success status
|
setFailedToLoadImage(false);
|
||||||
updateAttributes({
|
updateAttributes({ src: newAssetId, status: ECustomImageStatus.UPLOADED });
|
||||||
src: newAssetId,
|
|
||||||
status: ECustomImageStatus.UPLOADED,
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error("Failed to duplicate image:", error);
|
console.error("Failed to duplicate image:", error);
|
||||||
// Update status to failed
|
// Update status to failed
|
||||||
|
|
@ -115,11 +120,13 @@ export function CustomImageNodeView(props: CustomImageNodeViewProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === ECustomImageStatus.UPLOADED) {
|
if (status === ECustomImageStatus.UPLOADED) {
|
||||||
hasRetriedOnMount.current = false;
|
hasRetriedOnMount.current = false;
|
||||||
|
setFailedToLoadImage(false);
|
||||||
}
|
}
|
||||||
}, [status]);
|
}, [status]);
|
||||||
|
|
||||||
const hasDuplicationFailed = hasImageDuplicationFailed(status);
|
const hasDuplicationFailed = hasImageDuplicationFailed(status);
|
||||||
const shouldShowBlock = (isUploaded || imageFromFileSystem) && !failedToLoadImage;
|
const hasValidImageSource = imageFromFileSystem || (isUploaded && resolvedSrc);
|
||||||
|
const shouldShowBlock = hasValidImageSource && !failedToLoadImage && !hasDuplicationFailed;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper>
|
<NodeViewWrapper>
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ import type { TAdditionalActiveDropbarExtensions } from "@/plane-editor/types/ut
|
||||||
import { DropHandlerPlugin } from "@/plugins/drop";
|
import { DropHandlerPlugin } from "@/plugins/drop";
|
||||||
import { FilePlugins } from "@/plugins/file/root";
|
import { FilePlugins } from "@/plugins/file/root";
|
||||||
import { MarkdownClipboardPlugin } from "@/plugins/markdown-clipboard";
|
import { MarkdownClipboardPlugin } from "@/plugins/markdown-clipboard";
|
||||||
// types
|
|
||||||
import { PasteAssetPlugin } from "@/plugins/paste-asset";
|
|
||||||
import type { IEditorProps, TEditorAsset, TFileHandler } from "@/types";
|
import type { IEditorProps, TEditorAsset, TFileHandler } from "@/types";
|
||||||
|
|
||||||
type TActiveDropbarExtensions =
|
type TActiveDropbarExtensions =
|
||||||
|
|
@ -82,7 +80,6 @@ export const UtilityExtension = (props: Props) => {
|
||||||
flaggedExtensions,
|
flaggedExtensions,
|
||||||
editor: this.editor,
|
editor: this.editor,
|
||||||
}),
|
}),
|
||||||
PasteAssetPlugin(),
|
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,27 @@ export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {
|
||||||
});
|
});
|
||||||
return markdown;
|
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: () => {
|
isAnyDropbarOpen: () => {
|
||||||
if (!editor) return false;
|
if (!editor) return false;
|
||||||
const utilityStorage = editor.storage.utility;
|
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/plain", markdown);
|
||||||
event.clipboardData?.setData("text/html", clipboardHTML);
|
event.clipboardData?.setData("text/html", clipboardHTML);
|
||||||
|
event.clipboardData?.setData("text/plane-editor-html", clipboardHTML);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to copy markdown content to clipboard:", 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";
|
import type { EditorProps } from "@tiptap/pm/view";
|
||||||
// plane utils
|
// plane utils
|
||||||
import { cn } from "@plane/utils";
|
import { cn } from "@plane/utils";
|
||||||
|
// helpers
|
||||||
|
import { processAssetDuplication } from "@/helpers/paste-asset";
|
||||||
|
|
||||||
type TArgs = {
|
type TArgs = {
|
||||||
editorClassName: string;
|
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;
|
getDocumentInfo: () => TDocumentInfo;
|
||||||
getHeadings: () => IMarking[];
|
getHeadings: () => IMarking[];
|
||||||
getMarkDown: () => string;
|
getMarkDown: () => string;
|
||||||
|
copyMarkdownToClipboard: () => void;
|
||||||
getSelectedText: () => string | null;
|
getSelectedText: () => string | null;
|
||||||
insertText: (contentHTML: string, insertOnNextLine?: boolean) => void;
|
insertText: (contentHTML: string, insertOnNextLine?: boolean) => void;
|
||||||
isAnyDropbarOpen: () => boolean;
|
isAnyDropbarOpen: () => boolean;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue