"use client"; 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 } 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"; import { MembershipBadge } from "./frame-feedback"; import { ContentHandles } from "./content-handles"; import {ownsPointerCapture,acquirePointerCapture,releasePointerCapture} from "@/lib/canvas-bindings/pointer-capture"; import {canvasKeyContext,routeCanvasKey} from "@/lib/canvas-bindings/input-router"; import styles from "./canvas.module.css"; 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 = useScopedNodeEditing(id,data.__headlessTarget); const flow = useReactFlow(); const update = useUpdateNodeInternals(); 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); const resizing = capabilities.resizable && editing.resizing === id && selected; const rotating = capabilities.rotatable && editing.rotating === id && selected; useEffect(() => { update(id); }, [id, rotation, width, height, selected, update]); useEffect(() => { if (!rotating && !resizing) return; const outside = (event: globalThis.PointerEvent) => { if (!frame.current?.contains(event.target as Node) && !(event.target as Element).closest?.('[data-rotate-toggle="' + id + '"], [data-resize-toggle="' + id + '"]')) { editing.rotate(null); editing.resize(null); } }; const escape = (event: KeyboardEvent) => { if(event.key!=="Escape")return; const claim=routeCanvasKey({key:event.key,composing:event.isComposing,defaultPrevented:event.defaultPrevented,...canvasKeyContext(event)},{tool:()=>{cancelGesture();editing.rotate(null);editing.resize(null);return true;}}); if(claim==='tool'){event.preventDefault();event.stopPropagation();} }; window.addEventListener("pointerdown", outside, true); window.addEventListener("keydown", escape); return () => { window.removeEventListener("pointerdown", outside, true); window.removeEventListener("keydown", escape); }; }, [rotating, resizing, id, editing]); function start(event: PointerEvent, direction: Gesture["direction"]) { 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, 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 (!ownsPointerCapture(gesture.current,event)) return; const current = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }); 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); } 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(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(); if (!ownsPointerCapture(gesture.current,event)) return; const current = gesture.current!; move(event); gesture.current = null; if (current.lease) current.lease.commit(); else editing.finishTransform?.(); releasePointerCapture(current.capture,current.pointerId); } return <> {!props.hideStyleToolbar && }
start(event, "rotate") : undefined} onPointerMove={move} onPointerUp={finish} onPointerCancel={cancelGesture} onLostPointerCapture={cancelGesture} style={{ zoom: data.viewScale || 1, width: (width || (data.kind === "frame" ? 640 : data.kind === "plain-image" ? 320 : data.kind === "icon" ? 80 : plain ? 240 : 260)) / (data.viewScale || 1), height: ( height || (data.kind === "frame" ? 400 : data.kind === "plain-image" ? 240 : data.kind === "icon" ? 80 : plain ? 72 : 200)) / (data.viewScale || 1), transform: `rotate(${rotation}deg)` }}> {children} {editing.schema === 2 && ["plain-text", "plain-image"].includes(data.kind) && } {data.kind !== "frame" && } {capabilities.resizable && !resizing && } {resizing && (["top-left", "top", "top-right", "right", "bottom-right", "bottom", "bottom-left", "left"] as const).map((direction) => { const left = direction.includes("left") ? "0%" : direction.includes("right") ? "100%" : "50%"; const top = direction.includes("top") ? "0%" : direction.includes("bottom") ? "100%" : "50%"; return
; } export function ResizeToggle({ id,renderedTarget }: { id: string;renderedTarget?:import("@/lib/canvas-bindings/compat-types").RenderedTarget }) { const editing = useScopedNodeEditing(id,renderedTarget); return ; }