Cull offscreen canvas nodes while preserving active editing

This commit is contained in:
2026-11-08 17:56:54 +08:00
parent 8a2b1e4bc7
commit 1f89f9ebb5
6 changed files with 69 additions and 19 deletions
+6 -3
View File
@@ -8,10 +8,12 @@ import type { CanvasNode } from "@/lib/canvas";
import { NodeEditing, TransformableNode, ResizeToggle } from "./transformable-node";
import { FrameFeedback } from "./frame-feedback";
import { FrameLabelMenu } from "./frame-label-menu";
import { ContentHandles } from "./content-handles";
import styles from "./canvas.module.css";
export function FrameNode(props: NodeProps<CanvasNode>) {
const { id, data, selected } = props;
export function FrameNode(props: NodeProps<CanvasNode> & { directAnchors?: boolean }) {
const { directAnchors = false, ...nodeProps } = props;
const { id, data, selected } = nodeProps;
const editing = useContext(NodeEditing);
const feedback = useContext(FrameFeedback);
const incoming = feedback.drops.filter((drop) => drop.to === id && drop.from !== id).length;
@@ -47,11 +49,12 @@ export function FrameNode(props: NodeProps<CanvasNode>) {
</motion.div>}</AnimatePresence>
</NodeToolbar>
<TransformableNode {...props} hideStyleToolbar className={`${styles.frameNode} ${incoming ? styles.frameDropTarget : ""} ${outgoing ? styles.frameDropExit : ""}`}>
<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>}
{(incoming > 0 || outgoing > 0) && <div className={styles.frameDropHint} style={{ scale: 1 / (zoom * (data.viewScale || 1)) }}>{incoming > 0 ? `松开加入 · ${incoming} 项` : `松开移出 · ${outgoing} 项`}</div>}
{(["top", "right", "bottom", "left"] as const).map((side) => <div key={side} className={`${styles.frameBorderHit} ${styles["frameBorder_" + side]}`} title="拖动分区,已有成员一起移动" />)}
{directAnchors && <ContentHandles selected={!!selected} />}
{data.ports?.map((port) => {
const peers = data.ports!.filter((item) => item.direction === port.direction);
return <Handle key={port.id} id={`port:${port.id}`} type="source" position={port.direction === "input" ? Position.Left : Position.Right} className={styles.framePort} style={{ top: `${(peers.indexOf(port) + 1) / (peers.length + 1) * 100}%` }} isConnectableStart={selected && port.direction === "output"} isConnectableEnd={port.direction === "input"} title={port.direction === "input" ? "子画布输入" : "子画布输出"} />;
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore
import { Background, BackgroundVariant, ConnectionMode, ReactFlow, ReactFlowProvider, useReactFlow, type Connection, type NodeProps } from "@xyflow/react";
import { Copy, Download, Frame, Hand, ImagePlus, Maximize, Minus, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Type, Undo2 } from "lucide-react";
import type { Board } from "@/lib/workspace";
import type { CanvasNode } from "@/lib/canvas-elements";
import type { Data, ElementRecord, Geometry, Id, Json, Result } from "@/lib/canvas-core/types";
import { equal } from "@/lib/canvas-core/values";
import { createProjectionCache } from "@/lib/canvas-bindings/projection-cache";
@@ -28,7 +29,8 @@ import { IconNode } from "./icon-node";
import { ImagePreviews } from "./canvas-image";
import styles from "./canvas.module.css";
const nodeTypes = { material: MaterialNode, plainText: PlainTextNode, plainImage: PlainImageNode, frame: FrameNode, icon: IconNode,
function HeadlessFrameNode(props: NodeProps<CanvasNode>) { return <FrameNode {...props} directAnchors />; }
const nodeTypes = { material: MaterialNode, plainText: PlainTextNode, plainImage: PlainImageNode, frame: HeadlessFrameNode, icon: IconNode,
unsupported: ({ data }: NodeProps<ProjectionNode>) => <div className={styles.node} style={{ width: "100%", height: "100%", padding: 14 }} role="note">{data.title}<small style={{ display: "block" }}>此元素需要更新版本才能编辑</small></div>,
};
const edgeTypes = { default: CanvasEdge };
@@ -41,6 +43,8 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B
if (liveView) lastView.current = liveView;
const view = liveView ?? lastView.current!, flow = useReactFlow<ProjectionNode>();
const preparingCopy = [...(root.runtime.imports?.values() ?? [])].some(job => job.status === 'preparing' && job.source?.targetCanvasId === view.canvasId);
const [editorFocused, setEditorFocused] = useState(false), focusEpoch = useRef(0);
useEffect(() => { focusEpoch.current++; setEditorFocused(false); return () => { focusEpoch.current++; }; }, [engine,viewId]);
const [copying, setCopying] = useState(false);
const [showAI, setShowAI] = useState(false), [showRecovery, setShowRecovery] = useState(false);
const [error, setError] = useState(""), [sources, setSources] = useState(new Map<string, string>());
@@ -53,7 +57,9 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B
const result = projectCanvas(root.document, view.canvasId, engine.editor.registry, view.selection, engine.sessions.effectiveGeometry, sessions.connectionGesture);
// Preserve renderer measurements across metadata/preview updates. Dropping them
// makes React Flow discard handle bounds and unmount active edge controls.
for (const node of result.nodes) { const cached = measurements.current.get(node.id); if (cached && cached.width === node.width && cached.height === node.height) node.measured = cached.measured; }
const visibleIds = new Set(result.nodes.map(node => node.id));
for (const id of measurements.current.keys()) if (!visibleIds.has(id)) measurements.current.delete(id);
for (const node of result.nodes) { const cached = measurements.current.get(node.id); if (cached && cached.width === node.width && cached.height === node.height && projectionCache.current!.canRetainMeasurement(engine,root.document,view.canvasId,node)) node.measured = cached.measured; }
return projectionCache.current!.reuse(engine,root.document,view.canvasId,result);
}, [engine, root.document, sessions, view]);
const current = useRef({ root, projection, view, engine }); current.current = { root, projection, view, engine };
@@ -78,7 +84,7 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B
const { root, projection } = current.current, element = root.document.elements.get(id);
if (element?.structure !== 'node') return;
const t = projection.transforms.get(element.ownerCanvasId)!;
const displayed = projection.nodes.find(node => node.id === id)!;
const displayed = projectionCache.current!.getNode(id)!;
const width = (change.width ?? displayed.width!) / t.scale, height = (change.height ?? displayed.height!) / t.scale;
const position = change.position ?? displayed.position, center = toLocal(t, { x: position.x + width * t.scale / 2, y: position.y + height * t.scale / 2 });
const patch: Partial<Geometry> = { x: center.x - width / 2, y: center.y - height / 2, width, height, ...(change.rotation !== undefined ? { rotation: ((change.rotation - t.rotation) % 360 + 360) % 360 } : {}) };
@@ -121,6 +127,9 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B
return () => { window.removeEventListener('blur', cancel); window.removeEventListener('keydown', escape, true); };
}, [engine, viewId]);
const modes = view.modes;
// Native culling keeps the full graph in React Flow. Retain every mounted editor/lease
// while an interaction is active so viewport changes cannot discard IME or capture.
const cullVisible = !editorFocused && modes.size === 0 && sessions.gesture?.viewId !== viewId && sessions.connectionGesture?.viewId !== viewId;
const selected = view.selection.size === 1 ? root.document.elements.get([...view.selection][0]) : undefined;
const aiElement = selected?.structure === 'node' && engine.editor.registry.resolve(selected) === 'known' && ['text','plain-text','script','image','plain-image'].includes(selected.componentId) ? selected : undefined;
// Runtime-only publications must not invalidate every card's editing context.
@@ -137,7 +146,7 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B
rotating, rotate: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'rotate') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'rotate'); },
resizing, resize: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'resize') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'resize'); },
branch: (id, direction) => {
const node = current.current.projection.nodes.find(node => node.id === id); if (!node) return;
const node = projectionCache.current!.getNode(id); if (!node) return;
const dx = direction === 'left' ? -340 : direction === 'right' ? 340 : 0, dy = direction === 'top' ? -280 : direction === 'bottom' ? 280 : 0;
report(engine.editor.transaction(tx => { const created = add('text', undefined, tx, { x: node.position.x + dx, y: node.position.y + dy }); const source = tx.getDocument().elements.get(id)!, next = tx.getDocument().elements.get(created)!; tx.dispatch({ type: 'element.create', element: { id: crypto.randomUUID(), incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: commonCanvas(tx.getDocument(), source.ownerCanvasId, next.ownerCanvasId), source: { elementId: id, portId: direction }, target: { elementId: created }, data: {}, path: null } }); }, { label: 'Add branch' }));
},
@@ -165,6 +174,8 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B
}),[engine,viewId,report]);
if (!liveView) return <p role="status">画布已关闭</p>;
return <div className={`${styles.editor} ${view.tool === 'pan' ? styles.handMode : ''}`} data-headless-canvas tabIndex={-1}
onFocusCapture={event => { if ((event.target as HTMLElement).closest('input,textarea,[contenteditable="true"],[role="textbox"]')) { focusEpoch.current++; setEditorFocused(true); } }}
onBlurCapture={() => { const epoch = ++focusEpoch.current; queueMicrotask(() => { if (focusEpoch.current === epoch) setEditorFocused(false); }); }}
onMouseDownCapture={event => { if (event.button === 0 && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }); }}
onTouchStartCapture={event => { if (event.touches.length > 1) { engine.sessions.cancel('multi_touch'); drag.current = null; return; } const touch = event.touches[0]; if (touch && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = flow.screenToFlowPosition({ x: touch.clientX, y: touch.clientY }); }}
onPointerDownCapture={event => { if (event.button === 0 && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }); }}
@@ -181,7 +192,7 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B
<ImagePreviews.Provider value={imagePreviews}>
<NodeEditing.Provider value={nodeEditing}>
<EdgeEditing.Provider value={edgeEditing}>
<ReactFlow<ProjectionNode> nodes={projection.nodes} edges={projection.edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} connectionMode={ConnectionMode.Loose}
<ReactFlow<ProjectionNode> onlyRenderVisibleElements={cullVisible} nodes={projection.nodes} edges={projection.edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} connectionMode={ConnectionMode.Loose}
nodesDraggable={view.tool === 'select'} panOnDrag={view.tool === 'pan' ? true : [1, 2]} selectionOnDrag={view.tool === 'select'} panOnScroll zoomOnScroll={false} minZoom={.1} maxZoom={4} deleteKeyCode={null} defaultViewport={view.viewport}
onMoveEnd={(_, viewport) => engine.sessions.viewport(viewId, viewport)}
@@ -189,12 +200,12 @@ function CanvasView({ engine, board, viewId }: { engine: ProjectEngine; board: B
const ids = new Set((nodes.length ? nodes : [node]).map(node => node.id));
const top = [...ids].filter(id => { const element = current.current.root.document.elements.get(id)!; return !ancestors(current.current.root.document, element.ownerCanvasId).some(canvasId => { const parent = current.current.root.document.canvases.get(canvasId)?.parent; return parent && ids.has(parent.entryElementId); }); });
const point = 'clientX' in event ? event : event.changedTouches[0];
drag.current = { token: 0, start: pointerStart.current ?? flow.screenToFlowPosition({ x: point.clientX, y: point.clientY }), positions: new Map(top.map(id => [id, current.current.projection.nodes.find(node => node.id === id)!.position])) };
drag.current = { token: 0, start: pointerStart.current ?? flow.screenToFlowPosition({ x: point.clientX, y: point.clientY }), positions: new Map(top.map(id => [id, projectionCache.current!.getNode(id)!.position])) };
const result = engine.sessions.begin(viewId, top.map(target)); report(result);
if (result.enabled) drag.current.token = engine.sessions.getSnapshot().gesture!.token; else drag.current = null;
}}
onEdgesChange={changes => selectChanges(changes.filter(change => change.type === 'select'))}
onNodesChange={changes => { for (const change of changes) if (change.type === 'dimensions' && change.dimensions) { const node = current.current.projection.nodes.find(node => node.id === change.id); if (node) measurements.current.set(change.id, { width: node.width!, height: node.height!, measured: { ...change.dimensions } }); } selectChanges(changes.filter(change => change.type === 'select')); for (const change of changes) if (change.type === 'position' && change.position && engine.sessions.getSnapshot().gesture?.viewId === viewId && drag.current?.token === engine.sessions.getSnapshot().gesture?.token && engine.sessions.getSnapshot().gesture?.targets.some(target => target.id === change.id)) transform(change.id, { position: change.position }); }}
onNodesChange={changes => { for (const change of changes) if (change.type === 'dimensions' && change.dimensions) { const node = projectionCache.current!.getNode(change.id); if (node) measurements.current.set(change.id, { width: node.width!, height: node.height!, measured: { ...change.dimensions } }); } selectChanges(changes.filter(change => change.type === 'select')); for (const change of changes) if (change.type === 'position' && change.position && engine.sessions.getSnapshot().gesture?.viewId === viewId && drag.current?.token === engine.sessions.getSnapshot().gesture?.token && engine.sessions.getSnapshot().gesture?.targets.some(target => target.id === change.id)) transform(change.id, { position: change.position }); }}
onNodeDragStop={event => {
const saved = drag.current; drag.current = null; pointerStart.current = null;
if (engine.sessions.getSnapshot().gesture?.viewId !== viewId || !saved || engine.sessions.getSnapshot().gesture?.token !== saved.token) return;
@@ -23,6 +23,11 @@ export function createProjectionCache(){
nodes=retain(document,projection.nodes,nodes);edges=retain(document,projection.edges,edges);
return projection;
},
getNode(id:Id){return nodes.get(id)?.value;},
canRetainMeasurement(owner:object,document:Document,viewCanvasId:Id,value:Projection['nodes'][number]){
const old=nodes.get(value.id),record=document.elements.get(value.id);
return scope===owner&&projectId===document.projectId&&canvasId===viewCanvasId&&!!old&&!!record&&old.incarnation===record.incarnation&&old.ownerCanvasId===record.ownerCanvasId&&old.value.type===value.type&&old.value.width===value.width&&old.value.height===value.height&&equal(old.value.data,value.data);
},
clear(){nodes.clear();edges.clear();scope=undefined;projectId=undefined;canvasId=undefined;},
});
}