'use client' import { useDrag } from '@use-gesture/react' import { useMemo, useRef } from 'react' import { useHotkeys } from 'react-hotkeys-hook' import { useBlobImage } from '@/hooks/useBlobData' import { useCurrentPage, useTextNodes, type TextNodeEntry } from '@/hooks/useCurrentPage' import type { NodeDataPatch, Transform } from '@/lib/api/schemas' import { applyOp, queueAutoRender } from '@/lib/io/scene' import { ops } from '@/lib/ops' import { useEditorUiStore } from '@/lib/stores/editorUiStore' import { useSelectionStore } from '@/lib/stores/selectionStore' type TextBlockLayerProps = { showSprites?: boolean scale: number style?: React.CSSProperties } /** * Overlay for the active page's Text nodes. Each rectangle is draggable / * resizable; commits dispatch `Op::UpdateNode { transform }` through * `applyCommand`. Selection is driven by `selectionStore.nodeIds`. */ export function TextBlockLayer({ showSprites, scale, style }: TextBlockLayerProps) { const nodes = useTextNodes() const page = useCurrentPage() const selectedIds = useSelectionStore((s) => s.nodeIds) const select = useSelectionStore((s) => s.select) const mode = useEditorUiStore((s) => s.mode) const interactive = mode === 'select' || mode === 'block' const hasSelection = useMemo(() => { for (const id of selectedIds) if (id) return true return false }, [selectedIds]) const removeNode = async (id: string) => { if (!page) return const node = page.nodes[id] if (!node) return const idx = Object.keys(page.nodes).indexOf(id) await applyOp(ops.removeNode(page.id, id, node, idx < 0 ? 0 : idx)) if ('text' in node.kind) queueAutoRender(page.id) } const removeSelected = async () => { if (!page) return // Snapshot selection now: each op invalidates the page state by removing a // node, so we can't iterate against a stale closure mid-loop. const ids = Array.from(selectedIds).filter((id): id is string => !!id) for (const id of ids) { await removeNode(id) } } const updateTransform = async (id: string, t: Transform) => { if (!page) return const data: NodeDataPatch = { text: { lockLayoutBox: true, }, } await applyOp(ops.updateNode(page.id, id, { transform: t, data })) queueAutoRender(page.id) } useHotkeys( 'delete', () => { if (hasSelection && interactive) void removeSelected() }, { enabled: hasSelection && interactive }, [selectedIds, interactive], ) return (
{showSprites && nodes.map((n, i) => )} {nodes.map((n, i) => ( select(id, additive)} onCommit={(t) => void updateTransform(n.id, t)} /> ))}
) } type TextBlockItemProps = { node: TextNodeEntry index: number scale: number selected: boolean interactive: boolean onSelect: (id: string, additive: boolean) => void onCommit: (transform: Transform) => void } const isAdditiveEvent = (event: unknown): boolean => { if (!event || typeof event !== 'object') return false const e = event as { shiftKey?: boolean; metaKey?: boolean; ctrlKey?: boolean } return !!(e.shiftKey || e.metaKey || e.ctrlKey) } const RESIZE_HANDLE_SIZE = 8 type ResizeEdge = { top: boolean; bottom: boolean; left: boolean; right: boolean } function TextBlockItem({ node, index, scale, selected, interactive, onSelect, onCommit, }: TextBlockItemProps) { const boxRef = useRef(null) const dragStart = useRef({ x: 0, y: 0, w: 0, h: 0 }) const edgeRef = useRef(null) const isResizeRef = useRef(false) const setBox = (x: number, y: number, w: number, h: number) => { const el = boxRef.current if (!el) return el.style.transform = `translate(${x}px, ${y}px)` el.style.width = `${w}px` el.style.height = `${h}px` } const t = node.transform const bind = useDrag( ({ first, last, movement: [mx, my], event, tap }) => { if (!interactive) return event?.stopPropagation() const additive = isAdditiveEvent(event) if (tap) { onSelect(node.id, additive) return } if (first) { dragStart.current = { x: t.x * scale, y: t.y * scale, w: t.width * scale, h: t.height * scale, } // Keep multi-selection intact when dragging a node that's already selected; // otherwise this click is a single-select (unless the modifier is held). if (additive || !selected) onSelect(node.id, additive) } const { x: sx, y: sy, w: sw, h: sh } = dragStart.current const edge = edgeRef.current if (isResizeRef.current && edge) { let dx = 0 let dy = 0 let w = sw let h = sh if (edge.right) w += mx if (edge.left) { w -= mx dx = mx } if (edge.bottom) h += my if (edge.top) { h -= my dy = my } w = Math.max(4 * scale, w) h = Math.max(4 * scale, h) if (edge.left && w === 4 * scale) dx = sw - 4 * scale if (edge.top && h === 4 * scale) dy = sh - 4 * scale setBox(sx + dx, sy + dy, w, h) if (last) { isResizeRef.current = false edgeRef.current = null onCommit({ x: Math.round((sx + dx) / scale), y: Math.round((sy + dy) / scale), width: Math.max(4, Math.round(w / scale)), height: Math.max(4, Math.round(h / scale)), rotationDeg: t.rotationDeg ?? 0, }) } } else { setBox(sx + mx, sy + my, sw, sh) if (last) { onCommit({ x: Math.round((sx + mx) / scale), y: Math.round((sy + my) / scale), width: t.width, height: t.height, rotationDeg: t.rotationDeg ?? 0, }) } } }, { pointer: { buttons: 1, touch: true }, filterTaps: true, preventDefault: true, eventOptions: { passive: false }, }, ) const handleEdgePointerDown = (edge: ResizeEdge) => { if (!interactive || !selected) return isResizeRef.current = true edgeRef.current = edge } const w = t.width * scale const h = t.height * scale return (
{index + 1}
{selected && interactive && }
) } function BlockSprite({ node, scale }: { node: TextNodeEntry; scale: number }) { const sprite = (node.data.sprite as string | null | undefined) ?? undefined const { data: src } = useBlobImage(sprite) if (!src) return null const spriteT = node.data.spriteTransform const x = (spriteT?.x ?? node.transform.x) * scale const y = (spriteT?.y ?? node.transform.y) * scale return ( ) } function ResizeHandles({ onEdgePointerDown }: { onEdgePointerDown: (edge: ResizeEdge) => void }) { const s = RESIZE_HANDLE_SIZE const half = s / 2 const edges: { edge: ResizeEdge; style: React.CSSProperties; cursor: string }[] = [ { edge: { top: true, left: true, bottom: false, right: false }, cursor: 'nwse-resize', style: { top: -half, left: -half, width: s, height: s }, }, { edge: { top: true, left: false, bottom: false, right: true }, cursor: 'nesw-resize', style: { top: -half, right: -half, width: s, height: s }, }, { edge: { top: false, left: true, bottom: true, right: false }, cursor: 'nesw-resize', style: { bottom: -half, left: -half, width: s, height: s }, }, { edge: { top: false, left: false, bottom: true, right: true }, cursor: 'nwse-resize', style: { bottom: -half, right: -half, width: s, height: s }, }, { edge: { top: true, left: false, bottom: false, right: false }, cursor: 'ns-resize', style: { top: -half, left: s, right: s, height: s }, }, { edge: { top: false, left: false, bottom: true, right: false }, cursor: 'ns-resize', style: { bottom: -half, left: s, right: s, height: s }, }, { edge: { top: false, left: true, bottom: false, right: false }, cursor: 'ew-resize', style: { left: -half, top: s, bottom: s, width: s }, }, { edge: { top: false, left: false, bottom: false, right: true }, cursor: 'ew-resize', style: { right: -half, top: s, bottom: s, width: s }, }, ] return ( <> {edges.map((e, i) => (
onEdgePointerDown(e.edge)} style={{ position: 'absolute', ...e.style, cursor: e.cursor, zIndex: 30 }} /> ))} ) }