Batch node measurements and mount toolbars only while active

This commit is contained in:
2026-11-10 00:03:14 +08:00
parent da7d9d056f
commit d8bf6406c6
9 changed files with 97 additions and 14 deletions
+4 -4
View File
@@ -33,8 +33,8 @@ export function FrameNode(props: NodeProps<CanvasNode> & { directAnchors?: boole
setTitle(name);
}
return <>
<NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={45}>
<AnimatePresence>{selected && <motion.div ref={overlay} className={`${styles.styleToolbar} nodrag nopan nowheel`} role="toolbar" aria-label="分区设置"
<AnimatePresence>{selected && <NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={45}>
<motion.div ref={overlay} className={`${styles.styleToolbar} nodrag nopan nowheel`} role="toolbar" aria-label="分区设置"
initial={{ opacity: 0, y: reduce ? 0 : 5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reduce ? 0 : 5 }} transition={{ duration: reduce ? 0 : 0.14 }}
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(); }}
@@ -50,8 +50,8 @@ export function FrameNode(props: NodeProps<CanvasNode> & { directAnchors?: boole
<label className={styles.subgraphScale} title="子画布概览比例,不修改内容尺寸"><input aria-label="子画布概览比例" type="number" min={5} max={400} step={5} value={Math.round((data.presentation?.contentScale || 1) * 100)} onChange={(event) => { const scale = +event.target.value / 100; if (scale >= .05 && scale <= 4) editing.frame(id, { presentation: { ...data.presentation, contentScale: scale } }); }} />%</label>
<a href={`/dashboard/${encodeURIComponent(editing.workspace)}/${data.subgraph.boardId}`} aria-label="打开子画布" title="打开子画布"><ExternalLink size={15} /></a></>}
</motion.div>}</AnimatePresence>
</NodeToolbar>
</motion.div>
</NodeToolbar>}</AnimatePresence>
<TransformableNode {...nodeProps} hideStyleToolbar className={`${styles.frameNode} ${incoming ? styles.frameDropTarget : ""} ${outgoing ? styles.frameDropExit : ""}`}>
{data.subgraph && data.presentation?.collapsed && <a className={styles.collapsedSubgraph} href={`/dashboard/${encodeURIComponent(editing.workspace)}/${data.subgraph.boardId}`} onPointerDown={(event) => event.stopPropagation()}><FoldVertical size={22} /><span>{data.title}</span><small>打开子画布</small></a>}
{mode !== "hidden" && <div className={styles.frameLabel} style={{ scale: mode === "fixed" ? 1 / (zoom * (data.viewScale || 1)) : 1 }} title={data.title}>{data.title}</div>}
+19
View File
@@ -0,0 +1,19 @@
"use client";
import {useCallback,useEffect,useRef} from 'react';
import {useStoreApi,useUpdateNodeInternals} from '@xyflow/react';
import {createNodeInternalsBatch} from '@/lib/canvas-bindings/node-internals';
// useStoreApi wraps the API per hook. Its shared getState function identifies the
// actual store, separating canvases even when they render the same project.
const batches=new WeakMap<object,ReturnType<typeof createNodeInternalsBatch>>();
export function useNodeInternalsUpdate(id:string){
const scope=useStoreApi().getState,update=useUpdateNodeInternals();
const lease=useRef<ReturnType<ReturnType<typeof createNodeInternalsBatch>['acquire']>|undefined>(undefined);
useEffect(()=>{
let batch=batches.get(scope);if(!batch){batch=createNodeInternalsBatch(update);batches.set(scope,batch);}
const owner=batch.acquire(id);lease.current=owner;
return()=>{owner.dispose();if(lease.current===owner)lease.current=undefined;};
},[scope,update,id]);
return useCallback(()=>lease.current?.invalidate(),[]);
}
+4 -4
View File
@@ -18,8 +18,8 @@ export function NodeStyleToolbar({ id, data, selected }: NodeProps<CanvasNode>)
const style = data.textStyle || {};
const text = data.kind === "text" || data.kind === "script" || data.kind === "plain-text";
const patch = (value: TextStyle, fontSize?: number) => editing.style(id, value, fontSize);
return <NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={34}>
<AnimatePresence>{selected && <motion.div ref={overlay} initial={{ opacity: 0, y: reduce ? 0 : 5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reduce ? 0 : 5 }} transition={{ duration: reduce ? 0 : 0.14 }} className={`${styles.styleToolbar} nodrag nopan nowheel`} data-node-style={id} role="toolbar" aria-label="元素样式"
return <AnimatePresence>{selected && <NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={34}>
<motion.div ref={overlay} initial={{ opacity: 0, y: reduce ? 0 : 5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reduce ? 0 : 5 }} transition={{ duration: reduce ? 0 : 0.14 }} className={`${styles.styleToolbar} nodrag nopan nowheel`} data-node-style={id} role="toolbar" aria-label="元素样式"
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if(event.key!=="Escape"){event.stopPropagation();return;}
@@ -39,6 +39,6 @@ export function NodeStyleToolbar({ id, data, selected }: NodeProps<CanvasNode>)
{!(["image", "plain-image"].includes(data.kind)) && <button title="清除背景" aria-label="清除背景" onClick={() => patch({ background: "transparent" })}>∅</button>}
{data.kind !== "icon" && <ResizeToggle id={id} renderedTarget={data.__headlessTarget} />}
<button data-rotate-toggle={id} title="旋转模式" aria-label="旋转模式" aria-pressed={editing.rotating === id} onClick={() => editing.rotate(editing.rotating === id ? null : id)}><RotateCw size={16} /></button>
</motion.div>}</AnimatePresence>
</NodeToolbar>;
</motion.div>
</NodeToolbar>}</AnimatePresence>;
}
+4 -3
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef, type PointerEvent, type ReactNode } from "react";
import { useReactFlow, useUpdateNodeInternals, useViewport, type NodeProps, type XYPosition } from "@xyflow/react";
import { useReactFlow, useViewport, type NodeProps, type XYPosition } from "@xyflow/react";
import { Grip, Maximize2, RotateCw } from "lucide-react";
import { elementCapabilities } from "@/lib/canvas-elements";
import type { CanvasNode } from "@/lib/canvas";
@@ -13,6 +13,7 @@ import { ContentHandles } from "./content-handles";
import {ownsPointerCapture,acquirePointerCapture,releasePointerCapture} from "@/lib/canvas-bindings/pointer-capture";
import {canvasKeyContext,routeCanvasKey} from "@/lib/canvas-bindings/input-router";
import styles from "./canvas.module.css";
import {useNodeInternalsUpdate} from "./node-internals";
export {NodeEditing} from "./node-editing";
import {useScopedNodeEditing} from "./node-editing";
@@ -22,7 +23,7 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
const { id, data, selected, width, height, children, className = "" } = props;
const editing = useScopedNodeEditing(id,data.__headlessTarget);
const flow = useReactFlow<CanvasNode>();
const update = useUpdateNodeInternals();
const update = useNodeInternalsUpdate(id);
const { zoom } = useViewport();
const frame = useRef<HTMLDivElement>(null);
const gesture = useRef<Gesture | null>(null);
@@ -33,7 +34,7 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
const capabilities = elementCapabilities(data.kind);
const resizing = capabilities.resizable && editing.resizing === id && selected;
const rotating = capabilities.rotatable && editing.rotating === id && selected;
useEffect(() => { update(id); }, [id, rotation, width, height, selected, update]);
useEffect(() => { update(); }, [id, rotation, width, height, selected, update]);
useEffect(() => {
if (!rotating && !resizing) return;
const outside = (event: globalThis.PointerEvent) => {
+17
View File
@@ -0,0 +1,17 @@
/** One renderer store's transient measurement requests. Not model or history state. */
export function createNodeInternalsBatch(update:(ids:string[])=>void,defer:(flush:()=>void)=>void=queueMicrotask){
const owners=new Map<symbol,string>(),dirty=new Set<symbol>();let queued=false;
function flush(){
queued=false;const ids=new Set<string>();for(const owner of dirty){const id=owners.get(owner);if(id!==undefined)ids.add(id);}dirty.clear();
if(ids.size)update([...ids]);
}
return Object.freeze({
acquire(id:string){
const owner=Symbol(id);owners.set(owner,id);
return Object.freeze({
invalidate(){if(!owners.has(owner))return;dirty.add(owner);if(!queued){queued=true;defer(flush);}},
dispose(){owners.delete(owner);dirty.delete(owner);},
});
},
});
}