Fence pointer capture ownership across node and edge gestures

This commit is contained in:
2026-11-09 18:35:43 +08:00
parent 2f385433be
commit 0813b8df53
8 changed files with 71 additions and 35 deletions
+23 -15
View File
@@ -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} />}