From d0a9627cb6eb1f0e16231139c19a834953469c2a Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sat, 7 Nov 2026 00:31:16 +0800 Subject: [PATCH] Add application feedback and refine headless canvas design boundaries --- README.md | 4 +- VALIDATION.md | 10 ++ package-lock.json | 212 +++++++++++++++++++++++ package.json | 2 + src/app/layout.tsx | 3 +- src/components/canvas/graph-canvas.tsx | 21 ++- src/components/ui/app-feedback.tsx | 18 ++ src/components/ui/icon-button.module.css | 3 + src/components/ui/icon-button.tsx | 17 +- src/lib/notifications.ts | 10 ++ tests/canvas-ui-check.mjs | 10 +- tests/feedback-scenario.mjs | 54 ++++++ 12 files changed, 352 insertions(+), 12 deletions(-) create mode 100644 src/components/ui/app-feedback.tsx create mode 100644 src/lib/notifications.ts create mode 100644 tests/feedback-scenario.mjs diff --git a/README.md b/README.md index a966a21..3948ff0 100644 --- a/README.md +++ b/README.md @@ -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 的依赖。 ## 会话期限 diff --git a/VALIDATION.md b/VALIDATION.md index 82058f8..d77e78a 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -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 继续忽略,不强制跟踪。 diff --git a/package-lock.json b/package-lock.json index 9746f13..6084f66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5ec770f..3f19ece 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b9fe8ab..15c13f5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( - {children} + {children} ); } diff --git a/src/components/canvas/graph-canvas.tsx b/src/components/canvas/graph-canvas.tsx index fa0aa49..06a7064 100644 --- a/src/components/canvas/graph-canvas.tsx +++ b/src/components/canvas/graph-canvas.tsx @@ -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(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"); }} />} -
{membershipNotice}
+ {!feedback &&
{membershipNotice}
} {recovery && downloadDraft(recovery)} restore={() => { dispatch({ type: "replace", document: recovery }); setRecovery(null); }} discard={() => setRecovery(null)} />} {!recovery && childRecovery && downloadDraft(childRecovery[1])} restore={() => tree.resolveRecovery(childRecovery[0], true)} discard={() => tree.resolveRecovery(childRecovery[0], false)} />} {snapshot && sync.error &&
{sync.error === "local_backup_failed" ? "本地备份失败,请导出 JSON 备份。" : sync.error === "version_conflict" ? "画布已在其他地方修改,自动保存已停止,请导出当前内容或重新读取服务端版本。" : errorText(new Error(sync.error))} @@ -542,7 +557,7 @@ function CanvasEditor({ board, snapshot }: { board: Board; snapshot?: CanvasSnap ; }))} {tree.busy &&
{tree.pendingCommand ?
同步结果尚未确认,为保护内容暂时停止编辑。
: "正在同步画布…"}
} - {error && } + {error && !feedback && }
diff --git a/src/components/ui/app-feedback.tsx b/src/components/ui/app-feedback.tsx new file mode 100644 index 0000000..64c3661 --- /dev/null +++ b/src/components/ui/app-feedback.tsx @@ -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 + + {children} + + + ; +} diff --git a/src/components/ui/icon-button.module.css b/src/components/ui/icon-button.module.css index bd7b568..3df3986 100644 --- a/src/components/ui/icon-button.module.css +++ b/src/components/ui/icon-button.module.css @@ -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; } diff --git a/src/components/ui/icon-button.tsx b/src/components/ui/icon-button.tsx index a778290..5315ce8 100644 --- a/src/components/ui/icon-button.tsx +++ b/src/components/ui/icon-button.tsx @@ -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, "chi }; export const IconButton = forwardRef( - 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 = ( ); + if (!feedback || !title) return button; + return {button} + + {title} + + ; }, ); diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts new file mode 100644 index 0000000..24bdf40 --- /dev/null +++ b/src/lib/notifications.ts @@ -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); } diff --git a/tests/canvas-ui-check.mjs b/tests/canvas-ui-check.mjs index bd434eb..ac2df3b 100644 --- a/tests/canvas-ui-check.mjs +++ b/tests/canvas-ui-check.mjs @@ -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(); diff --git a/tests/feedback-scenario.mjs b/tests/feedback-scenario.mjs new file mode 100644 index 0000000..87833dd --- /dev/null +++ b/tests/feedback-scenario.mjs @@ -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"); +}