diff --git a/headless/README.md b/headless/README.md index 33ad132..e7a36a2 100644 --- a/headless/README.md +++ b/headless/README.md @@ -394,3 +394,17 @@ application registers frame settings and node style toolbars through an optional DOM context, including portalled content; legacy and pre-engine renderers use no registry. Other overlays need explicit registration. Current retention still suspends culling for the view; it does not selectively mount each overlay target. + +Pointer transport helpers (`ownsPointerCapture`, `acquirePointerCapture`, +`releasePointerCapture`) keep DOM capture separate from editor gesture ownership. +Move/up/cancel must match the active pointer ID. Lost capture also matches its +capture target and cannot cancel a target that already owns a newer platform +capture. Capture acquisition failure cancels the acquired edit lease; detached +release is idempotent. Node transforms and edge controls now share these checks +and cancel/release on blur or unmount. Native React Flow drag handling remains an +adapter path; these helpers do not replace the full tool/input controller system. + +While a control owns an active capture, foreign pointer events reaching that +control are consumed without changing the lease; they do not fall through into +pane navigation/connection handlers. Outside an active control, unclaimed input +continues normally. Node drag cancellation only addresses its own pointer/token. diff --git a/src/components/canvas/canvas-edge.tsx b/src/components/canvas/canvas-edge.tsx index 73309a7..4640fe4 100644 --- a/src/components/canvas/canvas-edge.tsx +++ b/src/components/canvas/canvas-edge.tsx @@ -12,6 +12,7 @@ import type { CanvasEdgeModel, EdgeMarker } from "@/lib/canvas-v2"; import type { EditingLease } from "./editing-lease"; import { EdgeEditing } from "./edge-editing"; import { EdgeToolbarControls } from "./edge-toolbar"; +import {ownsPointerCapture,acquirePointerCapture,releasePointerCapture} from "@/lib/canvas-bindings/pointer-capture"; import styles from "./canvas.module.css"; function connector(node:InternalNode,handle:string|null|undefined,fallback:ConnectorSide):Connector{return compatConnector({...node,position:node.internals.positionAbsolute},handle,fallback);} @@ -35,11 +36,8 @@ export function CanvasEdge(props: EdgeProps) { const [selectedKnot, setSelectedKnot] = useState(null); const gesture = useRef<{ capture: Element; controller: AbortController; pointerId: number; lease?: EditingLease['path']>; id: string; part: "position" | "in" | "out" | "startControl" | "endControl" } | null>(null); const pointerHandlers = useRef<{ move(event: PointerEvent): void; stop(event: PointerEvent): void; cancel(event: PointerEvent): void } | null>(null); - useEffect(() => () => { - const current = gesture.current; gesture.current = null; - current?.controller.abort(); current?.lease?.cancel(); - if (current?.capture.hasPointerCapture(current.pointerId)) current.capture.releasePointerCapture(current.pointerId); - }, []); + const interrupt=useRef(()=>{});interrupt.current=()=>cancel(); + useEffect(()=>{const blur=()=>interrupt.current();window.addEventListener('blur',blur);return()=>{window.removeEventListener('blur',blur);interrupt.current();};},[]); if (!source || !target) return null; const from = (source.data as ProjectionNode['data']).__headlessRecord ? nativeConnector(props.sourceX,props.sourceY,props.sourcePosition) : connector(source, props.sourceHandleId, "right"), to = (target.data as ProjectionNode['data']).__headlessRecord ? nativeConnector(props.targetX,props.targetY,props.targetPosition) : connector(target, props.targetHandleId, "left"); const data = props.data || {}, appearance = data.appearance || {}, animation = data.animation || {}; @@ -55,7 +53,7 @@ export function CanvasEdge(props: EdgeProps) { editing.patch(props.id, { ...data, path: next.path }); setSelectedKnot(next.knot.id); } function start(event: PointerEvent, id: string, part: NonNullable["part"]) { - event.preventDefault(); event.stopPropagation(); + if(event.button!==0||event.defaultPrevented)return;if(gesture.current){if(gesture.current.pointerId!==event.pointerId)cancel();return;}if(event.isPrimary===false)return; // The SVG control can be replaced by a projection update. The view surface survives it. const capture = event.currentTarget.closest('[data-headless-canvas]') ?? event.currentTarget.closest('.react-flow') ?? event.currentTarget; const lease = editing.begin(props.id); if (lease === false) return; @@ -65,12 +63,13 @@ export function CanvasEdge(props: EdgeProps) { capture.addEventListener('pointermove', input => { if (owned(input)) pointerHandlers.current?.move(input as unknown as PointerEvent); }, { signal: controller.signal }); capture.addEventListener('pointerup', input => { if (owned(input)) pointerHandlers.current?.stop(input as unknown as PointerEvent); }, { signal: controller.signal }); for (const name of ['pointercancel', 'lostpointercapture']) capture.addEventListener(name, input => { if (owned(input) && gesture.current) pointerHandlers.current?.cancel(input as unknown as PointerEvent); }, { signal: controller.signal }); - capture.setPointerCapture(event.pointerId); + if(!acquirePointerCapture(capture,event.pointerId)){cancel();return;}event.preventDefault();event.stopPropagation(); } function move(event: PointerEvent) { const current = gesture.current; - if (!current) return; - event.stopPropagation(); + if(!current)return;event.stopPropagation(); + if (!ownsPointerCapture(current,event)) return; + if(!current)return; const point = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }); const next = structuredClone(data.path || { type: "bezier-chain" as const, knots: [] }); if (current.part === "startControl" || current.part === "endControl") next[current.part] = minus(point, current.part === "startControl" ? from.point : to.point); @@ -92,17 +91,19 @@ export function CanvasEdge(props: EdgeProps) { if (current.lease) current.lease.preview(next); else editing.patch(props.id, { ...data, path: next }, true); } function stop(event: PointerEvent) { - if (!gesture.current) return; - event.stopPropagation(); const current = gesture.current; move(event); gesture.current = null; current.controller.abort(); + if(!gesture.current)return;event.stopPropagation(); + if (!ownsPointerCapture(gesture.current,event)) return; const current = gesture.current!; move(event); gesture.current = null; current.controller.abort(); if (current.lease) current.lease.commit(); else editing.finish?.(); - if (current.capture.hasPointerCapture(current.pointerId)) current.capture.releasePointerCapture(current.pointerId); + releasePointerCapture(current.capture,current.pointerId); } - function cancel(_event: PointerEvent) { + function cancel(event?:{pointerId:number;type?:string;target?:unknown;stopPropagation?():void}) { + if(event&&gesture.current)event.stopPropagation?.(); + if(event&&!ownsPointerCapture(gesture.current,event))return; const current = gesture.current; gesture.current = null; if (!current) return; current.controller.abort(); if (current.lease) current.lease.cancel(); else editing.cancel?.(); - if (current.capture.hasPointerCapture(current.pointerId)) current.capture.releasePointerCapture(current.pointerId); + releasePointerCapture(current.capture,current.pointerId); } pointerHandlers.current = { move, stop, cancel }; const control = (point: { x: number; y: number }, id: string, part: NonNullable["part"], label: string) => ;failures:number}>({engine:null,viewId:null,sources:new Map(),failures:0}); const resourceView=useRef|null>(null); - const pointerStart = useRef<{ x: number; y: number } | null>(null), drag = useRef<{ token: number; start: { x: number; y: number }; positions: Map } | null>(null); + const pointerStart = useRef<{ x: number; y: number;pointerId?:number } | null>(null), drag = useRef<{ token: number;pointerId?:number; start: { x: number; y: number }; positions: Map } | null>(null); const projectionCache = useRef | null>(null); projectionCache.current ??= createProjectionCache(); const geometryCache=useRef|null>(null);geometryCache.current??=createGeometryProjectionCache(); @@ -225,10 +225,10 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi return
{ 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 }); }} - onPointerCancelCapture={() => { const state = engine.sessions.getSnapshot(); if (state.gesture?.viewId === viewId || state.connectionGesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); drag.current = null; }} + onMouseDownCapture={event => { if (event.button === 0 && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = {...flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }),pointerId:pointerStart.current?.pointerId}; }} + 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 }),pointerId:pointerStart.current?.pointerId}; }} + onPointerDownCapture={event => { if (event.button === 0 && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = {...flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }),pointerId:event.pointerId}; }} + onPointerCancelCapture={event => { const currentDrag=drag.current;if(!currentDrag||currentDrag.pointerId!==undefined&¤tDrag.pointerId!==event.pointerId)return;const state=engine.sessions.getSnapshot();if(state.gesture?.viewId===viewId&&state.gesture.token===currentDrag.token)engine.sessions.cancel('pointer_cancel');drag.current=null; }} onKeyDown={event => { const element=event.target instanceof Element?event.target:null; const claim=routeCanvasKey({key:event.key,composing:event.nativeEvent.isComposing,textEditing:!!element?.closest('input,textarea,[contenteditable]:not([contenteditable="false"]),[role="textbox"]'),overlay:!!element?.closest('[role="dialog"],[role="menu"]'),defaultPrevented:event.defaultPrevented,meta:event.metaKey,ctrl:event.ctrlKey,alt:event.altKey,shift:event.shiftKey},{ @@ -250,7 +250,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi 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, projectionCache.current!.getNode(id)!.position])) }; + drag.current = { token: 0,pointerId:pointerStart.current?.pointerId, 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; }} diff --git a/src/components/canvas/transformable-node.tsx b/src/components/canvas/transformable-node.tsx index 80c35a2..aea696c 100644 --- a/src/components/canvas/transformable-node.tsx +++ b/src/components/canvas/transformable-node.tsx @@ -10,6 +10,7 @@ import type { EditingLease } from "./editing-lease"; import { NodeStyleToolbar } from "./node-style-toolbar"; import { MembershipBadge } from "./frame-feedback"; import { ContentHandles } from "./content-handles"; +import {ownsPointerCapture,acquirePointerCapture,releasePointerCapture} from "@/lib/canvas-bindings/pointer-capture"; import styles from "./canvas.module.css"; const defaultEditing = { @@ -27,7 +28,7 @@ const defaultEditing = { }; type GestureHooks = 'beginTransform' | 'finishTransform' | 'cancelTransform' | 'endEditing'; export const NodeEditing = createContext & Partial>>(defaultEditing); -type Gesture = { lease?: EditingLease; node: CanvasNode; start: XYPosition; direction: ResizeDirection | "rotate"; target: HTMLElement; pointerId: number }; +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; @@ -37,6 +38,8 @@ export function TransformableNode(props: NodeProps & { children: Rea const { zoom } = useViewport(); const frame = useRef(null); const gesture = useRef(null); + const interrupt=useRef(()=>{});interrupt.current=()=>cancelGesture(); + useEffect(()=>{const blur=()=>interrupt.current();window.addEventListener('blur',blur);return()=>{window.removeEventListener('blur',blur);interrupt.current();};},[]); const rotation = data.rotation || 0; const plain = data.kind === "plain-text" || data.kind === "icon" || data.kind === "plain-image"; const capabilities = elementCapabilities(data.kind); @@ -55,37 +58,42 @@ export function TransformableNode(props: NodeProps & { children: Rea }, [rotating, resizing, id, editing]); function start(event: PointerEvent, direction: Gesture["direction"]) { - if (event.button !== 0) return; - event.preventDefault(); event.stopPropagation(); + if (event.button !== 0||event.defaultPrevented) return; + if(direction==='rotate'&&event.target instanceof Element&&event.target.closest('input,textarea,[contenteditable]:not([contenteditable="false"]),[role="textbox"]'))return; + if(gesture.current){if(gesture.current.pointerId!==event.pointerId)cancelGesture();return;} + if(event.isPrimary===false)return; const node = flow.getNode(id); if (!node) return; const lease = editing.beginTransform?.(id); if (lease === false) return; if (!editing.beginTransform) editing.begin(id); if (!node.selected) editing.select(id); - gesture.current = { lease: lease && typeof lease === 'object' ? lease : undefined, node, direction, target: event.currentTarget, pointerId: event.pointerId, start: flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }) }; - event.currentTarget.setPointerCapture(event.pointerId); + gesture.current = { lease: lease && typeof lease === 'object' ? lease : undefined, node, direction, capture: event.currentTarget, pointerId: event.pointerId, start: flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }) }; + if(!acquirePointerCapture(event.currentTarget,event.pointerId)){cancelGesture();return;} + event.preventDefault();event.stopPropagation(); } function move(event: PointerEvent) { - if (!gesture.current) return; - event.stopPropagation(); + if(!gesture.current)return;event.stopPropagation(); + if (!ownsPointerCapture(gesture.current,event)) return; const current = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }); - const { node, start, direction } = gesture.current; + const { node, start, direction } = gesture.current!; const change = direction === "rotate" ? { rotation: rotationFromPointer(node, start, current, event.shiftKey) } : resizeFromPointer(node, direction, { x: current.x - start.x, y: current.y - start.y }, resizing ? event.shiftKey : undefined); - if (gesture.current.lease) gesture.current.lease.preview(change); else editing.transform(id, change); + if (gesture.current!.lease) gesture.current!.lease!.preview(change); else editing.transform(id, change); } - function cancelGesture() { + function cancelGesture(event?:{pointerId:number;type?:string;target?:unknown;stopPropagation?():void}) { + if(event&&gesture.current)event.stopPropagation?.(); + if(event&&!ownsPointerCapture(gesture.current,event))return; const current = gesture.current; if (!current) return; gesture.current = null; - if (current.lease) current.lease.cancel(); else if (editing.cancelTransform) editing.cancelTransform(); else editing.transform(id, current.direction === "rotate" ? { rotation: current.node.data.rotation || 0 } : { position: current.node.position, width: current.node.width, height: current.node.height }); - if (current.target.hasPointerCapture(current.pointerId)) current.target.releasePointerCapture(current.pointerId); + if (current.lease) current.lease.cancel(); else if (editing.cancelTransform) editing.cancelTransform(); else editing.transform(current.node.id, current.direction === "rotate" ? { rotation: current.node.data.rotation || 0 } : { position: current.node.position, width: current.node.width, height: current.node.height }); + releasePointerCapture(current.capture,current.pointerId); } function finish(event: PointerEvent) { - if (!gesture.current) return; - event.stopPropagation(); const current = gesture.current; move(event); gesture.current = null; + if(!gesture.current)return;event.stopPropagation(); + if (!ownsPointerCapture(gesture.current,event)) return; const current = gesture.current!; move(event); gesture.current = null; if (current.lease) current.lease.commit(); else editing.finishTransform?.(); - if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); + releasePointerCapture(current.capture,current.pointerId); } return <> {!props.hideStyleToolbar && } diff --git a/src/lib/canvas-bindings/index.ts b/src/lib/canvas-bindings/index.ts index 4c5b60e..061b8f6 100644 --- a/src/lib/canvas-bindings/index.ts +++ b/src/lib/canvas-bindings/index.ts @@ -21,3 +21,5 @@ export {projectedPaintOverflow,createProjectedPaintIndex} from './projected-pain export {projectedConnectionPaintOverflow} from './connection-paint.ts'; export {createOverlayIndex} from './overlays.ts'; export type {OverlayEntry,OverlayIndex} from './overlays.ts'; +export {ownsPointerCapture,acquirePointerCapture,releasePointerCapture} from './pointer-capture.ts'; +export type {PointerCapturePort} from './pointer-capture.ts'; diff --git a/src/lib/canvas-bindings/pointer-capture.ts b/src/lib/canvas-bindings/pointer-capture.ts new file mode 100644 index 0000000..95c172e --- /dev/null +++ b/src/lib/canvas-bindings/pointer-capture.ts @@ -0,0 +1,5 @@ +/** Transport ownership only: document geometry and gesture tokens stay in the editor. */ +export type PointerCapturePort={setPointerCapture(id:number):void;hasPointerCapture(id:number):boolean;releasePointerCapture(id:number):void}; +export function ownsPointerCapture(owner:{pointerId:number;capture:unknown}|null|undefined,input:{pointerId:number;type?:string;target?:unknown}){if(!owner||owner.pointerId!==input.pointerId)return false;if(input.type!=='lostpointercapture')return true;if(input.target!==owner.capture)return false;const capture=owner.capture as Partial|undefined;try{if(capture?.hasPointerCapture?.(owner.pointerId))return false;}catch{/* A detached matching target may still need cancellation. */}return true;} +export function acquirePointerCapture(target:PointerCapturePort,id:number){try{target.setPointerCapture(id);return true;}catch{return false;}} +export function releasePointerCapture(target:PointerCapturePort,id:number){try{if(target.hasPointerCapture(id))target.releasePointerCapture(id);}catch{/* Detached DOM or an already released platform capture has no editor authority. */}} diff --git a/tests/headless/pointer-capture.test.mjs b/tests/headless/pointer-capture.test.mjs new file mode 100644 index 0000000..400f8b3 --- /dev/null +++ b/tests/headless/pointer-capture.test.mjs @@ -0,0 +1,4 @@ +import {test} from 'node:test';import assert from 'node:assert/strict'; +import {ownsPointerCapture,acquirePointerCapture,releasePointerCapture} from '../../src/lib/canvas-bindings/pointer-capture.ts'; +test('pointer move/up/cancel only match their owner and old lost-capture cannot cancel a replacement target',()=>{const capture={},other={},owner={pointerId:7,capture};for(const type of ['pointermove','pointerup','pointercancel']){assert.equal(ownsPointerCapture(owner,{pointerId:8,type}),false);assert.equal(ownsPointerCapture(owner,{pointerId:7,type}),true);}assert.equal(ownsPointerCapture(owner,{pointerId:7,type:'lostpointercapture',target:other}),false);assert.equal(ownsPointerCapture(owner,{pointerId:7,type:'lostpointercapture',target:capture}),true);assert.equal(ownsPointerCapture(null,{pointerId:7}),false);const recaptured={hasPointerCapture:()=>true};assert.equal(ownsPointerCapture({pointerId:7,capture:recaptured},{pointerId:7,type:'lostpointercapture',target:recaptured}),false);}); +test('failed capture and detached release stay transport failures, while a held capture releases once',()=>{let held=false,releases=0;const target={setPointerCapture(){held=true;},hasPointerCapture(){return held;},releasePointerCapture(){held=false;releases++;}};assert.equal(acquirePointerCapture(target,1),true);releasePointerCapture(target,1);releasePointerCapture(target,1);assert.equal(releases,1);assert.equal(acquirePointerCapture({...target,setPointerCapture(){throw Error('detached');}},2),false);assert.doesNotThrow(()=>releasePointerCapture({...target,hasPointerCapture(){throw Error('detached');}},2));}); diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs index c376c8d..45ec8f4 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -192,6 +192,8 @@ try { const corner = await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b] [aria-label="拖动右下角调整大小"]').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`); await call('Input.dispatchMouseEvent', { type: 'mousePressed', x: corner.x, y: corner.y, button: 'left', buttons: 1, clickCount: 1 }); await call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: corner.x + 20, y: corner.y + 10, button: 'left', buttons: 1 }); + await evaluate(`(()=>{const handle=document.querySelector('.react-flow__node[data-id=b] [aria-label="拖动右下角调整大小"]');for(const type of ['pointermove','pointerup','pointercancel','lostpointercapture'])handle.dispatchEvent(new PointerEvent(type,{pointerId:999,clientX:9999,clientY:9999,bubbles:true,cancelable:true}));})()`); + assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,beforeCancel,'foreign pointer committed the resize'); await call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: corner.x + 40, y: corner.y + 20, button: 'left', buttons: 0 }); for (let i=0;i<100&&documents.get(root).elements.get('b').geometry.width!==300;i++)await pause(20); assert.equal(documents.get(root).elements.get('b').geometry.width,300,JSON.stringify({geometry:documents.get(root).elements.get('b').geometry,inputs:await evaluate('window.inputEvents'),text:await evaluate('document.body.innerText')}));assert.equal(documents.get(root).elements.get('b').geometry.height,220);