docs(canvas): explain menu focus and toolbar update boundaries
This commit is contained in:
@@ -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<HTMLButtonElement>("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;
|
||||
|
||||
@@ -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<EdgeData["appearance"]>) => change({ ...data, appearance: { ...a, ...patch } });
|
||||
/** 动画属性单独合并,调整一个设置不覆盖已有方向、效果等其它字段。 */
|
||||
const animate = (patch: NonNullable<EdgeData["animation"]>) => 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 <EdgeToolbar alignY="bottom" edgeId={id} x={x} y={y} isVisible style={{ zIndex: 115, pointerEvents: "auto" }}><div role="toolbar" aria-label="连线编辑" className={`${styles.edgeToolbar} nodrag nopan nowheel`}
|
||||
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
|
||||
|
||||
@@ -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 && <div className={styles.membershipBadge} title={label}><Frame size={11} />{label}</div>;
|
||||
}
|
||||
|
||||
@@ -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 <div className={styles.frameLabelSegments} role="radiogroup" aria-label="分区名称显示模式" onKeyDown={(event) => {
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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<HTMLButtonElement>(`[data-index="${next}"]`)?.focus());
|
||||
}
|
||||
// Portal 直接使用 document.body,仅供客户端挂载;dialog 角色不自动提供焦点陷阱或背景 inert。
|
||||
// 分类点击同时清空搜索,来源选择保留查询;网格按钮按全局结果索引绝对定位,不按切片局部索引。
|
||||
// 左右移动一项、上下移动一行,Home/End跳至结果首尾;只有当前focused项进入Tab顺序。
|
||||
// 每个按钮由CanvasIcon加载路径,页脚结果数来自筛选数组;choose后是否关闭由调用方决定。
|
||||
|
||||
@@ -24,7 +24,9 @@ export function NodeStyleToolbar({ id, data, selected }: NodeProps<CanvasNode>)
|
||||
// 只有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只在非组合输入、非文本编辑且未被处理时关闭当前节点的变换模式。
|
||||
|
||||
Reference in New Issue
Block a user