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,
});