fix(canvas): replace native frame options with a custom menu
This commit is contained in:
@@ -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)}
|
||||
</>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user