From fc0997fd9067f4e53e8862a4dbdf718c9a9c75cf Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sat, 28 Feb 2026 01:24:19 +0000 Subject: [PATCH] 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 --- .../components/callout/callout-menu.tsx | 10 +- .../common/editor-paste-handler.tsx | 157 ++++++++++++++++++ .../editor/components/drawio/drawio-menu.tsx | 21 ++- .../components/excalidraw/excalidraw-menu.tsx | 19 ++- .../editor/components/image/image-menu.tsx | 12 +- .../editor/components/video/video-menu.tsx | 10 +- .../features/page/services/page-service.ts | 9 + .../core/attachment/attachment.controller.ts | 30 +++- .../src/core/attachment/dto/attachment.dto.ts | 6 + apps/server/src/ee | 2 +- .../editor-ext/src/lib/callout/callout.ts | 1 + .../editor-ext/src/lib/image/image-upload.ts | 6 + .../editor-ext/src/lib/math/math-block.ts | 1 + .../editor-ext/src/lib/subpages/subpages.ts | 1 + .../editor-ext/src/lib/video/video-upload.ts | 8 +- 15 files changed, 260 insertions(+), 33 deletions(-) diff --git a/apps/client/src/features/editor/components/callout/callout-menu.tsx b/apps/client/src/features/editor/components/callout/callout-menu.tsx index cdfc3216..69c83693 100644 --- a/apps/client/src/features/editor/components/callout/callout-menu.tsx +++ b/apps/client/src/features/editor/components/callout/callout-menu.tsx @@ -132,7 +132,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) { shouldShow={shouldShow} >
- + setCalloutType("info")} size="lg" @@ -147,7 +147,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) { - + setCalloutType("note")} size="lg" @@ -159,7 +159,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) { - + setCalloutType("success")} size="lg" @@ -174,7 +174,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) { - + setCalloutType("warning")} size="lg" @@ -189,7 +189,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) { - + setCalloutType("danger")} size="lg" diff --git a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx index 61d7534e..a7a91749 100644 --- a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx +++ b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx @@ -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(); + + 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(); + 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(); + 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, diff --git a/apps/client/src/features/editor/components/drawio/drawio-menu.tsx b/apps/client/src/features/editor/components/drawio/drawio-menu.tsx index 4a47f7e5..bdd1461b 100644 --- a/apps/client/src/features/editor/components/drawio/drawio-menu.tsx +++ b/apps/client/src/features/editor/components/drawio/drawio-menu.tsx @@ -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} >
- + - + - + - + - +
- + - + - + - + - + - +
- + - + - + - + - + - +
- + - + - + - + - + { + const req = await api.post("/files/info", { + attachmentId, + }); + return req.data; +} + export async function uploadFile( file: File, pageId: string, diff --git a/apps/server/src/core/attachment/attachment.controller.ts b/apps/server/src/core/attachment/attachment.controller.ts index 99d2093d..4694e0f0 100644 --- a/apps/server/src/core/attachment/attachment.controller.ts +++ b/apps/server/src/core/attachment/attachment.controller.ts @@ -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') diff --git a/apps/server/src/core/attachment/dto/attachment.dto.ts b/apps/server/src/core/attachment/dto/attachment.dto.ts index e5ba4cc7..850de6f9 100644 --- a/apps/server/src/core/attachment/dto/attachment.dto.ts +++ b/apps/server/src/core/attachment/dto/attachment.dto.ts @@ -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([ diff --git a/apps/server/src/ee b/apps/server/src/ee index dc8da28f..9e493d75 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit dc8da28f248cf56e1c11af1bfaed56d79848fb1b +Subproject commit 9e493d75f5435415a1ded7b4d9faef58da06b043 diff --git a/packages/editor-ext/src/lib/callout/callout.ts b/packages/editor-ext/src/lib/callout/callout.ts index 1dc4d800..898fc415 100644 --- a/packages/editor-ext/src/lib/callout/callout.ts +++ b/packages/editor-ext/src/lib/callout/callout.ts @@ -53,6 +53,7 @@ export const Callout = Node.create({ content: "block+", group: "block", defining: true, + isolating: true, addAttributes() { return { diff --git a/packages/editor-ext/src/lib/image/image-upload.ts b/packages/editor-ext/src/lib/image/image-upload.ts index ca521bc8..35ba6c4b 100644 --- a/packages/editor-ext/src/lib/image/image-upload.ts +++ b/packages/editor-ext/src/lib/image/image-upload.ts @@ -40,6 +40,8 @@ const handleImageUpload = ); const placeholderId = generateNodeId(); + const width = imageDimensions?.width ?? undefined; + const height = imageDimensions?.height ?? undefined; const aspectRatio = imageDimensions ? imageDimensions.width / imageDimensions.height : undefined; @@ -57,6 +59,8 @@ const handleImageUpload = id: placeholderId, name: file.name, }, + width, + height, aspectRatio, }); @@ -88,6 +92,8 @@ const handleImageUpload = src: `/api/files/${attachment.id}/${attachment.fileName}`, attachmentId: attachment.id, size: attachment.fileSize, + width, + height, aspectRatio, }); diff --git a/packages/editor-ext/src/lib/math/math-block.ts b/packages/editor-ext/src/lib/math/math-block.ts index cf11e8f8..b86601b8 100644 --- a/packages/editor-ext/src/lib/math/math-block.ts +++ b/packages/editor-ext/src/lib/math/math-block.ts @@ -24,6 +24,7 @@ export const MathBlock = Node.create({ name: "mathBlock", group: "block", atom: true, + isolating: true, addOptions() { return { diff --git a/packages/editor-ext/src/lib/subpages/subpages.ts b/packages/editor-ext/src/lib/subpages/subpages.ts index 617f43ce..6f5c1062 100644 --- a/packages/editor-ext/src/lib/subpages/subpages.ts +++ b/packages/editor-ext/src/lib/subpages/subpages.ts @@ -29,6 +29,7 @@ export const Subpages = Node.create({ group: "block", atom: true, draggable: true, + isolating: true, parseHTML() { return [ diff --git a/packages/editor-ext/src/lib/video/video-upload.ts b/packages/editor-ext/src/lib/video/video-upload.ts index 404cf99e..7271649c 100644 --- a/packages/editor-ext/src/lib/video/video-upload.ts +++ b/packages/editor-ext/src/lib/video/video-upload.ts @@ -61,7 +61,9 @@ const handleVideoUpload = const objectUrl = URL.createObjectURL(file); const videoDimensions = await getVideoDimensions(objectUrl); const placeholderId = generateNodeId(); - const aspectRatio = videoDimensions.aspectRatio; + const width = videoDimensions?.width ?? undefined; + const height = videoDimensions?.height ?? undefined; + const aspectRatio = videoDimensions?.aspectRatio; let placeholderInserted = false; @@ -76,6 +78,8 @@ const handleVideoUpload = id: placeholderId, name: file.name, }, + width, + height, aspectRatio, }); @@ -108,6 +112,8 @@ const handleVideoUpload = attachmentId: attachment.id, title: attachment.fileName, size: attachment.fileSize, + width, + height, aspectRatio, });