Track view-scoped overlay placements and retain portalled editors
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
import {createContext,useContext,useCallback,useLayoutEffect,useId,useRef} from 'react';
|
||||
import {useViewport} from '@xyflow/react';
|
||||
import type {OverlayIndex} from '@/lib/canvas-bindings/overlays';
|
||||
import type {ProjectEngine} from '@/lib/canvas-runtime/workspace';
|
||||
export const CanvasOverlays=createContext<{index:OverlayIndex;engine:ProjectEngine}|null>(null);
|
||||
/** Optional for legacy/engine-less renderers; a portal keeps its explicit element target. */
|
||||
export function useCanvasOverlay(targetId:string,placement:string){
|
||||
const scope=useContext(CanvasOverlays),instance=useId(),refresh=useRef<()=>void>(()=>{}),cleanup=useRef<()=>void>(()=>{});
|
||||
useViewport();
|
||||
const incarnation=scope?.engine.getSnapshot().document.elements.get(targetId)?.incarnation;
|
||||
const ref=useCallback((element:HTMLElement|null)=>{cleanup.current();refresh.current=()=>{};cleanup.current=()=>{};if(!element||!scope||incarnation===undefined)return;
|
||||
const marker=crypto.randomUUID();element.dataset.canvasOverlayView=scope.index.viewId;element.dataset.canvasOverlayOwner=marker;element.dataset.canvasOverlayTarget=targetId;element.dataset.canvasOverlayPlacement=placement;
|
||||
let composing=false;const value=()=>{const rect=element.getBoundingClientRect();return{placement,target:{id:targetId,incarnation},bounds:{x:rect.x-8,y:rect.y-8,width:rect.width+16,height:rect.height+16},focused:element.contains(document.activeElement),composing};};
|
||||
const lease=scope.index.register(instance,value()),measure=()=>lease.update(value()),start=()=>{composing=true;measure();},end=()=>{composing=false;measure();};refresh.current=measure;
|
||||
const blur=()=>queueMicrotask(measure),observer=new ResizeObserver(measure);observer.observe(element);element.addEventListener('focusin',measure);element.addEventListener('focusout',blur);element.addEventListener('compositionstart',start);element.addEventListener('compositionend',end);let frame=requestAnimationFrame(measure);
|
||||
cleanup.current=()=>{if(element.dataset.canvasOverlayOwner===marker){delete element.dataset.canvasOverlayView;delete element.dataset.canvasOverlayOwner;delete element.dataset.canvasOverlayTarget;delete element.dataset.canvasOverlayPlacement;}cancelAnimationFrame(frame);observer.disconnect();element.removeEventListener('focusin',measure);element.removeEventListener('focusout',blur);element.removeEventListener('compositionstart',start);element.removeEventListener('compositionend',end);lease.release();};
|
||||
},[scope,instance,targetId,incarnation,placement]);
|
||||
useLayoutEffect(()=>{refresh.current();});useLayoutEffect(()=>()=>cleanup.current(),[]);return ref;
|
||||
}
|
||||
@@ -9,11 +9,13 @@ import { NodeEditing, TransformableNode, ResizeToggle } from "./transformable-no
|
||||
import { FrameFeedback } from "./frame-feedback";
|
||||
import { FrameLabelMenu } from "./frame-label-menu";
|
||||
import { ContentHandles } from "./content-handles";
|
||||
import {useCanvasOverlay} from "./canvas-overlays";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
export function FrameNode(props: NodeProps<CanvasNode> & { directAnchors?: boolean }) {
|
||||
const { directAnchors = false, ...nodeProps } = props;
|
||||
const { id, data, selected } = nodeProps;
|
||||
const overlay=useCanvasOverlay(id,"frame-settings");
|
||||
const editing = useContext(NodeEditing);
|
||||
const feedback = useContext(FrameFeedback);
|
||||
const incoming = feedback.drops.filter((drop) => drop.to === id && drop.from !== id).length;
|
||||
@@ -31,7 +33,7 @@ export function FrameNode(props: NodeProps<CanvasNode> & { directAnchors?: boole
|
||||
}
|
||||
return <>
|
||||
<NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={45}>
|
||||
<AnimatePresence>{selected && <motion.div className={`${styles.styleToolbar} nodrag nopan nowheel`} role="toolbar" aria-label="分区设置"
|
||||
<AnimatePresence>{selected && <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(); }}
|
||||
|
||||
@@ -12,6 +12,8 @@ import { equal } from "@/lib/canvas-core/values";
|
||||
import {createGeometryProjectionCache} from "@/lib/canvas-bindings/geometry-cache";
|
||||
import {createPaintIndex} from "@/lib/canvas-bindings/paint-index";
|
||||
import {projectedConnectionPaintOverflow} from "@/lib/canvas-bindings/connection-paint";
|
||||
import {createOverlayIndex} from "@/lib/canvas-bindings/overlays";
|
||||
import {CanvasOverlays} from "./canvas-overlays";
|
||||
import {createProjectedPaintIndex} from "@/lib/canvas-bindings/projected-paint";
|
||||
import {createSessionFrameSource} from "@/lib/canvas-bindings/session-frames";
|
||||
import {routeCanvasKey} from "@/lib/canvas-bindings/input-router";
|
||||
@@ -76,6 +78,11 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
||||
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,renderers,catalog]);
|
||||
const surface=useRef<HTMLDivElement>(null),overlays=useMemo(()=>createOverlayIndex(viewId),[engine,viewId]);
|
||||
const overlayScope=useMemo(()=>({index:overlays,engine}),[overlays,engine]);
|
||||
const overlaySnapshot=useSyncExternalStore(overlays.subscribe,overlays.getSnapshot,overlays.getSnapshot);
|
||||
const surfaceBounds=surface.current?.getBoundingClientRect();
|
||||
const overlayVisible=!!overlaySnapshot.length&&(!surfaceBounds||overlays.visible(surfaceBounds,target=>root.document.elements.get(target.id)?.incarnation===target.incarnation).length>0);
|
||||
const paintCache=useMemo(()=>createProjectedPaintIndex(),[engine,view.canvasId]);
|
||||
const paint=useMemo(()=>paintCache.sync(projection,engine.editor.registry),[paintCache,projection,engine]);
|
||||
const viewport=useViewport(),surfaceWidth=useStore(state=>state.width),surfaceHeight=useStore(state=>state.height);
|
||||
@@ -168,7 +175,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
||||
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 = !connectionPaintVisible && !paintVisible && !editorFocused && modes.size === 0 && sessions.gesture?.viewId !== viewId && sessions.connectionGesture?.viewId !== viewId;
|
||||
const cullVisible = !overlayVisible && !connectionPaintVisible && !paintVisible && !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 selectedResource=selected?.structure==='node'&&engine.editor.registry.resolve(selected)==='known'&&engine.editor.registry.supports(selected,'geometry.resize')&&typeof selected.data.assetId==='string'&&engine.editor.registry.components.get(selected.componentId)?.resourceFields?.includes('assetId')?root.runtime.resources.get(selected.data.assetId):undefined;
|
||||
const canFitImage=!!selectedResource&&(selectedResource.image?.version===1&&selectedResource.image.status==='ready'||!!selectedResource.stored)&&!sessions.gesture;
|
||||
@@ -215,7 +222,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
||||
}),[engine,viewId,report]);
|
||||
const rendererCommands=useMemo<CanvasRendererCommands>(()=>({dispatch(command,options){const result=engine.editor.dispatch(command,{label:options?.label,groupId:options?.groupId});report(result);return result;},transaction(callback,options){const result=engine.editor.transaction(callback,{label:options?.label,groupId:options?.groupId});report(result);return result;}}),[engine,report]);
|
||||
if (!liveView) return <p role="status">画布已关闭</p>;
|
||||
return <div className={`${styles.editor} ${view.tool === 'pan' ? styles.handMode : ''}`} data-headless-canvas data-model-loading={loading?"true":undefined} tabIndex={-1}
|
||||
return <div ref={surface} className={`${styles.editor} ${view.tool === 'pan' ? styles.handMode : ''}`} data-headless-canvas data-model-loading={loading?"true":undefined} 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 }); }}
|
||||
@@ -231,7 +238,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
||||
});
|
||||
if(claim==='gesture'||claim==='tool'||claim==='shortcut'){event.preventDefault();event.stopPropagation();}
|
||||
}}>
|
||||
<ImagePreviews.Provider value={imagePreviews}>
|
||||
<CanvasOverlays.Provider value={overlayScope}><ImagePreviews.Provider value={imagePreviews}>
|
||||
<CanvasRendererCommandsContext.Provider value={rendererCommands}>
|
||||
<NodeEditing.Provider value={nodeEditing}>
|
||||
<EdgeEditing.Provider value={edgeEditing}>
|
||||
@@ -259,7 +266,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
||||
onConnect={connect} onPaneClick={selectAtPane}>
|
||||
<Background variant={BackgroundVariant.Dots} gap={24} size={1} color="#ded7ce" />
|
||||
</ReactFlow>
|
||||
</EdgeEditing.Provider></NodeEditing.Provider></CanvasRendererCommandsContext.Provider></ImagePreviews.Provider>
|
||||
</EdgeEditing.Provider></NodeEditing.Provider></CanvasRendererCommandsContext.Provider></ImagePreviews.Provider></CanvasOverlays.Provider>
|
||||
<div className={styles.topRight}><div className={`${toolbar.surface} ${styles.toolbar}`} role="toolbar" aria-label="图谱视图控制">
|
||||
<IconButton icon={Undo2} label="撤销" disabled={!root.history.undo.length} onClick={() => report(engine.editor.history.undo())} /><IconButton icon={Redo2} label="重做" disabled={!root.history.redo.length} onClick={() => report(engine.editor.history.redo())} />
|
||||
<IconButton icon={Copy} label={copying || preparingCopy ? "正在准备复制" : "深复制选中内容"} disabled={copying || preparingCopy || !copyAvailability.enabled} onClick={() => {
|
||||
|
||||
@@ -7,16 +7,18 @@ import { AlignCenter, AlignLeft, AlignRight, Bold, Italic, RotateCw, Underline }
|
||||
import type { CanvasNode } from "@/lib/canvas";
|
||||
import type { TextStyle } from "@/lib/canvas-elements";
|
||||
import { NodeEditing, ResizeToggle } from "./transformable-node";
|
||||
import {useCanvasOverlay} from "./canvas-overlays";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
export function NodeStyleToolbar({ id, data, selected }: NodeProps<CanvasNode>) {
|
||||
const overlay=useCanvasOverlay(id,"node-style");
|
||||
const reduce = useReducedMotion();
|
||||
const editing = useContext(NodeEditing);
|
||||
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 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="元素样式"
|
||||
<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="元素样式"
|
||||
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => { if (event.key !== "Escape") event.stopPropagation(); }}>
|
||||
{!(["image", "plain-image"].includes(data.kind)) && <label title="文字或图标颜色" className={styles.colorPicker}><span style={{ background: style.color || "#37352f" }} /><input aria-label="文字或图标颜色" type="color" value={style.color || "#37352f"} onChange={(event) => patch({ color: event.target.value })} /></label>}
|
||||
|
||||
Reference in New Issue
Block a user