Batch node measurements and mount toolbars only while active
This commit is contained in:
@@ -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>}
|
||||
|
||||
@@ -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(),[]);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user