mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 14:43:06 +08:00
feat: editor attachment paste handling (#1975)
* reupload attachments if uploaded to a different page * use image dimensions on paste/DnD * tooltips withinPortal:false * isolating attribute
This commit is contained in:
@@ -132,7 +132,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<div className={classes.toolbar}>
|
||||
<Tooltip position="top" label={t("Info")}>
|
||||
<Tooltip position="top" label={t("Info")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={() => setCalloutType("info")}
|
||||
size="lg"
|
||||
@@ -147,7 +147,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Note")}>
|
||||
<Tooltip position="top" label={t("Note")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={() => setCalloutType("note")}
|
||||
size="lg"
|
||||
@@ -159,7 +159,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Success")}>
|
||||
<Tooltip position="top" label={t("Success")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={() => setCalloutType("success")}
|
||||
size="lg"
|
||||
@@ -174,7 +174,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Warning")}>
|
||||
<Tooltip position="top" label={t("Warning")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={() => setCalloutType("warning")}
|
||||
size="lg"
|
||||
@@ -189,7 +189,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Danger")}>
|
||||
<Tooltip position="top" label={t("Danger")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={() => setCalloutType("danger")}
|
||||
size="lg"
|
||||
|
||||
@@ -4,6 +4,20 @@ import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
|
||||
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
|
||||
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import {
|
||||
getAttachmentInfo,
|
||||
uploadFile,
|
||||
} from "@/features/page/services/page-service.ts";
|
||||
|
||||
const ATTACHMENT_NODE_TYPES = [
|
||||
"image",
|
||||
"video",
|
||||
"attachment",
|
||||
"excalidraw",
|
||||
"drawio",
|
||||
];
|
||||
|
||||
const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//;
|
||||
|
||||
export const handlePaste = (
|
||||
editor: Editor,
|
||||
@@ -57,9 +71,152 @@ export const handlePaste = (
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const htmlData = event.clipboardData?.getData("text/html");
|
||||
if (htmlData && ATTACHMENT_URL_RE.test(htmlData)) {
|
||||
const pasteFrom = editor.state.selection.from;
|
||||
setTimeout(() => {
|
||||
reuploadPastedAttachments(editor, pageId, pasteFrom);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
async function reuploadPastedAttachments(
|
||||
editor: Editor,
|
||||
pageId: string,
|
||||
pasteFrom: number,
|
||||
) {
|
||||
const pasteEnd = editor.state.selection.from;
|
||||
if (pasteEnd <= pasteFrom) return;
|
||||
|
||||
type PastedNode = {
|
||||
pos: number;
|
||||
attachmentId: string;
|
||||
nodeTypeName: string;
|
||||
src?: string;
|
||||
url?: string;
|
||||
fileName?: string;
|
||||
};
|
||||
|
||||
const pastedNodes: PastedNode[] = [];
|
||||
const seenAttachmentIds = new Set<string>();
|
||||
|
||||
editor.state.doc.nodesBetween(pasteFrom, pasteEnd, (node, pos) => {
|
||||
if (!ATTACHMENT_NODE_TYPES.includes(node.type.name)) return;
|
||||
const attachmentId = node.attrs.attachmentId;
|
||||
if (!attachmentId) return;
|
||||
|
||||
const src = node.attrs.src || node.attrs.url || "";
|
||||
const match = ATTACHMENT_URL_RE.exec(src);
|
||||
if (!match) return;
|
||||
|
||||
const fileName =
|
||||
node.attrs.name || src.split("/").pop() || "file";
|
||||
|
||||
pastedNodes.push({
|
||||
pos,
|
||||
attachmentId,
|
||||
nodeTypeName: node.type.name,
|
||||
src: node.attrs.src,
|
||||
url: node.attrs.url,
|
||||
fileName,
|
||||
});
|
||||
seenAttachmentIds.add(attachmentId);
|
||||
});
|
||||
|
||||
if (pastedNodes.length === 0) return;
|
||||
|
||||
const attachmentPageMap = new Map<string, string | null>();
|
||||
await Promise.all(
|
||||
[...seenAttachmentIds].map(async (id) => {
|
||||
try {
|
||||
const info = await getAttachmentInfo(id);
|
||||
attachmentPageMap.set(id, info.pageId);
|
||||
} catch {
|
||||
attachmentPageMap.set(id, null);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const nodesToReupload = pastedNodes.filter((n) => {
|
||||
const ownerPageId = attachmentPageMap.get(n.attachmentId);
|
||||
return ownerPageId !== null && ownerPageId !== pageId;
|
||||
});
|
||||
|
||||
if (nodesToReupload.length === 0) return;
|
||||
|
||||
const uniqueNodes = new Map<string, (typeof nodesToReupload)[0]>();
|
||||
for (const node of nodesToReupload) {
|
||||
if (!uniqueNodes.has(node.attachmentId)) {
|
||||
uniqueNodes.set(node.attachmentId, node);
|
||||
}
|
||||
}
|
||||
|
||||
const reuploadResults = new Map<
|
||||
string,
|
||||
{ id: string; fileName: string; fileSize: number; mimeType: string }
|
||||
>();
|
||||
|
||||
await Promise.all(
|
||||
[...uniqueNodes.values()].map(async (node) => {
|
||||
const fileUrl = node.src || node.url;
|
||||
if (!fileUrl) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(fileUrl, { credentials: "include" });
|
||||
if (!response.ok) return;
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], node.fileName, { type: blob.type });
|
||||
const newAttachment = await uploadFile(file, pageId);
|
||||
reuploadResults.set(node.attachmentId, {
|
||||
id: newAttachment.id,
|
||||
fileName: newAttachment.fileName,
|
||||
fileSize: newAttachment.fileSize,
|
||||
mimeType: newAttachment.mimeType,
|
||||
});
|
||||
} catch {
|
||||
// keep original reference on failure
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (reuploadResults.size === 0) return;
|
||||
|
||||
editor.chain().command(({ tr }) => {
|
||||
const sorted = [...nodesToReupload].sort((a, b) => b.pos - a.pos);
|
||||
|
||||
for (const pastedNode of sorted) {
|
||||
const result = reuploadResults.get(pastedNode.attachmentId);
|
||||
if (!result) continue;
|
||||
|
||||
const node = tr.doc.nodeAt(pastedNode.pos);
|
||||
if (!node || node.attrs.attachmentId !== pastedNode.attachmentId)
|
||||
continue;
|
||||
|
||||
const newAttrs = { ...node.attrs };
|
||||
newAttrs.attachmentId = result.id;
|
||||
|
||||
if (newAttrs.src) {
|
||||
newAttrs.src = `/api/files/${result.id}/${result.fileName}`;
|
||||
}
|
||||
if (newAttrs.url) {
|
||||
newAttrs.url = `/api/files/${result.id}/${result.fileName}`;
|
||||
}
|
||||
if (pastedNode.nodeTypeName === "attachment") {
|
||||
newAttrs.name = result.fileName;
|
||||
newAttrs.mime = result.mimeType;
|
||||
newAttrs.size = result.fileSize;
|
||||
}
|
||||
|
||||
tr.setNodeMarkup(pastedNode.pos, undefined, newAttrs);
|
||||
}
|
||||
|
||||
return true;
|
||||
}).run();
|
||||
}
|
||||
|
||||
export const handleFileDrop = (
|
||||
editor: Editor,
|
||||
event: DragEvent,
|
||||
|
||||
@@ -6,7 +6,12 @@ import {
|
||||
EditorMenuProps,
|
||||
ShouldShowProps,
|
||||
} from "@/features/editor/components/table/types/types.ts";
|
||||
import { ActionIcon, Modal, Tooltip, useComputedColorScheme } from "@mantine/core";
|
||||
import {
|
||||
ActionIcon,
|
||||
Modal,
|
||||
Tooltip,
|
||||
useComputedColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
@@ -194,7 +199,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<div className={classes.toolbar}>
|
||||
<Tooltip position="top" label={t("Align left")}>
|
||||
<Tooltip position="top" label={t("Align left")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={alignLeft}
|
||||
size="lg"
|
||||
@@ -206,7 +211,11 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Align center")}>
|
||||
<Tooltip
|
||||
position="top"
|
||||
label={t("Align center")}
|
||||
withinPortal={false}
|
||||
>
|
||||
<ActionIcon
|
||||
onClick={alignCenter}
|
||||
size="lg"
|
||||
@@ -232,7 +241,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Edit")}>
|
||||
<Tooltip position="top" label={t("Edit")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleOpen}
|
||||
size="lg"
|
||||
@@ -243,7 +252,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Download")}>
|
||||
<Tooltip position="top" label={t("Download")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleDownload}
|
||||
size="lg"
|
||||
@@ -254,7 +263,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Delete")}>
|
||||
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleDelete}
|
||||
size="lg"
|
||||
|
||||
@@ -79,8 +79,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
editor.isActive("excalidraw") &&
|
||||
editor.getAttributes("excalidraw")?.src
|
||||
editor.isActive("excalidraw") && editor.getAttributes("excalidraw")?.src
|
||||
);
|
||||
},
|
||||
[editor],
|
||||
@@ -228,7 +227,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<div className={classes.toolbar}>
|
||||
<Tooltip position="top" label={t("Align left")}>
|
||||
<Tooltip position="top" label={t("Align left")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={alignLeft}
|
||||
size="lg"
|
||||
@@ -242,7 +241,11 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Align center")}>
|
||||
<Tooltip
|
||||
position="top"
|
||||
label={t("Align center")}
|
||||
withinPortal={false}
|
||||
>
|
||||
<ActionIcon
|
||||
onClick={alignCenter}
|
||||
size="lg"
|
||||
@@ -256,7 +259,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Align right")}>
|
||||
<Tooltip position="top" label={t("Align right")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={alignRight}
|
||||
size="lg"
|
||||
@@ -272,7 +275,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Edit")}>
|
||||
<Tooltip position="top" label={t("Edit")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleOpen}
|
||||
size="lg"
|
||||
@@ -283,7 +286,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Download")}>
|
||||
<Tooltip position="top" label={t("Download")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleDownload}
|
||||
size="lg"
|
||||
@@ -294,7 +297,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Delete")}>
|
||||
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleDelete}
|
||||
size="lg"
|
||||
|
||||
@@ -149,7 +149,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<div className={classes.toolbar}>
|
||||
<Tooltip position="top" label={t("Align left")}>
|
||||
<Tooltip position="top" label={t("Align left")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={alignImageLeft}
|
||||
size="lg"
|
||||
@@ -161,7 +161,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Align center")}>
|
||||
<Tooltip position="top" label={t("Align center")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={alignImageCenter}
|
||||
size="lg"
|
||||
@@ -173,7 +173,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Align right")}>
|
||||
<Tooltip position="top" label={t("Align right")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={alignImageRight}
|
||||
size="lg"
|
||||
@@ -187,7 +187,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Download")}>
|
||||
<Tooltip position="top" label={t("Download")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleDownload}
|
||||
size="lg"
|
||||
@@ -198,7 +198,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Replace image")}>
|
||||
<Tooltip position="top" label={t("Replace image")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleReplace}
|
||||
size="lg"
|
||||
@@ -209,7 +209,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Delete")}>
|
||||
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleDelete}
|
||||
size="lg"
|
||||
|
||||
@@ -125,7 +125,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<div className={classes.toolbar}>
|
||||
<Tooltip position="top" label={t("Align left")}>
|
||||
<Tooltip position="top" label={t("Align left")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={alignLeft}
|
||||
size="lg"
|
||||
@@ -137,7 +137,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Align center")}>
|
||||
<Tooltip position="top" label={t("Align center")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={alignCenter}
|
||||
size="lg"
|
||||
@@ -149,7 +149,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Align right")}>
|
||||
<Tooltip position="top" label={t("Align right")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={alignRight}
|
||||
size="lg"
|
||||
@@ -163,7 +163,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Download")}>
|
||||
<Tooltip position="top" label={t("Download")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleDownload}
|
||||
size="lg"
|
||||
@@ -174,7 +174,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Delete")}>
|
||||
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleDelete}
|
||||
size="lg"
|
||||
|
||||
@@ -158,6 +158,15 @@ export async function importZip(
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getAttachmentInfo(
|
||||
attachmentId: string,
|
||||
): Promise<IAttachment> {
|
||||
const req = await api.post<IAttachment>("/files/info", {
|
||||
attachmentId,
|
||||
});
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function uploadFile(
|
||||
file: File,
|
||||
pageId: string,
|
||||
|
||||
Reference in New Issue
Block a user