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:
Philip Okugbe
2026-02-28 01:24:19 +00:00
committed by GitHub
parent df64de5306
commit fc0997fd90
15 changed files with 260 additions and 33 deletions
@@ -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,
@@ -52,7 +52,7 @@ import { EnvironmentService } from '../../integrations/environment/environment.s
import { TokenService } from '../auth/services/token.service';
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
import * as path from 'path';
import { RemoveIconDto } from './dto/attachment.dto';
import { AttachmentInfoDto, RemoveIconDto } from './dto/attachment.dto';
import { PageAccessService } from '../page/page-access/page-access.service';
@Controller()
@@ -349,6 +349,34 @@ export class AttachmentController {
}
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('files/info')
async getAttachmentInfo(
@Body() dto: AttachmentInfoDto,
@AuthWorkspace() workspace: Workspace,
@AuthUser() user: User,
) {
const attachment = await this.attachmentRepo.findById(dto.attachmentId);
if (
!attachment ||
!attachment.pageId ||
attachment.workspaceId !== workspace.id ||
attachment.type !== AttachmentType.File
) {
throw new NotFoundException('File not found');
}
const page = await this.pageRepo.findById(attachment.pageId);
if (!page) {
throw new NotFoundException('File not found');
}
await this.pageAccessService.validateCanView(page, user);
return attachment;
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('attachments/remove-icon')
@@ -1,6 +1,12 @@
import { IsEnum, IsIn, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
import { AttachmentType } from '../attachment.constants';
export class AttachmentInfoDto {
@IsNotEmpty()
@IsUUID()
attachmentId: string;
}
export class RemoveIconDto {
@IsEnum(AttachmentType)
@IsIn([