diff --git a/headless/README.md b/headless/README.md index d61261a..059aebf 100644 --- a/headless/README.md +++ b/headless/README.md @@ -453,3 +453,13 @@ A real three-node fixture verifies that editing one node through ACK leaves an unrelated node and nonincident edge's render function counts unchanged. Connected edges may legitimately remeasure when a custom renderer's data changes; this does not assume arbitrary custom Handle layouts stay fixed. + +Node projections now carry readonly `__headlessTarget` display metadata with +id/incarnation/ownerCanvasId/display canvasId, written from model identity after +user data is projected. It is not a persisted element field. The application binds +legacy editing actions to that captured target through `useScopedNodeEditing`; +old incarnation/owner/placement and detached/closed-view callbacks do not write to +current records. Frame titles, node titles, text/style/branch/transform controls +use the scoped bridge. Legacy non-headless contexts retain their old interface. +Text grouping now records incarnation and drops obsolete groups. This is separate +from the public explicit-Target action API and gesture lease tokens. diff --git a/src/components/canvas/canvas-text-editor.tsx b/src/components/canvas/canvas-text-editor.tsx index 1b9247e..33b8656 100644 --- a/src/components/canvas/canvas-text-editor.tsx +++ b/src/components/canvas/canvas-text-editor.tsx @@ -1,14 +1,14 @@ "use client"; -import { useContext, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import type { CanvasNodeData } from "@/lib/canvas-elements"; -import { NodeEditing } from "./transformable-node"; +import {useScopedNodeEditing} from "./node-editing"; import { textAppearance } from "./text-appearance"; import styles from "./canvas.module.css"; /** Single clicks select; native double-click timing controls entry into editing. */ export function CanvasTextEditor({ id, data, selected, plain = false }: { id: string; data: CanvasNodeData; selected: boolean; plain?: boolean }) { - const actions = useContext(NodeEditing); + const actions = useScopedNodeEditing(id,data.__headlessTarget); const [editing, setEditing] = useState(false); const textarea = useRef(null); const press = useRef({ x: 0, y: 0, moved: false }); diff --git a/src/components/canvas/frame-node.tsx b/src/components/canvas/frame-node.tsx index b550d51..33a6dc7 100644 --- a/src/components/canvas/frame-node.tsx +++ b/src/components/canvas/frame-node.tsx @@ -5,18 +5,19 @@ import { FoldVertical, UnfoldVertical, ExternalLink } from "lucide-react"; import { Handle, NodeToolbar, Position, useViewport, type NodeProps } from "@xyflow/react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import type { CanvasNode } from "@/lib/canvas"; -import { NodeEditing, TransformableNode, ResizeToggle } from "./transformable-node"; +import { TransformableNode, ResizeToggle } from "./transformable-node"; import { FrameFeedback } from "./frame-feedback"; import { FrameLabelMenu } from "./frame-label-menu"; import { ContentHandles } from "./content-handles"; import {useCanvasOverlay} from "./canvas-overlays"; +import {useScopedNodeEditing} from "./node-editing"; import styles from "./canvas.module.css"; export function FrameNode(props: NodeProps & { directAnchors?: boolean }) { const { directAnchors = false, ...nodeProps } = props; const { id, data, selected } = nodeProps; const overlay=useCanvasOverlay(id,"frame-settings"); - const editing = useContext(NodeEditing); + const editing = useScopedNodeEditing(id,data.__headlessTarget); const feedback = useContext(FrameFeedback); const incoming = feedback.drops.filter((drop) => drop.to === id && drop.from !== id).length; const outgoing = feedback.drops.filter((drop) => drop.from === id && drop.to !== id).length; @@ -39,7 +40,7 @@ export function FrameNode(props: NodeProps & { directAnchors?: boole 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(); } }} /> editing.frame(id, { labelMode })} /> - + {data.subgraph && <>} - {data.kind !== "icon" && } + {data.kind !== "icon" && } } ; diff --git a/src/components/canvas/node-title.tsx b/src/components/canvas/node-title.tsx index c7daa56..adbc93b 100644 --- a/src/components/canvas/node-title.tsx +++ b/src/components/canvas/node-title.tsx @@ -1,9 +1,9 @@ "use client"; -import { useContext, useEffect, useRef, useState } from "react"; -import { NodeEditing } from "./transformable-node"; +import { useEffect, useRef, useState } from "react"; +import {useScopedNodeEditing} from "./node-editing"; import styles from "./canvas.module.css"; -export function NodeTitle({ id, title, selected }: { id: string; title: string; selected: boolean }) { - const actions = useContext(NodeEditing); +export function NodeTitle({ id, title, selected,renderedTarget }: { id: string; title: string; selected: boolean;renderedTarget?:import("@/lib/canvas-bindings/compat-types").RenderedTarget }) { + const actions = useScopedNodeEditing(id,renderedTarget); const [editing, setEditing] = useState(false), [value, setValue] = useState(title); const input = useRef(null), cancelled = useRef(false); useEffect(() => { if (!editing) setValue(title); }, [title, editing]); diff --git a/src/components/canvas/transformable-node.tsx b/src/components/canvas/transformable-node.tsx index aea696c..7925404 100644 --- a/src/components/canvas/transformable-node.tsx +++ b/src/components/canvas/transformable-node.tsx @@ -1,10 +1,10 @@ "use client"; -import { createContext, useContext, useEffect, useRef, type PointerEvent, type ReactNode } from "react"; +import { useEffect, useRef, type PointerEvent, type ReactNode } from "react"; import { useReactFlow, useUpdateNodeInternals, useViewport, type NodeProps, type XYPosition } from "@xyflow/react"; import { Grip, Maximize2, RotateCw } from "lucide-react"; -import { elementCapabilities, type TextStyle } from "@/lib/canvas-elements"; -import type { CanvasNode, NodeDirection } from "@/lib/canvas"; +import { elementCapabilities } from "@/lib/canvas-elements"; +import type { CanvasNode } from "@/lib/canvas"; import { resizeFromPointer, rotationFromPointer, type NodeTransform, type ResizeDirection } from "@/lib/canvas-geometry"; import type { EditingLease } from "./editing-lease"; import { NodeStyleToolbar } from "./node-style-toolbar"; @@ -13,26 +13,13 @@ import { ContentHandles } from "./content-handles"; import {ownsPointerCapture,acquirePointerCapture,releasePointerCapture} from "@/lib/canvas-bindings/pointer-capture"; import styles from "./canvas.module.css"; -const defaultEditing = { - beginTransform: undefined as undefined | ((id: string) => boolean | void | EditingLease), finishTransform: undefined as undefined | (() => void), cancelTransform: undefined as undefined | (() => void), - endEditing: undefined as undefined | ((id: string) => void), - workspace: "", schema: 1 as 1 | 2, - begin: (_id?: string) => {}, select: (_id: string) => {}, edit: (_id: string, _text: string) => {}, - branch: (_id: string, _direction: NodeDirection, _point: XYPosition) => {}, - transform: (_id: string, _transform: NodeTransform) => {}, - style: (_id: string, _style: TextStyle, _fontSize?: number) => {}, - rename: (_id: string, _title: string) => {}, - frame: (_id: string, _patch: { title?: string; labelMode?: import("@/lib/canvas-elements").FrameLabelMode; presentation?: import("@/lib/canvas-v2").FramePresentation }) => {}, - rotating: null as string | null, rotate: (_id: string | null) => {}, - resizing: null as string | null, resize: (_id: string | null) => {}, -}; -type GestureHooks = 'beginTransform' | 'finishTransform' | 'cancelTransform' | 'endEditing'; -export const NodeEditing = createContext & Partial>>(defaultEditing); +export {NodeEditing} from "./node-editing"; +import {useScopedNodeEditing} from "./node-editing"; type Gesture = { lease?: EditingLease; node: CanvasNode; start: XYPosition; direction: ResizeDirection | "rotate"; capture: HTMLElement; pointerId: number }; export function TransformableNode(props: NodeProps & { children: ReactNode; className?: string; hideStyleToolbar?: boolean }) { const { id, data, selected, width, height, children, className = "" } = props; - const editing = useContext(NodeEditing); + const editing = useScopedNodeEditing(id,data.__headlessTarget); const flow = useReactFlow(); const update = useUpdateNodeInternals(); const { zoom } = useViewport(); @@ -129,7 +116,7 @@ export function TransformableNode(props: NodeProps & { children: Rea ; } -export function ResizeToggle({ id }: { id: string }) { - const editing = useContext(NodeEditing); +export function ResizeToggle({ id,renderedTarget }: { id: string;renderedTarget?:import("@/lib/canvas-bindings/compat-types").RenderedTarget }) { + const editing = useScopedNodeEditing(id,renderedTarget); return ; } diff --git a/src/lib/canvas-bindings/compat-types.ts b/src/lib/canvas-bindings/compat-types.ts index b207bc5..7faf745 100644 --- a/src/lib/canvas-bindings/compat-types.ts +++ b/src/lib/canvas-bindings/compat-types.ts @@ -8,7 +8,9 @@ export type FramePresentation = { collapsed?: boolean; contentScale?: number; ex export type ElementKind = "text" | "image" | "script" | "plain-text" | "icon" | "frame" | "plain-image"; export type FrameLabelMode = "hidden" | "fixed" | "scaled"; export type TextStyle = { color?: string; background?: string; bold?: boolean; italic?: boolean; underline?: boolean; align?: "left" | "center" | "right"; fontFamily?: "sans" | "serif" | "mono"; lineHeight?: number }; +export type RenderedTarget={readonly id:string;readonly incarnation:number;readonly ownerCanvasId:string;readonly canvasId:string}; export type CanvasNodeData = { + __headlessTarget?:RenderedTarget; kind: ElementKind; title: string; text: string; diff --git a/src/lib/canvas-bindings/projection.ts b/src/lib/canvas-bindings/projection.ts index 82954ef..4a33744 100644 --- a/src/lib/canvas-bindings/projection.ts +++ b/src/lib/canvas-bindings/projection.ts @@ -37,7 +37,7 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist const child = queries?queries.relations.childCanvasByEntry.get(element.id):childByEntry.get(element.id), data = element.data; const projected: ProjectionNode = { id: element.id, type, position: projectedGeometry?{...projectedGeometry.position}:{ x: center!.x - width / 2, y: center!.y - height / 2 }, width, height, selected: selected.has(element.id), draggable: known && registry.supports(element, 'geometry.move'), zIndex: element.componentId === 'frame' ? depth - 100 : depth * 2, style: { width, height, ...(element.componentId === 'frame'&&!fallback&&renderer?.projection==='compat-v1' ? { pointerEvents: 'none' as const } : {}) }, - data: { ...(fallback?{}:data), kind: element.componentId as CanvasNodeData['kind'], title: typeof data.title === 'string' ? data.title : element.componentId, text: !fallback&&typeof data.text === 'string' ? data.text : '', __headlessFallback:fallback, __headlessRecord:!fallback&&renderer?.projection==='record-v1'?element:undefined,__headlessTransform:!fallback&&renderer?.projection==='record-v1'?transform:undefined, rotation: projectedGeometry?.rotation??(g.rotation + transform.rotation) % 360, viewScale: transform.scale*(!fallback?unitScale(data.presentation):1), ...(child ? { subgraph: { boardId: child } } : {}) } as CanvasNodeData, + data: { ...(fallback?{}:data), __headlessTarget:Object.freeze({id:element.id,incarnation:element.incarnation,ownerCanvasId:element.ownerCanvasId,canvasId:canvasId}), kind: element.componentId as CanvasNodeData['kind'], title: typeof data.title === 'string' ? data.title : element.componentId, text: !fallback&&typeof data.text === 'string' ? data.text : '', __headlessFallback:fallback, __headlessRecord:!fallback&&renderer?.projection==='record-v1'?element:undefined,__headlessTransform:!fallback&&renderer?.projection==='record-v1'?transform:undefined, rotation: projectedGeometry?.rotation??(g.rotation + transform.rotation) % 360, viewScale: transform.scale*(!fallback?unitScale(data.presentation):1), ...(child ? { subgraph: { boardId: child } } : {}) } as CanvasNodeData, }; nodes.push(projected); visible.add(element.id); if (child) { diff --git a/tests/headless/bindings.test.mjs b/tests/headless/bindings.test.mjs index 9d821a5..f257afd 100644 --- a/tests/headless/bindings.test.mjs +++ b/tests/headless/bindings.test.mjs @@ -121,3 +121,4 @@ test('known components without a matching renderer retain registered geometry ca const {defineComponent,movable}=await import('../../src/lib/canvas-core/index.ts'),custom=defineComponent({id:'plugin:known',dataVersion:1,structure:'node',validate:()=>true,features:[movable()]}),r=createRegistry([...builtinComponents(),custom]),source=createDocument('project',[{id:'root',parent:null}],[node('custom','root','plugin:known')]); const projected=projectCanvas(source,'root',r,new Set(),()=>undefined).nodes[0];assert.equal(projected.type,'unsupported');assert.equal(projected.draggable,true);assert.equal(projected.data.__headlessFallback.reason,'renderer-unavailable');const editor=createEditor({document:source,registry:r});assert.equal(editor.dispatch({type:'element.geometry',target:{id:'custom',incarnation:1},capability:'geometry.move',patch:{x:20}}).status,'applied'); }); +test('rendered targets carry captured incarnation/owner/placement and overwrite spoofed data without mutating the model',()=>{const projection=projectCanvas(document,'root',registry,new Set(),()=>undefined),inside=projection.nodes.find(node=>node.id==='inside');assert.deepEqual(inside.data.__headlessTarget,{id:'inside',incarnation:1,ownerCanvasId:'child',canvasId:'root'});assert.ok(Object.isFrozen(inside.data.__headlessTarget));const raw={...node('safe','root'),incarnation:2,data:{title:'safe',text:'',__headlessTarget:{id:'spoof',incarnation:99,ownerCanvasId:'elsewhere',canvasId:'elsewhere'}}},source=createDocument('p',[{id:'root',parent:null}],[raw]),before=JSON.stringify([...source.elements]),drawn=projectCanvas(source,'root',registry,new Set(),()=>undefined).nodes[0];assert.deepEqual(drawn.data.__headlessTarget,{id:'safe',incarnation:2,ownerCanvasId:'root',canvasId:'root'});assert.equal(JSON.stringify([...source.elements]),before);}); diff --git a/tests/headless/renderer-extension.tsx b/tests/headless/renderer-extension.tsx index 7338ebb..b706875 100644 --- a/tests/headless/renderer-extension.tsx +++ b/tests/headless/renderer-extension.tsx @@ -1,12 +1,13 @@ "use client"; +import {useScopedNodeEditing} from '@/components/canvas/node-editing'; import { Handle, Position, BaseEdge, getBezierPath, type NodeProps, type EdgeProps } from '@xyflow/react'; import { defineComponent, editable, movable } from '@/lib/canvas-core/index'; import type {ReactFlowProjectionNode as ProjectionNode,ReactFlowProjectionEdge as ProjectionEdge} from '@/lib/canvas-bindings/react-flow'; import type { CanvasRuntimeExtension } from '@/lib/canvas-bindings/react-flow'; -import { useElement, useCanvasRendererCommands } from '@/lib/canvas-bindings/react-flow'; +import { useCanvasBinding,useElement, useCanvasRendererCommands } from '@/lib/canvas-bindings/react-flow'; function rendered(id:string){const host=globalThis as typeof globalThis&{__fixtureRenders?:Record};const counts=host.__fixtureRenders??={};counts[id]=(counts[id]??0)+1;} function Meter({data}:NodeProps){ - const commands=useCanvasRendererCommands(),lookup=useElement(data.__headlessRecord!),record=lookup.status==='present'?lookup.element:data.__headlessRecord!;rendered(record.id); + const commands=useCanvasRendererCommands(),lookup=useElement(data.__headlessRecord!),record=lookup.status==='present'?lookup.element:data.__headlessRecord!;rendered(record.id);const legacy=useScopedNodeEditing(record.id,data.__headlessTarget),unbound=useScopedNodeEditing(record.id),binding=useCanvasBinding();const host=globalThis as typeof globalThis&{__fixtureEngine?:typeof binding.engine;__fixtureLegacy?:Record;__fixtureUnbound?:Record};host.__fixtureEngine=binding.engine;(host.__fixtureLegacy??={})[record.id]=legacy;(host.__fixtureUnbound??={})[record.id]=unbound; return
Metric {String(record.data.metric)} diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs index 120fe41..a62e72c 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -374,6 +374,15 @@ try { documents.set(extensionRoot,createDocument(extensionRoot,[{id:extensionRoot,parent:null}],[meter,meterTwo,meterThree,meterEdge,{...meterEdge,id:'unrelated-meter-edge',source:{elementId:'meter-two',portId:'custom-out'},target:{elementId:'meter-three',portId:'custom-in'}},{...meterEdge,id:'meter-builtin-edge',componentId:'connection',data:{},path:null}]));versions.set(extensionRoot,1);boards.push({id:extensionRoot,workspace_id:workspace,kind:'board',version:1,title:'Extension board'});await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${extensionRoot}`});await until(`document.querySelectorAll('[data-renderer-fixture="meter"]').length===3&&document.querySelectorAll('[data-renderer-fixture="metric-edge"]').length===2`,'host-installed custom node/edge renderers did not mount');await until(`document.querySelectorAll('.react-flow__edge').length===3`,'built-in edge did not connect custom renderer ports');assert.equal(await evaluate(`document.querySelectorAll('[aria-label="暂不支持编辑的元素"]').length`),0);await until(`[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'extension baseline did not settle');await pause(200);await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');const rendersBefore=await evaluate('({...window.__fixtureRenders})');await click('Increase meter-one');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==4;i++)await pause(20);assert.deepEqual(documents.get(extensionRoot).elements.get('meter-one').data,{metric:4,raw:{retained:true,edited:true}});assert.deepEqual(documents.get(extensionRoot).elements.get('meter-edge'),meterEdge);await until(`[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'extension edit/ACK did not settle');await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');const rendersAfter=await evaluate('({...window.__fixtureRenders})');assert.ok(rendersAfter['meter-one']>rendersBefore['meter-one'],'edited custom node did not render');assert.equal(rendersAfter['meter-two'],rendersBefore['meter-two'],'unrelated custom node rendered during edit/ACK');assert.equal(rendersAfter['unrelated-meter-edge'],rendersBefore['unrelated-meter-edge'],'unrelated custom edge rendered during edit/ACK');console.log('PASS actual registered renderer updates the edited node while unrelated node/edge render counts remain unchanged through ACK'); await click('撤销');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==3;i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.metric,3);assert.deepEqual(documents.get(extensionRoot).elements.get('meter-one').data.raw,{retained:true});await click('重做');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==4;i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.metric,4);await call('Page.reload');await until(`document.querySelector('[data-id="meter-one"] [data-renderer-fixture="meter"]')?.textContent.includes('Metric 4')&&!!document.querySelector('[data-renderer-fixture="metric-edge"]')`,'registered renderer edits did not recover'); console.log('PASS Host-installed custom model/node/edge definitions render without core type switches; command-backed edits preserve raw fields and recover through undo/redo/reload'); + assert.equal(await evaluate(`(()=>{const engine=window.__fixtureEngine,before=engine.getSnapshot();window.__fixtureUnbound['meter-one'].rename('meter-one','unbound callback');return engine.getSnapshot()===before;})()`),true,'headless legacy editing without a rendered target was allowed'); + const staleEdit=await evaluate(`(()=>{const engine=window.__fixtureEngine,old=window.__fixtureLegacy['meter-one'],target=engine.getSnapshot().document.elements.get('meter-one');window.__fixtureStaleLegacy=old;engine.editor.dispatch({type:'element.remove',target:{id:target.id,incarnation:target.incarnation}});engine.editor.history.undo();const restored=engine.getSnapshot();old.rename('meter-one','stale callback');return{unchanged:engine.getSnapshot()===restored,incarnation:restored.document.elements.get('meter-one').incarnation};})()`);assert.equal(staleEdit.incarnation,2);assert.equal(staleEdit.unchanged,true,'old rendered edit callback modified a restored incarnation'); + console.log('PASS old rendered edit callbacks cannot modify a restored incarnation'); + await until(`window.__fixtureLegacy['meter-one']!==window.__fixtureStaleLegacy&&window.__fixtureEngine.getSnapshot().document.elements.get('meter-one').incarnation===2`,'restored renderer did not bind a fresh edit target');await evaluate(`window.__fixtureLegacy['meter-one'].rename('meter-one','fresh callback')`);for(let i=0;i<200&&documents.get(extensionRoot).elements.get('meter-one')?.data.title!=='fresh callback';i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.title,'fresh callback'); + assert.equal(await evaluate(`(()=>{const engine=window.__fixtureEngine,old=window.__fixtureLegacy['meter-one'],other=window.__fixtureLegacy['meter-two'];old.resize('meter-one');other.resize('meter-two');old.resize(null);const view=[...engine.sessions.getSnapshot().views.values()][0],retained=view.modes.get('meter-two')==='resize';other.resize(null);return retained;})()`),true,'old target mode cleanup canceled another target'); + await evaluate(`(()=>{window.__detachedEngine=window.__fixtureEngine;window.__detachedEditing=window.__fixtureLegacy['meter-one'];[...document.querySelectorAll('a')].find(link=>link.pathname.endsWith('/${other}')).click();})()`);await until(`location.pathname.endsWith('${other}')&&!!document.querySelector('[data-headless-canvas]')`,'renderer view did not detach on navigation');assert.equal(await evaluate(`(()=>{const before=window.__detachedEngine.getSnapshot();window.__detachedEditing.rename('meter-one','detached callback');return window.__detachedEngine.getSnapshot()===before;})()`),true,'detached renderer wrote into its retained project'); + console.log('PASS restored renderers can edit with fresh bindings and detached views cannot write through old callbacks'); + + brokenStreamRoot=id(830);const brokenRoot=brokenStreamRoot; documents.set(brokenRoot,createDocument(brokenRoot,[{id:brokenRoot,parent:null}],[{...node('prefix-visible',brokenRoot,80),data:{title:'Preserved prefix',text:'prefix-kept'}},node('tail-node',brokenRoot,450)]));versions.set(brokenRoot,1);boards.push({id:brokenRoot,workspace_id:workspace,kind:'board',version:1,title:'Interrupted board'});