diff --git a/components/flow-board/drawnix-workspace.tsx b/components/flow-board/drawnix-workspace.tsx index 0a13a2d4a..45fee97a2 100644 --- a/components/flow-board/drawnix-workspace.tsx +++ b/components/flow-board/drawnix-workspace.tsx @@ -41,6 +41,7 @@ import { MindLayoutType } from "@plait/layouts"; import { BranchShape, canSetAbstract, + DEFAULT_MIND_IMAGE_WIDTH, getBranchShapeByMindElement, getRectangleByNode, getFontSizeByMindElement, @@ -875,14 +876,14 @@ function isFlowBoardEditablePasteTarget(target: HTMLElement | null) { ); } -function getFlowBoardImageDimensions(file: File) { +function getFlowBoardImageDimensions(file: File, maxWidth = 400) { return new Promise<{ width: number; height: number }>((resolve) => { const url = URL.createObjectURL(file); const image = new Image(); image.onload = () => { const originalWidth = image.naturalWidth || 400; const originalHeight = image.naturalHeight || 300; - const width = Math.min(originalWidth, 400); + const width = Math.min(originalWidth, maxWidth); const height = Math.round((width / originalWidth) * originalHeight); URL.revokeObjectURL(url); resolve({ width, height }); @@ -895,6 +896,15 @@ function getFlowBoardImageDimensions(file: File) { }); } +function readFlowBoardImageFile(file: File) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(reader.error || new Error("图片读取失败")); + reader.readAsDataURL(file); + }); +} + async function uploadFlowBoardImageFile(file: File) { const formData = new FormData(); formData.append("file", file); @@ -2163,7 +2173,8 @@ export function DrawnixWorkspace({ const handleFlowBoardPastedImages = useCallback(async ( board: PlaitBoard, - files: File[] + files: File[], + topicId: string | null = null ) => { const supportedFiles = files.filter((file) => FLOW_BOARD_IMAGE_UPLOAD_TYPES.has(file.type) @@ -2178,10 +2189,12 @@ export function DrawnixWorkspace({ } const basePoint = getFlowBoardPastePoint(board); + // A topic has one image. Capture its identity before any asynchronous upload. + const filesToInsert = topicId ? supportedFiles.slice(0, 1) : supportedFiles; let insertedCount = 0; - for (let index = 0; index < supportedFiles.length; index += 1) { - const file = supportedFiles[index]; + for (let index = 0; index < filesToInsert.length; index += 1) { + const file = filesToInsert[index]; if (file.size > FLOW_BOARD_IMAGE_UPLOAD_MAX_BYTES) { toast.error(IMAGE_UPLOAD_TOO_LARGE_MESSAGE); continue; @@ -2189,9 +2202,18 @@ export function DrawnixWorkspace({ try { const [url, dimensions] = await Promise.all([ - uploadFlowBoardImageFile(file), - getFlowBoardImageDimensions(file), + previewMode ? readFlowBoardImageFile(file) : uploadFlowBoardImageFile(file), + getFlowBoardImageDimensions(file, topicId ? DEFAULT_MIND_IMAGE_WIDTH : 400), ]); + // Switching sheets or deleting the target while uploading must not paste elsewhere. + if (boardRef.current !== board) return; + if (topicId) { + const topic = getTopicEntries(board.children).find(entry => entry.element.id === topicId)?.element; + if (!topic || !MindElement.isMindElement(board, topic)) return; + MindTransforms.setImage(board, topic, { url, ...dimensions }); + insertedCount += 1; + continue; + } const targetPoint: Point = [ basePoint[0] + index * 24, basePoint[1] + index * 24, @@ -2207,11 +2229,11 @@ export function DrawnixWorkspace({ if (insertedCount > 0) { toast.success( insertedCount === 1 - ? "已粘贴图片到白板" + ? topicId ? "已插入主题图片" : "已粘贴图片到白板" : `已粘贴 ${insertedCount} 张图片到白板` ); } - }, []); + }, [previewMode]); const handleFlowBoardPasteCapture = useCallback((event: ClipboardEvent) => { const board = boardRef.current; @@ -2220,17 +2242,23 @@ export function DrawnixWorkspace({ } const target = event.target instanceof HTMLElement ? event.target : null; + const selectedTopics = getSelectedMindElements(board); + const topicId = selectedTopics.length === 1 ? selectedTopics[0].id : null; + const isTopicEditor = Boolean(topicId && target?.closest(".drawnix [data-slate-editor='true']")); if ( target?.closest("[data-flow-board-shell-control='true']") || - isFlowBoardEditablePasteTarget(target) + (isFlowBoardEditablePasteTarget(target) && !isTopicEditor) ) { return; } - const imageFiles = Array.from(event.clipboardData.items || []) + let imageFiles = Array.from(event.clipboardData.items || []) .filter((item) => item.kind === "file" && item.type.startsWith("image/")) .map((item) => item.getAsFile()) .filter((file): file is File => Boolean(file)); + if (imageFiles.length === 0) { + imageFiles = Array.from(event.clipboardData.files || []).filter(file => file.type.startsWith("image/")); + } if (imageFiles.length === 0) { return; @@ -2239,7 +2267,7 @@ export function DrawnixWorkspace({ event.preventDefault(); event.stopPropagation(); - void handleFlowBoardPastedImages(board, imageFiles); + void handleFlowBoardPastedImages(board, imageFiles, topicId); }, [handleFlowBoardPastedImages]); const handleFlowBoardFileInputChangeCapture = useCallback( @@ -2736,7 +2764,8 @@ export function DrawnixWorkspace({ const board = boardRef.current; const files = Array.from(event.currentTarget.files || []); if (board && files.length > 0) { - void handleFlowBoardPastedImages(board, files); + const topics = getActiveMarkerElements(); + void handleFlowBoardPastedImages(board, files, topics.length === 1 ? topics[0].id : null); } event.currentTarget.value = ""; }} diff --git a/docs/XMind-26.04-客户端界面对照.md b/docs/XMind-26.04-客户端界面对照.md index 13379cfa7..5fdb2f872 100644 --- a/docs/XMind-26.04-客户端界面对照.md +++ b/docs/XMind-26.04-客户端界面对照.md @@ -28,3 +28,10 @@ - 将画板基础 CSS 移到共享客户端组件中打包加载,替代只在正式路由加载的外部样式链接。修复后活动 SVG 为 absolute,覆盖画布可视区域且 pointer-events=none,不拦截编辑操作。 - 框选边线为 2px 蓝色,拖动区域有浅蓝填充;松开后去掉拖动填充,保留被选主题和多选范围描边。 - 框选回归新增可视坐标、边框颜色/宽度、填充及松开后高亮检查,覆盖平移后再框选。实际截图保存在 `.local/xmind-audit-2026-10-04/web/22-marquee-drag-after.png`。 + +## 2026-10-05 主题图片粘贴修复 + +- 修复主题文字编辑时跳过图片粘贴的问题:选中一个主题或正在编辑该主题时,剪贴板图片作为主题图片插入,保留标题;同一主题再次插入替换原图。插入菜单的图片入口采用相同行为,空白画布保留独立图片插入。 +- 图片按比例缩放,主题图片宽度上限 240px。异步读取/上传后重新查找目标主题,切换画布或目标已删除时不插入其他位置。文字、标题、备注等文本粘贴保留各自原有处理。 +- 开发预览直接保存图片数据,避免依赖登录上传;正式页面继续使用原有上传接口。支持 JPEG、PNG、WebP,保留原有大小限制。 +- 4 项浏览器用例通过,使用实际剪贴板和粘贴快捷键验证编辑状态、选中状态、空白画布及文字粘贴;同时覆盖图片撤销/重做、XMind 导出嵌入资源和标题/备注不拦截。TypeScript 与本轮文件 Biome 检查通过。正式账号上传接口未在此轮实测。 diff --git a/tests/flow-board/image-paste.spec.ts b/tests/flow-board/image-paste.spec.ts new file mode 100644 index 000000000..56f60cf81 --- /dev/null +++ b/tests/flow-board/image-paste.spec.ts @@ -0,0 +1,120 @@ +import { expect, test, type Page } from "@playwright/test"; +import JSZip from "jszip"; +import { clickXmindAction } from "./editor-actions"; + +const pixel = "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAO0lEQVR4AezXwQkAMAgDwND9d27rBD4FOdB3wv1yktzJrwI/f+4UIECAAAECBAgQIECAAAEC+wW63f0AAAD//+3aM8YAAAAGSURBVAMABkQgIT13JVIAAAAASUVORK5CYII="; +const picture = { name: "clipboard.png", mimeType: "image/png", buffer: Buffer.from(pixel, "base64") }; + +async function openMap(page: Page) { + await page.goto("/dev/flow-board-preview", { waitUntil: "commit" }); + const zip = new JSZip(); + zip.file("content.json", JSON.stringify([{ title: "图片粘贴", rootTopic: { + id: "root", title: "项目", children: { attached: [ + { id: "first", title: "设计方案" }, { id: "second", title: "开发计划" }, + ] }, + } }])); + await page.locator("input[accept*=xmind]").setInputFiles({ + name: "paste.xmind", mimeType: "application/octet-stream", + buffer: await zip.generateAsync({ type: "nodebuffer" }), + }); + await expect(page.locator(".drawnix").getByText("设计方案", { exact: true })).toBeVisible(); +} + +async function writeClipboardImage(page: Page) { + await page.evaluate(async (base64) => { + const blob = await (await fetch(`data:image/png;base64,${base64}`)).blob(); + await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]); + }, pixel); +} + +const canvasImages = (page: Page) => page.locator('.drawnix img[src^="data:image/png"], .drawnix image[href^="data:image/png"]'); + +async function exportedSheets(page: Page) { + const pending = page.waitForEvent("download"); + await page.getByRole("button", { name: "导出 XMind", exact: true }).click(); + const download = await pending; + const stream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream!) chunks.push(chunk); + const zip = await JSZip.loadAsync(Buffer.concat(chunks)); + return { zip, sheets: JSON.parse(await zip.file("content.json")!.async("string")) }; +} + +test("real clipboard image paste while editing attaches to the topic, survives export, and undoes", async ({ page, context }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + const uploads: string[] = []; + page.on("request", request => { if (/\/api\/files\/upload/.test(request.url())) uploads.push(request.url()); }); + const errors: string[] = []; + page.on("pageerror", error => errors.push(error.message)); + await openMap(page); + await page.locator(".drawnix").getByText("设计方案", { exact: true }).dblclick(); + await expect(page.locator('.drawnix [data-slate-editor="true"][contenteditable="true"]')).toBeFocused(); + await writeClipboardImage(page); + await page.keyboard.press("ControlOrMeta+v"); + await expect(canvasImages(page)).toHaveCount(1); + await expect(page.locator(".drawnix").getByText("设计方案", { exact: true })).toBeVisible(); + await page.keyboard.press("Escape"); + const { zip, sheets } = await exportedSheets(page); + const topic = sheets[0].rootTopic.children.attached.find((item: { title: string }) => item.title === "设计方案"); + expect(topic.image.width).toBe(32); + expect(topic.image.height).toBe(32); + expect(topic.image.src).toMatch(/^xap:resources\//); + expect(await zip.file(topic.image.src.replace("xap:", ""))!.async("uint8array")).not.toHaveLength(0); + expect(sheets[0].rootTopic.children.attached.find((item: { title: string }) => item.title === "开发计划").image).toBeUndefined(); + await clickXmindAction(page, "撤销"); + await expect(canvasImages(page)).toHaveCount(0); + await clickXmindAction(page, "重做"); + await expect(canvasImages(page)).toHaveCount(1); + await page.screenshot({ path: "test-results/flow-board-ui/image-paste-editing.png" }); + expect(uploads).toEqual([]); + expect(errors).toEqual([]); +}); + +test("selected topic accepts clipboard and Insert image, replacing its single picture", async ({ page, context }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await openMap(page); + await page.locator(".drawnix").getByText("设计方案", { exact: true }).click(); + await writeClipboardImage(page); + await page.keyboard.press("ControlOrMeta+v"); + await expect(canvasImages(page)).toHaveCount(1); + await clickXmindAction(page, "图片"); + await page.locator('input[data-flow-board-shell-control="true"][accept="image/jpeg,image/png,image/webp"]').setInputFiles(picture); + await expect(canvasImages(page)).toHaveCount(1); + const { sheets } = await exportedSheets(page); + expect(sheets[0].rootTopic.children.attached.find((item: { title: string }) => item.title === "设计方案").image).toBeDefined(); +}); + +test("blank canvas still pastes an independent image and text editing still pastes text", async ({ page, context }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await openMap(page); + await page.mouse.click(120, 170); + await writeClipboardImage(page); + await page.keyboard.press("ControlOrMeta+v"); + await expect(canvasImages(page)).toHaveCount(1); + await page.locator(".drawnix").getByText("设计方案", { exact: true }).dblclick(); + await page.evaluate(() => navigator.clipboard.writeText("新的方案")); + await page.keyboard.press("ControlOrMeta+a"); + await page.keyboard.press("ControlOrMeta+v"); + await expect(page.locator(".drawnix").getByText("新的方案", { exact: true })).toBeVisible(); + await page.keyboard.press("Escape"); + const { sheets } = await exportedSheets(page); + expect(sheets[0].rootTopic.children.attached.every((topic: { image?: unknown }) => !topic.image)).toBe(true); +}); + +test("image paste in title and notes is not intercepted by the canvas", async ({ page }) => { + await openMap(page); + await page.locator(".drawnix").getByText("设计方案", { exact: true }).click(); + await clickXmindAction(page, "备注"); + for (const target of [page.getByLabel("白板标题", { exact: true }), page.getByLabel("主题备注", { exact: true })]) { + const prevented = await target.evaluate((element, base64) => { + const clipboardData = new DataTransfer(); + const bytes = Uint8Array.from(atob(base64), char => char.charCodeAt(0)); + clipboardData.items.add(new File([bytes], "image.png", { type: "image/png" })); + const event = new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true }); + element.dispatchEvent(event); + return event.defaultPrevented; + }, pixel); + expect(prevented).toBe(false); + } + await expect(canvasImages(page)).toHaveCount(0); +});