fix(flow-board): support image paste into mind map topics
Handle clipboard images while editing or selecting a topic and route Insert image through the same topic attachment behavior. Read image data locally in preview mode and retain the production upload path. Add four browser regression cases for real clipboard paste, undo/redo, export resources, canvas images, and text input isolation. Record validation in the client comparison notes.
This commit is contained in:
parent
79253245f3
commit
ae7a0e7ba4
@ -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<string>((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<HTMLElement>) => {
|
||||
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 = "";
|
||||
}}
|
||||
|
||||
@ -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 检查通过。正式账号上传接口未在此轮实测。
|
||||
|
||||
120
tests/flow-board/image-paste.spec.ts
Normal file
120
tests/flow-board/image-paste.spec.ts
Normal file
@ -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);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user