"use client"; import { createContext, useContext, 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 { 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 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); type Gesture = { lease?: EditingLease; node: CanvasNode; start: XYPosition; direction: ResizeDirection | "rotate"; target: 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 flow = useReactFlow(); const update = useUpdateNodeInternals(); const { zoom } = useViewport(); const frame = useRef(null); const gesture = useRef(null); 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") { cancelGesture(); editing.rotate(null); editing.resize(null); } }; 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) return; event.preventDefault(); event.stopPropagation(); 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); } function move(event: PointerEvent) { if (!gesture.current) return; event.stopPropagation(); 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() { 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); } function finish(event: PointerEvent) { if (!gesture.current) return; event.stopPropagation(); 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); } 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 }: { id: string }) { const editing = useContext(NodeEditing); return ; }