diff --git a/src/components/canvas/branch-menu.tsx b/src/components/canvas/branch-menu.tsx index ca7aa4c..219cf83 100644 --- a/src/components/canvas/branch-menu.tsx +++ b/src/components/canvas/branch-menu.tsx @@ -21,11 +21,14 @@ export function BranchMenu({ request, close, choose }: { request: BranchRequest if (!request) return; const focus = document.activeElement as HTMLElement | null; ref.current?.querySelector("button[data-choice]")?.focus(); + // outside 不阻止事件,关闭后原指针仍可交给被点击的画布/控件;这是非模态浮层,不提供焦点圈定。 const outside = (event: PointerEvent) => { if (!ref.current?.contains(event.target as Node)) close(); }; const escape = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); focus?.focus(); } }; window.addEventListener("pointerdown", outside); window.addEventListener("keydown", escape, true); return () => { window.removeEventListener("pointerdown", outside); window.removeEventListener("keydown", escape, true); }; }, [request, close]); + // choose 不在本组件内调用 close;异步创建、异常报告和关闭时机由宿主回调负责。 + // close 引用或 request 变化会重装监听并重新捕获焦点,不只在空→非空时执行。 // 无document时不创建Portal。菜单位置按预设宽高限制在视口边距内;上下键循环所有按钮(含关闭)。 // 减少动态效果偏好禁用位移/缩放动画;退出动画仍通过AnimatePresence管理。 if (typeof document === "undefined") return null; diff --git a/src/components/canvas/edge-toolbar.tsx b/src/components/canvas/edge-toolbar.tsx index cde0d06..0404449 100644 --- a/src/components/canvas/edge-toolbar.tsx +++ b/src/components/canvas/edge-toolbar.tsx @@ -12,6 +12,7 @@ import styles from "./canvas.module.css"; export function EdgeToolbarControls({ id, x, y, data, change, add, remove, count, knotMode, changeKnotMode }: { id: string; x: number; y: number; data: EdgeData; change: (data: EdgeData) => void; add: () => void; remove: () => void; count: number; knotMode?: string; changeKnotMode: (mode: "corner" | "smooth" | "symmetric") => void }) { const a = data.appearance || {}, animation = data.animation || {}; /** 合并appearance子对象并保留其它连线数据;箭头/箭尾各自更新时仍需在事件内保留其嵌套字段。 */ + // 合并基于本次 render 捕获的 data;change 必须由外层正确采用,不能把旧回调当成读取最新文档的入口。 const appearance = (patch: NonNullable) => change({ ...data, appearance: { ...a, ...patch } }); /** 动画属性单独合并,调整一个设置不覆盖已有方向、效果等其它字段。 */ const animate = (patch: NonNullable) => change({ ...data, animation: { ...animation, ...patch } }); @@ -20,6 +21,7 @@ export function EdgeToolbarControls({ id, x, y, data, change, add, remove, count // 宽度校验0.5—20,端部尺寸4—32,动画时长250—30000ms;透明度范围由range控件给出。 // head/tail分别合并type/size/filled;传播开关将execution替换为当前enabled对象。 // 无曲线点时禁用模式和删除,达到64点时禁用添加;外层仍负责相应操作的实际合法性。 + // 每个控件即时调用回调,不负责 history 分组或持久化;数值范围检查也不要求步长整数倍。 // 所有指针/键盘冒泡止于工具栏,避免颜色或输入操作同时触发画布编辑。 return
event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}> diff --git a/src/components/canvas/frame-feedback.tsx b/src/components/canvas/frame-feedback.tsx index e90e31a..12f7582 100644 --- a/src/components/canvas/frame-feedback.tsx +++ b/src/components/canvas/frame-feedback.tsx @@ -20,6 +20,7 @@ export function MembershipBadge({ id, frameId, selected }: { id: string; frameId const name = target ? names.get(target) : undefined; if (!drop && !selected) return null; // 目标有名称时区分“松开加入”与“属于”;没有目标且原来有归属则提示移出,否则不绘制。 + // 提示依赖 names 完整:目标有 ID 却缺名称时也会落入下面的移出/空提示分支,不证明真实归属已改变。 const label = name ? `${drop && drop.to !== drop.from ? "松开加入" : "属于"}:${name}` : drop?.from ? `松开移出:${names.get(drop.from) || "分区"}` : null; return label &&
{label}
; } diff --git a/src/components/canvas/frame-label-menu.tsx b/src/components/canvas/frame-label-menu.tsx index 547d78f..b94fad9 100644 --- a/src/components/canvas/frame-label-menu.tsx +++ b/src/components/canvas/frame-label-menu.tsx @@ -17,7 +17,9 @@ const options = [ export function FrameLabelMenu({ value, change }: { value: FrameLabelMode; change: (mode: FrameLabelMode) => void }) { // 以数组保存三个真实按钮引用,键盘选择后直接聚焦;滑块索引在未知value时回退0。 const items = useRef<(HTMLButtonElement | null)[]>([]); + // 未知值只让视觉索引回退0,下面 radio 的 aria-checked/tabIndex 仍按原 value 比较,不会自动修复模型。 const current = Math.max(0, options.findIndex((option) => option.value === value)); + // 键盘计算基于受控 value 而非实际焦点;change 应让宿主及时发布新值,连续按键才沿新索引前进。 // 左/上为-1,右/下为+1,通过加3再取模实现循环;Home/End直接使用0/2。 // 阻止默认滚动并停止冒泡,避免选择模式的按键同时移动画布;滑块每项28px。 return
{ diff --git a/src/components/canvas/icon-catalog.ts b/src/components/canvas/icon-catalog.ts index cae59ef..46e7716 100644 --- a/src/components/canvas/icon-catalog.ts +++ b/src/components/canvas/icon-catalog.ts @@ -17,6 +17,7 @@ export type IconSource = "all" | "lucide" | "tabler"; * 筛选同时满足来源和分类;common看common标记,other指没有分类,all跳过对应限制。 * 保留目录原顺序,不计算相关性分数,也不限制结果数量;虚拟列表由调用方实现。 */ export function searchIcons(query: string, category = "all", source: IconSource = "all") { + // 每次扫描全部目录并归一化检索串,无预建倒排索引;返回新数组但条目仍共享静态记录引用。 const needle = query.trim().toLowerCase().replace(/[-_\s]/g, ""); return iconCatalog.filter((icon) => (source === "all" || icon.source === source) && (category === "all" || (category === "common" ? icon.common : category === "other" ? !icon.categories.length : icon.categories.includes(category))) diff --git a/src/components/canvas/icon-picker.tsx b/src/components/canvas/icon-picker.tsx index a280419..2f74d8e 100644 --- a/src/components/canvas/icon-picker.tsx +++ b/src/components/canvas/icon-picker.tsx @@ -40,6 +40,7 @@ export function IconPicker({ close, choose }: { close: () => void; choose: (name const rows = Math.ceil(matches.length / columns); const firstRow = Math.max(0, Math.floor(scrollTop / rowHeight) - 1); const lastRow = Math.min(rows, Math.ceil((scrollTop + height) / rowHeight) + 1); + // 虚拟化只限制挂载按钮,matches 仍保留完整同步筛选结果;不是分批解析/下载目录。 const visible = matches.slice(firstRow * columns, lastRow * columns); /** 先钳制目标索引,再按行把目标滚入视口;等下一帧虚拟按钮挂载后调用focus。 * 空结果没有可聚焦按钮,最终可选链自然不执行focus;不凭空创建占位项目。 */ @@ -52,8 +53,10 @@ export function IconPicker({ close, choose }: { close: () => void; choose: (name else if (top + rowHeight > grid.current.scrollTop + height) grid.current.scrollTop = top + rowHeight - height; setScrollTop(grid.current.scrollTop); } + // 帧未单独取消且不带筛选代次;执行时读最新 grid,筛选变化可能让同一 index 指向另一条结果。 requestAnimationFrame(() => grid.current?.querySelector(`[data-index="${next}"]`)?.focus()); } + // Portal 直接使用 document.body,仅供客户端挂载;dialog 角色不自动提供焦点陷阱或背景 inert。 // 分类点击同时清空搜索,来源选择保留查询;网格按钮按全局结果索引绝对定位,不按切片局部索引。 // 左右移动一项、上下移动一行,Home/End跳至结果首尾;只有当前focused项进入Tab顺序。 // 每个按钮由CanvasIcon加载路径,页脚结果数来自筛选数组;choose后是否关闭由调用方决定。 diff --git a/src/components/canvas/node-style-toolbar.tsx b/src/components/canvas/node-style-toolbar.tsx index 6b7dbbf..d7a1355 100644 --- a/src/components/canvas/node-style-toolbar.tsx +++ b/src/components/canvas/node-style-toolbar.tsx @@ -24,7 +24,9 @@ export function NodeStyleToolbar({ id, data, selected }: NodeProps) // 只有text/script/plain-text展示文字排版;图片不展示前景/背景色,图标可调颜色但不展示拉伸开关。 const text = data.kind === "text" || data.kind === "script" || data.kind === "plain-text"; /** 统一样式动作入口;字号作为独立可选参数,避免把它混进textStyle的字段空间。 */ + // 每次控件变化立即派发;没有在本组件合并拖动取色等多次输入为一个 undo 项,也没有等待服务端确认。 const patch = (value: TextStyle, fontSize?: number) => editing.style(id, value, fontSize); + // 显示默认值不写回 data;命令校验仍由 editing 实现,select 的类型断言不是运行时 schema 验证。 // 颜色输入用现有值或默认色;字号仅提交8—160范围,透明背景在取色器中显示白色但不自动写回模型。 // 加粗/斜体/下划线按当前值取反,对齐和行高直接提交所选值;清除背景明确写transparent。 // 常规事件停止冒泡;Escape只在非组合输入、非文本编辑且未被处理时关闭当前节点的变换模式。