From 3478daeccb3ed90079589a705d7c57546ef6cc12 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 6 Nov 2026 15:07:37 +0800 Subject: [PATCH] fix(canvas): replace native frame options with a custom menu --- src/components/canvas/canvas.module.css | 8 +++ src/components/canvas/frame-label-menu.tsx | 77 ++++++++++++++++++++++ src/components/canvas/frame-node.tsx | 6 +- tests/canvas-ui-check.mjs | 31 +++++++++ 4 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 src/components/canvas/frame-label-menu.tsx diff --git a/src/components/canvas/canvas.module.css b/src/components/canvas/canvas.module.css index e7514b4..a09b108 100644 --- a/src/components/canvas/canvas.module.css +++ b/src/components/canvas/canvas.module.css @@ -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; } diff --git a/src/components/canvas/frame-label-menu.tsx b/src/components/canvas/frame-label-menu.tsx new file mode 100644 index 0000000..6abff86 --- /dev/null +++ b/src/components/canvas/frame-label-menu.tsx @@ -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(null); + const menu = useRef(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 <> + + {open && createPortal(, document.body)} + ; +} diff --git a/src/components/canvas/frame-node.tsx b/src/components/canvas/frame-node.tsx index 2884cb3..fa39bd0 100644 --- a/src/components/canvas/frame-node.tsx +++ b/src/components/canvas/frame-node.tsx @@ -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) { @@ -34,9 +34,7 @@ export function FrameNode(props: NodeProps) { onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}> 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(); } }} /> - + editing.frame(id, { labelMode })} /> {count} 项内容 } diff --git a/tests/canvas-ui-check.mjs b/tests/canvas-ui-check.mjs index b0d427d..d1ac2a1 100644 --- a/tests/canvas-ui-check.mjs +++ b/tests/canvas-ui-check.mjs @@ -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(); });