Add application feedback and refine headless canvas design boundaries
This commit is contained in:
@@ -116,7 +116,9 @@ CANVAS_UI_TOOLS=/path/to/isolated-tools node tests/canvas-ui-check.mjs
|
||||
|
||||
DOM 工具仅需在临时目录安装 esbuild/jsdom,见测试文件开头;不启动浏览器。生产构建与 `tests/api-smoke.mjs` 使用隔离副本和临时本地服务,避免影响现有开发服务器。实际结果及限制见 VALIDATION.md。
|
||||
|
||||
Headless Canvas API 的拆包方案保留在本地忽略的 `docs/HEADLESS_CANVAS_API_DRAFT.md`,当前没有创建或发布该库。其他已被替代的元素/分区草案已清理。
|
||||
Headless Canvas API 的拆包方案保留在本地忽略的 `docs/HEADLESS_CANVAS_API_DRAFT.md`,已按同步本地编辑与独立异步 runtime 重写,包含 element/component/feature、API 选择、八个工具栏位置、mode 作用域、上传恢复与 ACK 合并合同。当前没有创建或发布该库;outbox/IndexedDB 等仍是待实施方案。其他已被替代的元素/分区草案已清理。
|
||||
|
||||
应用提示使用 Sonner,图标说明使用 Radix Tooltip;根布局统一安装 provider。画布通用操作错误与归属完成使用固定 ID 通知,保存冲突、失败上传和重试操作仍保留在相关内容处。消息和浮层依赖仅属于应用 UI,不作为未来 headless core 的依赖。
|
||||
|
||||
## 会话期限
|
||||
|
||||
|
||||
@@ -77,3 +77,13 @@
|
||||
- DOM/Hook 回归通过:上传期间拖动/缩放/改名在确认后保留;确认前删除不会被重新插入;删除可撤销;尺寸初始化与引用补齐不消耗撤销;本地跨分区移动与复合撤销、文件确认到当前所属画布;已持久化后的归属撤销走真实事务。
|
||||
- 并发回归通过:版本号型响应的重新读取、包含重复 parent 的事务快照,都保留本地上传节点;其他事务与附件完成同时发生不会覆盖新图片。AI 在文件上传完成后可执行,编辑无需等待。
|
||||
- 最终 TypeScript、84 项单元测试、完整 DOM 回归和隔离生产构建均通过;未访问用户浏览器,不将 DOM 测试描述为用户已完成浏览器验收。
|
||||
|
||||
## Headless 方案与应用提示依赖
|
||||
|
||||
本地忽略的 `docs/HEADLESS_CANVAS_API_DRAFT.md` 已重写为方案:同步 command/working document 与独立 runtime、operation ledger、ACK 基线分离;提供 component/feature 组合、API A/B/C、八个 viewport slot、实例 mode、混合选择、文件恢复、幂等与撤销设计。仅为待选择方案,没有提取或发布 npm 包,也没有实现 IndexedDB/outbox。特别区分普通 CAS 快照 PATCH 与有 Idempotency-Key 的事务,避免把重试后的 412 错判为原请求零写入。
|
||||
|
||||
实际安装 Sonner 2.0.8、Radix Tooltip 1.4.1,并同步 `package-lock.json`。根布局挂应用 provider,固定通知 ID 避免重复;图标按钮保留实际 button/ref 与原尺寸。通用错误/归属完成使用应用通知,原保存冲突、失败上传和重试入口保留。
|
||||
|
||||
- TypeScript、84 项单元测试与完整 DOM 回归通过;新增真实依赖场景覆盖独立按钮原生说明、键盘 tooltip/Escape、ref/DOM 结构、点击、通知更新去重/关闭,以及 provider 内的分区完成、非法文件提示和继续编辑。
|
||||
- 在临时副本执行 Webpack 生产构建和 API 冒烟,均通过;未改变当前开发服务的 `.next`,未访问用户浏览器或生产画布。DOM 布局检查不替代真实浏览器视觉验收。
|
||||
- 保留用户的 `next-env.d.ts`、未跟踪的 pnpm lock/workspace 文件;docs 继续忽略,不强制跟踪。
|
||||
|
||||
Generated
+212
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "1.3.1",
|
||||
"@radix-ui/react-slot": "1.5.0",
|
||||
"@radix-ui/react-tooltip": "1.4.1",
|
||||
"@xyflow/react": "12.12.1",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
@@ -18,6 +19,7 @@
|
||||
"next": "16.4.5",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1",
|
||||
"sonner": "2.0.8",
|
||||
"tailwind-merge": "3.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -52,6 +54,44 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
|
||||
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
|
||||
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.8.0",
|
||||
"@floating-ui/utils": "^0.2.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react-dom": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz",
|
||||
"integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
|
||||
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
@@ -743,6 +783,29 @@
|
||||
"integrity": "sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-arrow": {
|
||||
"version": "1.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.17.tgz",
|
||||
"integrity": "sha512-67jjcVvxjXCCStnTW3Z+M7jmaFMuMR7M2JgpDzH4w7JSRrikmzhjfYgP/09ECb8n7whnsgh1ifJ+Ie96qEj+pQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
|
||||
@@ -895,6 +958,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.4.0.tgz",
|
||||
"integrity": "sha512-yCWRvLeGv9Pz3+efwHqGPp70mZ56r3kfIgQVF56Oj2aAa4uP/Tb+IE59wNcc+758rctqbCKx4LbK8II/r7Bn0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.0.0",
|
||||
"@radix-ui/react-arrow": "1.1.17",
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-context": "1.3.0",
|
||||
"@radix-ui/react-primitive": "2.2.0",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.5",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4",
|
||||
"@radix-ui/react-use-rect": "1.1.4",
|
||||
"@radix-ui/react-use-size": "1.1.5",
|
||||
"@radix-ui/rect": "1.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-portal": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.2.0.tgz",
|
||||
@@ -983,6 +1078,42 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.4.1.tgz",
|
||||
"integrity": "sha512-NxLFGSFtZ78zVfcoOhTwR1xwqo5qx1Jh8Pw6FMZ9McCaqfRzz7/gIRahs2Oy5XlPgQ+8QkUcKbN5B0U44J/kCg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.2.0",
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-context": "1.3.0",
|
||||
"@radix-ui/react-dismissable-layer": "1.2.1",
|
||||
"@radix-ui/react-focus-scope": "1.3.1",
|
||||
"@radix-ui/react-id": "1.1.4",
|
||||
"@radix-ui/react-popper": "1.4.0",
|
||||
"@radix-ui/react-portal": "1.2.0",
|
||||
"@radix-ui/react-presence": "1.1.11",
|
||||
"@radix-ui/react-primitive": "2.2.0",
|
||||
"@radix-ui/react-slot": "1.5.0",
|
||||
"@radix-ui/react-use-controllable-state": "1.3.0",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4",
|
||||
"@radix-ui/react-visually-hidden": "1.2.13"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.5.tgz",
|
||||
@@ -1054,6 +1185,71 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-rect": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz",
|
||||
"integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/rect": "1.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-size": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.5.tgz",
|
||||
"integrity": "sha512-V1McDQkMGzgivwqdu7u96APs7oi0Jvj9LTzgq3WWrDZqxWdAqU9yZnZQqk5OizGFR8badUQwZsrlao7MOlWYOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-visually-hidden": {
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.13.tgz",
|
||||
"integrity": "sha512-feijiN1oPk4rck4u9Dm9tsU4mT6Wp9oeEEhaz1WUEs1VuqLf8xzsFG+Qk+uWq6TnMH9O+WRECRg0NftyP9E4Fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/rect": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz",
|
||||
"integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.23",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
||||
@@ -2753,6 +2949,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.8.tgz",
|
||||
"integrity": "sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "1.3.1",
|
||||
"@radix-ui/react-slot": "1.5.0",
|
||||
"@radix-ui/react-tooltip": "1.4.1",
|
||||
"@xyflow/react": "12.12.1",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
@@ -19,6 +20,7 @@
|
||||
"next": "16.4.5",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1",
|
||||
"sonner": "2.0.8",
|
||||
"tailwind-merge": "3.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import { AppFeedback } from "@/components/ui/app-feedback";
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import "@/styles/globals.css";
|
||||
@@ -18,7 +19,7 @@ export const metadata: Metadata = {
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="zh-CN" data-scroll-behavior="smooth">
|
||||
<body>{children}</body>
|
||||
<body><AppFeedback>{children}</AppFeedback></body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
import { useImageUploads } from "./use-image-uploads";
|
||||
import { notify, dismissNotification } from "@/lib/notifications";
|
||||
import { FeedbackContext } from "@/components/ui/app-feedback";
|
||||
import { ImagePreviews } from "./canvas-image";
|
||||
import { displayID } from "@/lib/canvas-tree";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||
import {
|
||||
Background, BackgroundVariant, ConnectionLineType, ConnectionMode, ReactFlow, ReactFlowProvider, SelectionMode,
|
||||
addEdge, NodeToolbar, Position, useReactFlow, useViewport, type Connection, type OnConnectEnd, type XYPosition,
|
||||
@@ -60,6 +62,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
|
||||
const [tool, setTool] = useState<"select" | "hand">("select");
|
||||
const [error, setError] = useState(initial.error);
|
||||
const feedback = useContext(FeedbackContext);
|
||||
const [branchRequest, setBranchRequest] = useState<BranchRequest | null>(null);
|
||||
const [demoImageUploading, setImageUploading] = useState(false);
|
||||
const [iconPicker, setIconPicker] = useState(false);
|
||||
@@ -122,6 +125,18 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
const timer = setTimeout(() => setMembershipNotice(""), 4000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [membershipNotice]);
|
||||
useEffect(() => {
|
||||
if (!feedback || !error) return;
|
||||
const id = `canvas:${board.id}:error`;
|
||||
notify({ id, level: "error", title: error });
|
||||
setError("");
|
||||
}, [feedback, error, board.id]);
|
||||
useEffect(() => {
|
||||
if (!feedback || !membershipNotice) return;
|
||||
const id = `canvas:${board.id}:membership`;
|
||||
notify({ id, level: "success", title: membershipNotice });
|
||||
return () => dismissNotification(id);
|
||||
}, [feedback, membershipNotice, board.id]);
|
||||
const duration = reduceMotion ? 0 : 180;
|
||||
const rememberViewport = (viewport: import("@xyflow/react").Viewport) => {
|
||||
treeRef.current.dispatch({ type: "viewport", viewport });
|
||||
@@ -526,7 +541,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
dispatch({ type: "replace", document: { ...tree.rootDocument, nodes: [...tree.rootDocument.nodes.map((item) => ({ ...item, selected: false })), { ...node, selected: true }] } });
|
||||
setTool("select");
|
||||
}} />}
|
||||
<div className={styles.membershipNotice} role="status" aria-live="polite">{membershipNotice}</div>
|
||||
{!feedback && <div className={styles.membershipNotice} role="status" aria-live="polite">{membershipNotice}</div>}
|
||||
{recovery && <DraftRecovery canRestore={canRestoreCanvasDraft(recovery, tree.rootDocument)} exportDraft={() => downloadDraft(recovery)} restore={() => { dispatch({ type: "replace", document: recovery }); setRecovery(null); }} discard={() => setRecovery(null)} />}
|
||||
{!recovery && childRecovery && <DraftRecovery canRestore={canRestoreCanvasDraft(childRecovery[1], tree.documents.get(childRecovery[0])!)} exportDraft={() => downloadDraft(childRecovery[1])} restore={() => tree.resolveRecovery(childRecovery[0], true)} discard={() => tree.resolveRecovery(childRecovery[0], false)} />}
|
||||
{snapshot && sync.error && <div className={styles.syncError} role="alert"><span>{sync.error === "local_backup_failed" ? "本地备份失败,请导出 JSON 备份。" : sync.error === "version_conflict" ? "画布已在其他地方修改,自动保存已停止,请导出当前内容或重新读取服务端版本。" : errorText(new Error(sync.error))}</span>
|
||||
@@ -542,7 +557,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap
|
||||
</NodeToolbar>;
|
||||
}))}
|
||||
{tree.busy && <div className={styles.canvasBusy} role="status">{tree.pendingCommand ? <div>同步结果尚未确认,为保护内容暂时停止编辑。<button onClick={() => void tree.retryPending().catch((error) => setError(error.message))}>重试同步</button><button onClick={() => window.location.reload()}>重新读取画布</button></div> : "正在同步画布…"}</div>}
|
||||
{error && <button className={styles.error} role="alert" onClick={() => setError("")} title="点击关闭提示">{error}</button>}
|
||||
{error && !feedback && <button className={styles.error} role="alert" onClick={() => setError("")} title="点击关闭提示">{error}</button>}
|
||||
<input ref={imageInput} type="file" accept="image/png,image/jpeg,image/webp,image/gif" onChange={readImage} hidden />
|
||||
<input ref={projectInput} type="file" accept="application/json,.json" onChange={importProject} hidden />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, type ReactNode } from "react";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
export const FeedbackContext = createContext(false);
|
||||
|
||||
export function AppFeedback({ children }: { children: ReactNode }) {
|
||||
return <FeedbackContext.Provider value={true}>
|
||||
<Tooltip.Provider delayDuration={400} skipDelayDuration={200}>
|
||||
{children}
|
||||
<Toaster position="bottom-right" closeButton visibleToasts={3} duration={4500}
|
||||
offset={{ bottom: 24, right: 20 }} containerAriaLabel="操作通知"
|
||||
toastOptions={{ closeButtonAriaLabel: "关闭通知", style: { background: "var(--popover, #fffcf9)", color: "var(--popover-foreground, #38281f)", border: "1px solid var(--border, #ded5cc)" } }} />
|
||||
</Tooltip.Provider>
|
||||
</FeedbackContext.Provider>;
|
||||
}
|
||||
@@ -17,3 +17,6 @@
|
||||
.button:hover { background: #e9e7e3; }
|
||||
.button:focus-visible { outline: 2px solid #9d7d66; outline-offset: 2px; }
|
||||
.button:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
|
||||
.tooltip { z-index: 1000; max-width: 240px; padding: 6px 9px; border-radius: 6px; background: #49372e; color: #fffdf9; font-size: 12px; line-height: 1.45; box-shadow: 0 3px 12px #372f2420; user-select: none; }
|
||||
.arrow { fill: #49372e; }
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef, type ButtonHTMLAttributes } from "react";
|
||||
import { FeedbackContext } from "./app-feedback";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { useContext, forwardRef, type ButtonHTMLAttributes } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import styles from "./icon-button.module.css";
|
||||
|
||||
@@ -10,18 +12,25 @@ export type IconButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "chi
|
||||
};
|
||||
|
||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
function IconButton({ icon: Icon, label, className = "", type = "button", ...props }, ref) {
|
||||
return (
|
||||
function IconButton({ icon: Icon, label, title = label, className = "", type = "button", ...props }, ref) {
|
||||
const feedback = useContext(FeedbackContext);
|
||||
const button = (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
title={feedback ? undefined : title}
|
||||
className={`${styles.button} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
<Icon size={16} strokeWidth={1.6} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
if (!feedback || !title) return button;
|
||||
return <Tooltip.Root><Tooltip.Trigger asChild>{button}</Tooltip.Trigger><Tooltip.Portal>
|
||||
<Tooltip.Content className={styles.tooltip} sideOffset={7} collisionPadding={8}>
|
||||
{title}<Tooltip.Arrow className={styles.arrow} />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal></Tooltip.Root>;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { toast } from "sonner";
|
||||
|
||||
/** Application adapter. Domain/editor packages must not import Sonner. */
|
||||
export type Notification = { id: string; title: string; description?: string; level?: "info" | "success" | "warning" | "error" };
|
||||
export function notify({ id, title, description, level = "info" }: Notification) {
|
||||
return toast[level](title, { id, description });
|
||||
}
|
||||
export function dismissNotification(id: string) { toast.dismiss(id); }
|
||||
@@ -16,7 +16,7 @@ const { window } = dom;
|
||||
const NativeMessageChannel = globalThis.MessageChannel;
|
||||
const channels = [];
|
||||
globalThis.MessageChannel = class extends NativeMessageChannel { constructor() { super(); channels.push(this); } };
|
||||
for (const key of ["window", "document", "HTMLElement", "Element", "Node", "SVGElement", "Event", "MouseEvent", "KeyboardEvent", "MutationObserver", "localStorage", "getComputedStyle"]) Object.defineProperty(globalThis, key, { value: key === "window" ? window : typeof window[key] === "function" && key === "getComputedStyle" ? window[key].bind(window) : window[key], configurable: true });
|
||||
for (const key of ["window", "document", "HTMLElement", "Element", "Node", "SVGElement", "Event", "CustomEvent", "MouseEvent", "KeyboardEvent", "MutationObserver", "localStorage", "getComputedStyle"]) Object.defineProperty(globalThis, key, { value: key === "window" ? window : typeof window[key] === "function" && key === "getComputedStyle" ? window[key].bind(window) : window[key], configurable: true });
|
||||
Object.defineProperty(globalThis, "navigator", { value: window.navigator, configurable: true });
|
||||
globalThis.requestAnimationFrame = window.requestAnimationFrame.bind(window);
|
||||
globalThis.cancelAnimationFrame = window.cancelAnimationFrame.bind(window);
|
||||
@@ -51,7 +51,7 @@ const folder = await mkdtemp(resolve(tmpdir(), "endoleap-canvas-ui-"));
|
||||
try {
|
||||
const outfile = resolve(folder, "fixture.cjs");
|
||||
await build({
|
||||
stdin: { contents: 'export { default as GraphCanvas } from "./src/components/canvas/graph-canvas"; export { BoardDirectory } from "./src/components/workspace/board-directory"; export { useCanvasTree } from "./src/components/canvas/use-canvas-tree"; export { useCanvasAI } from "./src/components/canvas/use-canvas-ai"; export { IconPicker } from "./src/components/canvas/icon-picker"; export { makeCanvasNode, emptyCanvas } from "./src/lib/canvas"; export { createRoot } from "react-dom/client"; export { act, createElement } from "react";', resolveDir: process.cwd(), loader: "tsx" },
|
||||
stdin: { contents: 'export { default as GraphCanvas } from "./src/components/canvas/graph-canvas"; export { AppFeedback } from "./src/components/ui/app-feedback"; export { IconButton } from "./src/components/ui/icon-button"; export { notify, dismissNotification } from "./src/lib/notifications"; export { BoardDirectory } from "./src/components/workspace/board-directory"; export { useCanvasTree } from "./src/components/canvas/use-canvas-tree"; export { useCanvasAI } from "./src/components/canvas/use-canvas-ai"; export { IconPicker } from "./src/components/canvas/icon-picker"; export { makeCanvasNode, emptyCanvas } from "./src/lib/canvas"; export { createRoot } from "react-dom/client"; export { act, createElement } from "react";', resolveDir: process.cwd(), loader: "tsx" },
|
||||
outfile, bundle: true, platform: "node", format: "cjs", jsx: "automatic", logLevel: "silent", nodePaths: [resolve("node_modules")],
|
||||
loader: { ".css": "empty" },
|
||||
plugins: [{ name: "dynamic-component", setup(build) {
|
||||
@@ -61,10 +61,11 @@ try {
|
||||
if (process.env.ROTATION_BASELINE === "1") build.onLoad({ filter: /node-style-toolbar\.tsx$/ }, async ({ path }) => ({ contents: (await readFile(path, "utf8")).replace('onClick={(event) => event.stopPropagation()}', ''), loader: "tsx" }));
|
||||
} }],
|
||||
});
|
||||
const { GraphCanvas, BoardDirectory, useCanvasAI, useCanvasTree, IconPicker, makeCanvasNode, emptyCanvas, act, createElement: h, createRoot } = createRequire(import.meta.url)(outfile);
|
||||
const { GraphCanvas, AppFeedback, IconButton, notify, dismissNotification, BoardDirectory, useCanvasAI, useCanvasTree, IconPicker, makeCanvasNode, emptyCanvas, act, createElement: h, createRoot } = createRequire(import.meta.url)(outfile);
|
||||
const root = createRoot(document.querySelector("#app"));
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 30));
|
||||
const event = async (target, type, init = {}) => { assert.ok(target, `Missing target for ${type}`); await act(async () => { const Type = type.startsWith("pointer") ? PointerEvent : type.startsWith("key") ? window.KeyboardEvent : window.MouseEvent; target.dispatchEvent(new Type(type, { bubbles: true, cancelable: true, view: window, ...init })); await tick(); }); };
|
||||
if (process.env.CANVAS_UI_FEEDBACK_ONLY !== "1") {
|
||||
{
|
||||
const boards = [{ id: "root", title: "主画布", workspace_id: "w", slug: "root-slug" }, { id: "child", title: "未命名分区", parent_board_id: "root", workspace_id: "w", slug: "child-slug" }, { id: "nested", title: "孙画布", parent_board_id: "child", workspace_id: "w" }];
|
||||
const renderDirectory = (active, query = "") => h(BoardDirectory, { boards, workspace: "space", active, query, demo: false });
|
||||
@@ -218,6 +219,9 @@ try {
|
||||
await canvasMemberScenario({ root, act, h, useCanvasTree, GraphCanvas, event, window, makeCanvasNode, emptyCanvas, tick, mock: (handler) => { apiMock = handler; } });
|
||||
const { canvasUploadScenario } = await import("./canvas-upload-scenario.mjs");
|
||||
await canvasUploadScenario({ root, act, h, GraphCanvas, window, emptyCanvas, event, tick, mock: (handler) => { apiMock = handler; } });
|
||||
}
|
||||
const { feedbackScenario } = await import("./feedback-scenario.mjs");
|
||||
await feedbackScenario({ root, act, h, AppFeedback, IconButton, notify, dismissNotification, GraphCanvas, window, makeCanvasNode, emptyCanvas, event, tick });
|
||||
await act(async () => { root.unmount(); await tick(); });
|
||||
} finally {
|
||||
await rm(folder, { recursive: true, force: true }); window.close(); stop();
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
export async function feedbackScenario({ root, act, h, AppFeedback, IconButton, notify, dismissNotification, GraphCanvas, window, makeCanvasNode, emptyCanvas, event, tick }) {
|
||||
await act(async () => { root.render(null); await tick(); });
|
||||
let button, clicks = 0;
|
||||
const icon = () => h("svg", { "aria-hidden": true });
|
||||
const control = () => h(IconButton, { icon, label: "旋转工具", title: "旋转选中元素", ref: (value) => { button = value; }, onClick: () => { clicks++; } });
|
||||
await act(async () => { root.render(control()); await tick(); });
|
||||
assert.equal(button.title, "旋转选中元素", "Standalone controls retain native explanations");
|
||||
await act(async () => { root.render(h(AppFeedback, null, h("div", { id: "controls" }, control()))); await tick(); });
|
||||
assert.ok(document.querySelector("#controls").firstElementChild === button, "Tooltip does not wrap the button or alter toolbar geometry");
|
||||
assert.equal(button.tagName, "BUTTON", "Forwarded ref still points at the actual button");
|
||||
assert.equal(button.title, "", "A provider avoids duplicate native and Radix tooltips");
|
||||
await act(async () => { button.focus(); await tick(); });
|
||||
const tooltip = document.querySelector('[role="tooltip"]');
|
||||
assert.ok(tooltip?.textContent.includes("旋转选中元素"), "Keyboard focus exposes the explanation");
|
||||
assert.ok(button.getAttribute("aria-describedby"), "Explanation is associated with its trigger");
|
||||
await event(document, "keydown", { key: "Escape" });
|
||||
assert.ok(!document.querySelector('[role="tooltip"]'), "Escape dismisses the tooltip");
|
||||
await event(button, "click"); assert.equal(clicks, 1, "Tooltips preserve business clicks");
|
||||
await act(async () => {
|
||||
notify({ id: "feedback-check", title: "正在处理" }); await tick();
|
||||
notify({ id: "feedback-check", title: "已完成", level: "success" }); await tick();
|
||||
});
|
||||
assert.equal(document.querySelectorAll('[data-sonner-toast]').length, 1, "One logical notice updates instead of duplicating");
|
||||
assert.ok(document.querySelector('[data-sonner-toast]').textContent.includes("已完成"));
|
||||
assert.ok(document.querySelector('[aria-label="关闭通知"]'), "Notification close control is labelled");
|
||||
await act(async () => { dismissNotification("feedback-check"); await tick(); });
|
||||
for (let i = 0; i < 20 && document.querySelector('[data-sonner-toast]'); i++) await act(async () => { await tick(); });
|
||||
assert.ok(!document.querySelector('[data-sonner-toast]'), "Dismissed notifications are removed");
|
||||
console.log("PASS feedback: native fallback, forwarded ref, keyboard tooltip/Escape, clicks and stable notification IDs");
|
||||
|
||||
const id = "feedback-board";
|
||||
const node = { ...makeCanvasNode("text", { x: 100, y: 100 }), id: "feedback-text" };
|
||||
const frame = { ...makeCanvasNode("frame", { x: 500, y: 100 }), id: "feedback-frame" };
|
||||
frame.data.title = "提示分区";
|
||||
localStorage.setItem(`endoleap_canvas_v1:w:${id}`, JSON.stringify({ ...emptyCanvas(), nodes: [node, frame] }));
|
||||
await act(async () => { root.render(h(AppFeedback, null, h(GraphCanvas, { board: { id, workspace_id: "w", title: "反馈测试", kind: "board", version: 1 } }))); await tick(); });
|
||||
const textNode = document.querySelector('.react-flow__node[data-id="feedback-text"]');
|
||||
await event(textNode, "mousedown", { clientX: 200, clientY: 200, button: 0, buttons: 1 });
|
||||
await event(window, "mousemove", { clientX: 210, clientY: 200, buttons: 1 });
|
||||
await event(window, "mousemove", { clientX: 700, clientY: 200, buttons: 1 });
|
||||
await event(window, "mouseup", { clientX: 700, clientY: 200, button: 0 });
|
||||
assert.ok(document.querySelector('[data-sonner-toast][data-type="success"]')?.textContent.includes("已加入「提示分区」"));
|
||||
assert.ok(document.querySelector('[title="属于:提示分区"]'), "Notifications do not replace actual membership state");
|
||||
const fileInput = document.querySelector('input[type="file"][accept^="image/"]');
|
||||
Object.defineProperty(fileInput, "files", { configurable: true, value: [new File(["test"], "test.pdf", { type: "application/pdf" })] });
|
||||
await act(async () => { fileInput.dispatchEvent(new window.Event("change", { bubbles: true })); await tick(); });
|
||||
assert.ok(document.querySelector('[data-sonner-toast][data-type="error"]')?.textContent.includes("演示模式请选择"));
|
||||
await event(document.querySelector('[aria-label="添加文本卡片"]'), "click");
|
||||
assert.equal(document.querySelectorAll('.react-flow__node').length, 3, "An error notification never blocks editing");
|
||||
await act(async () => { dismissNotification(`canvas:${id}:error`); root.render(null); await tick(); });
|
||||
console.log("PASS canvas feedback: membership success, input errors and continued local editing through the application provider");
|
||||
}
|
||||
Reference in New Issue
Block a user