Fence pointer capture ownership across node and edge gestures
This commit is contained in:
@@ -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<CanvasNode>,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<CanvasEdgeModel>) {
|
||||
const [selectedKnot, setSelectedKnot] = useState<string | null>(null);
|
||||
const gesture = useRef<{ capture: Element; controller: AbortController; pointerId: number; lease?: EditingLease<NonNullable<CanvasEdgeModel['data']>['path']>; id: string; part: "position" | "in" | "out" | "startControl" | "endControl" } | null>(null);
|
||||
const pointerHandlers = useRef<{ move(event: PointerEvent<SVGCircleElement>): void; stop(event: PointerEvent<SVGCircleElement>): void; cancel(event: PointerEvent<SVGCircleElement>): 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<CanvasEdgeModel>) {
|
||||
editing.patch(props.id, { ...data, path: next.path }); setSelectedKnot(next.knot.id);
|
||||
}
|
||||
function start(event: PointerEvent<SVGCircleElement>, id: string, part: NonNullable<typeof gesture.current>["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<CanvasEdgeModel>) {
|
||||
capture.addEventListener('pointermove', input => { if (owned(input)) pointerHandlers.current?.move(input as unknown as PointerEvent<SVGCircleElement>); }, { signal: controller.signal });
|
||||
capture.addEventListener('pointerup', input => { if (owned(input)) pointerHandlers.current?.stop(input as unknown as PointerEvent<SVGCircleElement>); }, { 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<SVGCircleElement>); }, { signal: controller.signal });
|
||||
capture.setPointerCapture(event.pointerId);
|
||||
if(!acquirePointerCapture(capture,event.pointerId)){cancel();return;}event.preventDefault();event.stopPropagation();
|
||||
}
|
||||
function move(event: PointerEvent<SVGCircleElement>) {
|
||||
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<CanvasEdgeModel>) {
|
||||
if (current.lease) current.lease.preview(next); else editing.patch(props.id, { ...data, path: next }, true);
|
||||
}
|
||||
function stop(event: PointerEvent<SVGCircleElement>) {
|
||||
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<SVGCircleElement>) {
|
||||
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<typeof gesture.current>["part"], label: string) => <circle key={part + id} cx={point.x} cy={point.y} r={(part === "position" ? 5 : 3.5) / zoom} className={styles.curveControl} fill={part === "position" ? "#fffefb" : "#a47764"} stroke="#a47764" strokeWidth={1 / zoom} role="button" aria-label={label} tabIndex={0}
|
||||
|
||||
@@ -62,7 +62,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
||||
const [error, setError] = useState("");
|
||||
const [displayState, setDisplayState] = useState<{engine:ProjectEngine|null;viewId:Id|null;sources:Map<Id,ResourceViewSource>;failures:number}>({engine:null,viewId:null,sources:new Map(),failures:0});
|
||||
const resourceView=useRef<ReturnType<typeof createResourceView>|null>(null);
|
||||
const pointerStart = useRef<{ x: number; y: number } | null>(null), drag = useRef<{ token: number; start: { x: number; y: number }; positions: Map<Id, { x: number; y: number }> } | 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<Id, { x: number; y: number }> } | null>(null);
|
||||
const projectionCache = useRef<ReturnType<typeof createProjectionCache> | null>(null);
|
||||
projectionCache.current ??= createProjectionCache();
|
||||
const geometryCache=useRef<ReturnType<typeof createGeometryProjectionCache>|null>(null);geometryCache.current??=createGeometryProjectionCache();
|
||||
@@ -225,10 +225,10 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
||||
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 }); }}
|
||||
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;
|
||||
}}
|
||||
|
||||
@@ -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<Omit<typeof defaultEditing, GestureHooks> & Partial<Pick<typeof defaultEditing, GestureHooks>>>(defaultEditing);
|
||||
type Gesture = { lease?: EditingLease<NodeTransform>; node: CanvasNode; start: XYPosition; direction: ResizeDirection | "rotate"; target: HTMLElement; pointerId: number };
|
||||
type Gesture = { lease?: EditingLease<NodeTransform>; node: CanvasNode; start: XYPosition; direction: ResizeDirection | "rotate"; capture: HTMLElement; pointerId: number };
|
||||
|
||||
export function TransformableNode(props: NodeProps<CanvasNode> & { children: ReactNode; className?: string; hideStyleToolbar?: boolean }) {
|
||||
const { id, data, selected, width, height, children, className = "" } = props;
|
||||
@@ -37,6 +38,8 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
||||
const { zoom } = useViewport();
|
||||
const frame = useRef<HTMLDivElement>(null);
|
||||
const gesture = useRef<Gesture | null>(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<CanvasNode> & { children: Rea
|
||||
}, [rotating, resizing, id, editing]);
|
||||
|
||||
function start(event: PointerEvent<HTMLElement>, 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<HTMLElement>) {
|
||||
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<HTMLElement>) {
|
||||
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 && <NodeStyleToolbar {...props} />}
|
||||
|
||||
Reference in New Issue
Block a user