fix(canvas): replace native frame options with a custom menu

This commit is contained in:
2026-11-06 15:07:37 +08:00
parent 83021d0f6c
commit 3478daeccb
4 changed files with 118 additions and 4 deletions
+8
View File
@@ -82,6 +82,14 @@
.membershipBadge { position: absolute; bottom: -25px; left: 0; display: flex; align-items: center; gap: 4px; max-width: 280px; padding: 3px 6px; border-radius: 4px; background: #e9f1e9; color: #3e684b; font-size: 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; pointer-events: none; }
.rotatingFrame .membershipBadge { display: none; }
.frameMemberCount { font-size: 10px; color: #92877c; white-space: nowrap; }
.styleToolbar .frameModeTrigger { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: auto; min-width: 96px; flex: 0 0 auto; padding: 0 7px; font-size: 12px; }
.frameModeMenu { position: fixed; z-index: 120; width: 220px; max-width: calc(100vw - 16px); max-height: calc(100dvh - 16px); overflow-y: auto; padding: 5px; border: 1px solid #ded7ce; border-radius: 9px; background: #fffefb; color: #49372e; box-shadow: 0 8px 28px #32251a1f; }
.frameModeMenu button { display: flex; align-items: center; gap: 8px; width: 100%; padding: 9px 7px; border-radius: 5px; text-align: left; }
.frameModeMenu button:hover, .frameModeMenu button:focus { outline: none; background: #f1e6df; }
.frameModeMenu button[aria-checked="true"] { color: #78523f; }
.frameModeCheck { display: grid; place-items: center; width: 15px; flex: 0 0 15px; }
.frameModeMenu strong { display: block; font-size: 12px; font-weight: 500; }
.frameModeMenu small { display: block; margin-top: 2px; color: #92877c; font-size: 10px; }
.frameNode.frameDropTarget { border-color: #538366; background: #5383660c; box-shadow: 0 0 0 3px #53836638; }
.frameNode.frameDropExit { border-style: dashed; }
.frameDropHint { position: absolute; top: 12px; left: 12px; transform-origin: top left; border: 1px solid #9db8a4; border-radius: 6px; padding: 6px 9px; background: #f3f8f3; color: #3e684b; font-size: 12px; white-space: nowrap; pointer-events: none; }
@@ -0,0 +1,77 @@
"use client";
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Check, ChevronDown } from "lucide-react";
import type { FrameLabelMode } from "@/lib/canvas-elements";
import styles from "./canvas.module.css";
const options = [
{ value: "hidden", label: "隐藏名称", hint: "在画布上隐藏分区名称" },
{ value: "fixed", label: "固定字号", hint: "缩放画布时保持名称大小" },
{ value: "scaled", label: "随画布缩放", hint: "名称随画布一起放大缩小" },
] as const;
export function FrameLabelMenu({ value, change }: { value: FrameLabelMode; change: (mode: FrameLabelMode) => void }) {
const id = useId();
const [open, setOpen] = useState(false);
const [position, setPosition] = useState({ left: 0, top: 0 });
const trigger = useRef<HTMLButtonElement>(null);
const menu = useRef<HTMLDivElement>(null);
const items = useRef<(HTMLButtonElement | null)[]>([]);
const initialIndex = useRef(0);
const current = options.findIndex((option) => option.value === value);
function show(index = current) { initialIndex.current = index; setOpen(true); }
function close(focus = false) { setOpen(false); if (focus) trigger.current?.focus(); }
useLayoutEffect(() => {
if (!open) return;
const bounds = trigger.current!.getBoundingClientRect();
const height = menu.current!.getBoundingClientRect().height;
setPosition({ left: Math.max(8, Math.min(bounds.left, window.innerWidth - 228)), top: Math.max(8, bounds.bottom + height + 8 <= window.innerHeight ? bounds.bottom + 6 : bounds.top - height - 6) });
items.current[initialIndex.current]?.focus({ preventScroll: true });
}, [open]);
useEffect(() => {
if (!open) return;
const outside = (event: PointerEvent) => {
if (!trigger.current?.contains(event.target as Node) && !menu.current?.contains(event.target as Node)) close();
};
const dismiss = (event: Event) => {
if (event.target instanceof Node && menu.current?.contains(event.target)) return;
close();
};
document.addEventListener("pointerdown", outside, true);
window.addEventListener("resize", dismiss);
window.addEventListener("scroll", dismiss, true);
window.addEventListener("wheel", dismiss, true);
return () => {
document.removeEventListener("pointerdown", outside, true);
window.removeEventListener("resize", dismiss);
window.removeEventListener("scroll", dismiss, true);
window.removeEventListener("wheel", dismiss, true);
};
}, [open]);
return <>
<button ref={trigger} type="button" className={styles.frameModeTrigger} aria-label="分区名称显示模式" aria-haspopup="menu" aria-expanded={open} aria-controls={open ? id : undefined}
onClick={() => open ? close() : show()} onKeyDown={(event) => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); show(event.key === "ArrowUp" ? options.length - 1 : current); }
}}>{options[current].label}<ChevronDown size={13} /></button>
{open && createPortal(<div ref={menu} id={id} className={`${styles.frameModeMenu} nodrag nopan nowheel`} style={position} role="menu" aria-label="分区名称显示模式选项"
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()}
onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node)) close(); }}
onKeyDown={(event) => {
event.stopPropagation();
const index = items.current.indexOf(document.activeElement as HTMLButtonElement);
if (event.key === "Escape") { event.preventDefault(); close(true); }
else if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
event.preventDefault();
const next = event.key === "Home" ? 0 : event.key === "End" ? options.length - 1 : (index + (event.key === "ArrowDown" ? 1 : -1) + options.length) % options.length;
items.current[next]?.focus({ preventScroll: true });
}
}}>
{options.map((option, index) => <button key={option.value} ref={(element) => { items.current[index] = element; }} type="button" role="menuitemradio" aria-checked={value === option.value} tabIndex={-1}
onClick={() => { change(option.value); close(true); }}>
<span className={styles.frameModeCheck}>{value === option.value && <Check size={14} />}</span><span><strong>{option.label}</strong><small>{option.hint}</small></span>
</button>)}
</div>, document.body)}
</>;
}
+2 -4
View File
@@ -4,9 +4,9 @@ import { useContext, useEffect, useRef, useState } from "react";
import { NodeToolbar, Position, useViewport, type NodeProps } from "@xyflow/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { CanvasNode } from "@/lib/canvas";
import type { FrameLabelMode } from "@/lib/canvas-elements";
import { NodeEditing, TransformableNode } from "./transformable-node";
import { FrameFeedback } from "./frame-feedback";
import { FrameLabelMenu } from "./frame-label-menu";
import styles from "./canvas.module.css";
export function FrameNode(props: NodeProps<CanvasNode>) {
@@ -34,9 +34,7 @@ export function FrameNode(props: NodeProps<CanvasNode>) {
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
<input className={styles.frameNameInput} aria-label="分区名称" value={title} maxLength={300} onChange={(event) => setTitle(event.target.value)} onBlur={() => { if (cancelBlur.current) cancelBlur.current = false; else saveTitle(); }}
onKeyDown={(event) => { if (event.key === "Enter" && !event.nativeEvent.isComposing) { event.currentTarget.blur(); } if (event.key === "Escape") { cancelBlur.current = true; setTitle(data.title); event.currentTarget.blur(); } }} />
<select aria-label="分区名称显示模式" value={mode} onChange={(event) => editing.frame(id, { labelMode: event.target.value as FrameLabelMode })}>
<option value="hidden">隐藏名称</option><option value="fixed">固定字号</option><option value="scaled">随画布缩放</option>
</select>
<FrameLabelMenu value={mode} change={(labelMode) => editing.frame(id, { labelMode })} />
<span className={styles.frameMemberCount}>{count} 项内容</span>
</motion.div>}</AnimatePresence>
</NodeToolbar>
+31
View File
@@ -106,6 +106,37 @@ try {
assert.ok(document.querySelector('[role="status"]').textContent.includes("已移出「分区 A」"));
console.log("PASS frames: actual drag previews, membership badge, count and join/leave announcements");
const frameElement = document.querySelector('.react-flow__node[data-id="frame-one"]');
await event(frameElement, "click");
const modeButton = () => document.querySelector('[aria-label="分区名称显示模式"]');
assert.equal(modeButton().tagName, "BUTTON", "Frame options must use a custom control");
await event(modeButton(), "click");
assert.equal(document.querySelectorAll('[role="menuitemradio"]').length, 3);
assert.equal(document.querySelector('[role="menuitemradio"][aria-checked="true"] strong').textContent, "固定字号");
await event(document.querySelector('[role="menuitemradio"]'), "click");
assert.equal(modeButton().textContent, "隐藏名称");
assert.equal(frameElement.querySelector('[title="分区 A"]'), null);
assert.equal(document.querySelector('[role="menu"]'), null);
assert.equal(document.activeElement, modeButton());
await event(modeButton(), "keydown", { key: "ArrowDown" });
await event(document.activeElement, "keydown", { key: "End" });
assert.equal(document.activeElement.querySelector("strong").textContent, "随画布缩放");
await event(document.activeElement, "click");
assert.equal(modeButton().textContent, "随画布缩放");
assert.ok(frameElement.querySelector('[title="分区 A"]'));
await event(document.querySelector('[aria-label="撤销"]'), "click");
assert.equal(modeButton().textContent, "隐藏名称");
await event(document.querySelector('[aria-label="重做"]'), "click");
assert.equal(modeButton().textContent, "随画布缩放");
await event(modeButton(), "click");
await event(document.activeElement, "keydown", { key: "Escape" });
assert.equal(document.querySelector('[role="menu"]'), null);
assert.equal(document.activeElement, modeButton());
await event(modeButton(), "click");
await event(document.body, "pointerdown");
assert.equal(document.querySelector('[role="menu"]'), null);
console.log("PASS frame menu: custom options, selection, keyboard focus, Escape, outside dismissal and undo/redo");
let choice;
await act(async () => { root.render(h(IconPicker, { close() {}, choose(name) { choice = name; } })); await tick(); });
await act(async () => { await tick(); });