'use client'; import { Fragment, useCallback, useEffect, useMemo, useRef, type ReactNode, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, } from 'react'; import type { PPTElement, PPTShapeElement, PPTTableElement, PPTTextElement } from '@openmaic/dsl'; import { SlideCanvas, useViewportSize } from '@openmaic/renderer'; import { SelectionOverlay } from './handles/SelectionOverlay'; import { LineHandles } from './handles/LineHandles'; import { ResizeHandles } from './handles/ResizeHandles'; import { RotateHandle } from './handles/RotateHandle'; import { ShapeKeypointHandles } from './handles/ShapeKeypointHandles'; import { MarqueeBox } from './handles/MarqueeBox'; import { AlignmentGuides } from './handles/AlignmentGuides'; import { ElementInteractionLayer } from './layers/ElementInteractionLayer'; import { useEditGesture } from './useEditGesture'; import { useLineHandleGesture } from './useLineHandleGesture'; import { useMarqueeGesture } from './useMarqueeGesture'; import { useResizeGesture } from './useResizeGesture'; import { useRotateGesture } from './useRotateGesture'; import { useShapeKeypointGesture } from './useShapeKeypointGesture'; import { useTextCreateGesture } from './useTextCreateGesture'; import { useLineCreateGesture } from './useLineCreateGesture'; import { getLineSnapPoints } from './core/line-drag'; import { getResizeHandles } from './core/resize'; import { canRotate } from './core/rotate'; import { RendererTextEditor } from './text/RendererTextEditor'; import { RendererShapeLabelEditor } from './shape/RendererShapeLabelEditor'; import { RendererTableEditor, TableEditMask, type RendererTableEditorController, } from './table/RendererTableEditor'; import { TextAutoSize, type TextAutoSizeController } from './text/TextAutoSize'; import { isSemanticallyEmptyText } from './text/richText'; import { EDITOR_REACT_STYLES } from './styles'; import type { TextEditorController } from './text/types'; import { EMPTY_SELECTION, type EditableSlideCanvasProps } from './types'; /** * EditableSlideCanvas — the renderer v2 editing surface. It renders the * controlled document through the v1 read-only {@link SlideCanvas} (whose * render path is left untouched) and layers its own interaction surface on top: * a per-element hit layer that arms drag/click gestures, and a * {@link SelectionOverlay} driven by the controlled `selection`. * * Gestures are owned by {@link useEditGesture}: pointer-down + drag produces a * live working copy for 60fps feedback and, on pointer-up past a small * threshold, emits exactly one `element.update` intent via `onElementsChange`; * a click with no movement reports selection via `onSelectionChange` only. * Alignment guides are computed but not drawn in this PR. * * The interaction layer is a sibling overlay (same origin, positions scaled by * `canvasScale`) so the v1 fill/render contract is preserved unmodified. When * `scale` is omitted the canvas auto-fits: the overlay reads the SAME * `fitScale` SlideCanvas uses (both measure the same box — see the inner * wrapper below), so overlay and elements stay aligned at auto-fit. * `renderImage`/`renderVideo`/`elementIdPrefix`/`className`/`style` pass through. * * The interaction hit layer is only mounted when a mutation/selection callback * is provided; with neither, the canvas renders read-only (no pointer-capturing * hit targets), matching the Stage-0 inert-without-callbacks contract. */ export function EditableSlideCanvas(props: EditableSlideCanvasProps) { const { slide, onScaleChange, renderImage, renderVideo, renderShapeLabel, videoInteractive, elementIdPrefix, hiddenElementIds, className, style, selection, onSelectionChange, onElementsChange, onTextContentChange, onTextAutoSize, onTextFormatChange, onTextEditorChange, onTextFocusChange, onTableCellChange, tableEditMaskLabel, creatingText, onTextCreate, creatingLine, onLineCreate, onLineCreateCancel, shapePathFormulas, snapping, } = props; const activeSelection = selection ?? EMPTY_SELECTION; const interactive = Boolean(onElementsChange || onSelectionChange); const textEditingEnabled = Boolean(onTextContentChange); const tableEditingEnabled = Boolean(onTableCellChange); const activeEditingTextId = slide.elements.find( (element) => element.id === activeSelection.editingId && activeSelection.elementIds.length === 1 && element.type === 'text' && !element.lock && !hiddenElementIds?.includes(element.id), )?.id; const activeEditingShapeId = slide.elements.find( (element) => element.id === activeSelection.editingId && activeSelection.elementIds.length === 1 && element.type === 'shape' && !element.lock && !hiddenElementIds?.includes(element.id), )?.id; const activeEditingTableId = slide.elements.find( (element) => element.id === activeSelection.editingId && activeSelection.elementIds.length === 1 && element.type === 'table' && !element.lock && !hiddenElementIds?.includes(element.id), )?.id; const hasActiveEditor = Boolean( activeEditingTextId || activeEditingShapeId || activeEditingTableId, ); const textControllerRef = useRef(null); const tableEditorRef = useRef(null); const textAutoSizeRef = useRef(null); const pendingTextFocusPointRef = useRef<{ elementId: string; left: number; top: number; } | null>(null); const pendingTableFocusPointRef = useRef<{ elementId: string; left: number; top: number; } | null>(null); const publishSelection = useCallback( (next: typeof activeSelection) => { const controller = textControllerRef.current; if ( activeEditingTextId && controller?.elementId === activeEditingTextId && next.editingId !== activeEditingTextId ) { if (isSemanticallyEmptyText(controller.getHTML()) && onElementsChange) { controller.discard(); onElementsChange?.([{ type: 'element.delete', ids: [activeEditingTextId] }]); if (next.elementIds.includes(activeEditingTextId)) { onSelectionChange?.(EMPTY_SELECTION); return; } } else { controller.flush(); } } if (activeEditingTableId && next.editingId !== activeEditingTableId) { tableEditorRef.current?.flush(); tableEditorRef.current = null; } onSelectionChange?.(next); }, [activeEditingTableId, activeEditingTextId, onElementsChange, onSelectionChange], ); const handleTextClick = useCallback( (element: PPTElement, event: ReactPointerEvent) => { if ( !textEditingEnabled || (element.type !== 'text' && element.type !== 'shape') || element.lock || (element.type === 'shape' && isSemanticallyEmptyText(element.text?.content ?? '')) ) return; pendingTextFocusPointRef.current = { elementId: element.id, left: event.clientX, top: event.clientY, }; publishSelection({ elementIds: [element.id], primaryId: element.id, editingId: element.id, }); }, [publishSelection, textEditingEnabled], ); const handleElementDoubleClick = useCallback( (element: PPTElement, event: ReactMouseEvent) => { if (textEditingEnabled && element.type === 'shape' && !element.lock) { publishSelection({ elementIds: [element.id], primaryId: element.id, editingId: element.id, }); return; } if (!tableEditingEnabled || element.type !== 'table' || element.lock) return; pendingTableFocusPointRef.current = { elementId: element.id, left: event.clientX, top: event.clientY, }; publishSelection({ elementIds: [element.id], primaryId: element.id, editingId: element.id, }); }, [publishSelection, tableEditingEnabled, textEditingEnabled], ); // Overlay wrapper is `inset: 0` of the same padding-free inner box that // SlideCanvas fills, so its container size — and therefore the fit-computed // `fitScale` and centering offset — is identical to SlideCanvas's own. // Computing `viewportStyles`/`fitScale` here lets the interaction layer sit // at the same on-screen origin and zoom as the rendered elements, including // when `scale` is omitted and both sides auto-fit. const overlayRef = useRef(null); const { viewportStyles, fitScale } = useViewportSize(overlayRef, { viewportSize: slide.viewportSize, viewportRatio: slide.viewportRatio, }); const canvasScale = props.scale ?? fitScale; const textCreationActive = Boolean(creatingText && onTextCreate); const lineCreationActive = Boolean(creatingLine && onLineCreate); const creationActive = textCreationActive || lineCreationActive; const { previewRect: textCreatePreview, onCanvasPointerDown: onTextCreatePointerDown } = useTextCreateGesture({ active: textCreationActive, scale: canvasScale, overlayRef, viewportStyles, onCreate: onTextCreate, }); const { preview: lineCreatePreview, onCanvasPointerDown: onLineCreatePointerDown, onCanvasContextMenu: onLineCreateContextMenu, } = useLineCreateGesture({ active: lineCreationActive, scale: canvasScale, overlayRef, viewportStyles, onCreate: onLineCreate, onCancel: onLineCreateCancel, }); const { workingSlide, guides: dragGuides, dragOffsets, onElementPointerDown, } = useEditGesture({ slide, scale: canvasScale, selection: activeSelection, snapping, onSelectionChange: publishSelection, onElementsChange, }); const onElementPointerDownRef = useRef(onElementPointerDown); useEffect(() => { onElementPointerDownRef.current = onElementPointerDown; }, [onElementPointerDown]); const handleElementPointerDown = useCallback( (element: PPTElement, event: ReactPointerEvent) => onElementPointerDownRef.current(element, event), [], ); // Line-handle reshape gesture. It owns its own working copy (the dragged // line's re-normalized props) so a selected line's endpoint/control handles // can be dragged to reshape it. This is independent of the box move gesture // above — in practice only one is ever in flight — so we layer its working // props on top of the box gesture's `workingSlide` below. const lineSnapPoints = useMemo( () => getLineSnapPoints( slide.elements.filter((element) => !hiddenElementIds?.includes(element.id)), ), [hiddenElementIds, slide.elements], ); const { lineDrag, onHandlePointerDown } = useLineHandleGesture({ scale: canvasScale, snapPoints: lineSnapPoints, onElementsChange, }); // Resize gesture (8-point handles on selected box elements). Like the line // gesture, it owns its own working copy (the resized box props), layered on // the box gesture's `workingSlide` below. const { resizeDrag, onResizeHandlePointerDown } = useResizeGesture({ slide, scale: canvasScale, snapping, shapePathFormulas, onElementsChange, }); // Rotate gesture (the handle floating above a box's top edge). It converts // absolute pointer positions to canvas coordinates, so it needs the overlay // origin (`overlayRef` + `viewportStyles`) rather than just a scale. const { rotateDrag, onRotateHandlePointerDown } = useRotateGesture({ overlayRef, viewportStyles, scale: canvasScale, onElementsChange, }); const { shapeKeypointDrag, onShapeKeypointPointerDown } = useShapeKeypointGesture({ scale: canvasScale, shapePathFormulas, onElementsChange, }); // Marquee (rubber-band) gesture. It arms from a blank-canvas pointer-down on // the capture surface below the element hit targets, tracks a live rectangle, // and on release REPLACES the selection with whatever it covers (or clears it // on a sub-threshold blank click). The capture surface mounts for selection // hosts (`onSelectionChange` — see the surface comment below); the hook itself // also requires `onSelectionChange` to publish. const { marqueeRect, onCanvasPointerDown } = useMarqueeGesture({ slide, scale: canvasScale, overlayRef, viewportStyles, selection: activeSelection, excludeIds: hiddenElementIds, onSelectionChange: publishSelection, }); // The elements to render/hit-test: the box gesture's working copy, with the // active handle drag's props (line reshape / resize box / rotate angle) // merged in so the v1 canvas, the hit layers, and the handles all preview // off the SAME working element and move together during a gesture. At most // one handle gesture is ever in flight (single-pointer hooks), so the merges // never compete. let displayElements = workingSlide.elements; if (lineDrag) { displayElements = displayElements.map((el) => el.id === lineDrag.id ? ({ ...el, ...lineDrag.props } as PPTElement) : el, ); } if (resizeDrag) { displayElements = displayElements.map((el) => el.id === resizeDrag.id ? ({ ...el, ...resizeDrag.props } as PPTElement) : el, ); } if (rotateDrag) { displayElements = displayElements.map((el) => el.id === rotateDrag.id ? ({ ...el, rotate: rotateDrag.rotate } as PPTElement) : el, ); } if (shapeKeypointDrag) { displayElements = displayElements.map((el) => el.id === shapeKeypointDrag.id ? ({ ...el, ...shapeKeypointDrag.props } as PPTElement) : el, ); } const displaySlide = displayElements === workingSlide.elements ? workingSlide : { ...workingSlide, elements: displayElements }; const renderDragOffsets = lineDrag || resizeDrag || rotateDrag ? undefined : dragOffsets; const renderedSlide = renderDragOffsets ? slide : displaySlide; const elements = useMemo(() => { if (!hiddenElementIds?.length) return displayElements; const hidden = new Set(hiddenElementIds); return displayElements.filter((element) => !hidden.has(element.id)); }, [displayElements, hiddenElementIds]); const activeGuides = resizeDrag?.guides ?? dragGuides; // Touch suppression belongs to mutation gestures: select-only hosts keep // native touch panning, while tap-select still receives pointer events. const editingTouchAction = onElementsChange ? 'none' : undefined; const exitElementEditing = useCallback(() => { if (!activeEditingTextId && !activeEditingTableId && !activeEditingShapeId) return; publishSelection({ elementIds: activeSelection.elementIds, primaryId: activeSelection.primaryId, groupId: activeSelection.groupId, }); }, [ activeEditingShapeId, activeEditingTableId, activeEditingTextId, activeSelection, publishSelection, ]); const handleTextEditorChange = useCallback( (controller: TextEditorController | null) => { textControllerRef.current = controller; if (controller && pendingTextFocusPointRef.current?.elementId === controller.elementId) { pendingTextFocusPointRef.current = null; } onTextEditorChange?.(controller); }, [onTextEditorChange], ); const renderText = useCallback( (element: PPTTextElement, defaultContent: ReactNode) => { if (activeEditingTextId !== element.id) return defaultContent; const pendingFocusPoint = pendingTextFocusPointRef.current; const initialFocusPoint = pendingFocusPoint?.elementId === element.id ? { left: pendingFocusPoint.left, top: pendingFocusPoint.top } : undefined; return ( textAutoSizeRef.current?.notifyContentChange()} onFormatChange={onTextFormatChange} onControllerChange={handleTextEditorChange} onFocusChange={onTextFocusChange} onEscape={exitElementEditing} /> ); }, [ activeEditingTextId, handleTextEditorChange, onTextContentChange, onTextAutoSize, onTextFocusChange, onTextFormatChange, resizeDrag?.id, exitElementEditing, ], ); const renderActiveShapeLabel = useCallback( (element: PPTShapeElement, defaultContent: ReactNode) => { if (activeEditingShapeId !== element.id) return renderShapeLabel?.(element, defaultContent) ?? defaultContent; const pendingFocusPoint = pendingTextFocusPointRef.current; const initialFocusPoint = pendingFocusPoint?.elementId === element.id ? { left: pendingFocusPoint.left, top: pendingFocusPoint.top } : undefined; return ( ); }, [ activeEditingShapeId, exitElementEditing, handleTextEditorChange, onElementsChange, onTextContentChange, onTextFocusChange, onTextFormatChange, renderShapeLabel, ], ); const renderTable = useCallback( (element: PPTTableElement, defaultContent: ReactNode) => { if (activeEditingTableId !== element.id || !onTableCellChange) { const selectedForTableEditing = tableEditingEnabled && activeSelection.elementIds.length === 1 && activeSelection.primaryId === element.id && !element.lock; return selectedForTableEditing ? ( {defaultContent} ) : ( defaultContent ); } const pendingFocusPoint = pendingTableFocusPointRef.current; const initialFocusPoint = pendingFocusPoint?.elementId === element.id ? { left: pendingFocusPoint.left, top: pendingFocusPoint.top } : undefined; return ( ); }, [ activeEditingTableId, activeSelection.elementIds.length, activeSelection.primaryId, exitElementEditing, handleTextEditorChange, onTableCellChange, onTextFocusChange, onTextFormatChange, tableEditMaskLabel, tableEditingEnabled, ], ); const handleCanvasPointerDownCapture = useCallback( (event: ReactPointerEvent) => { if (creationActive) return; if (!hasActiveEditor || event.button !== 0) return; const target = event.target; if ( target instanceof Element && target.closest( '.ProseMirror, [contenteditable="true"], [data-table-cell-id], [data-element-id], [data-hit-kind], [data-resize-handle], [data-rotate-handle], [data-line-handle]', ) ) { return; } publishSelection(EMPTY_SELECTION); }, [creationActive, hasActiveEditor, publishSelection], ); return ( // Outer wrapper carries the documented `className`/`style` pass-through // (which may add padding). It fills its container by default (`width`/ // `height: 100%`, merged BEFORE `...style` so a consumer can still override) // — without an explicit height the inner `height: 100%` (and SlideCanvas's // own `height: 100%`) would resolve against an auto-height box, so // `useViewportSize` reads `clientHeight ≈ 0`, `fitScale ≈ 0`, and the canvas // renders blank when `scale` is omitted. The inner wrapper below is // padding-free so that SlideCanvas (normal flow) and the overlay (`inset: 0`) // always measure the same box — otherwise consumer padding would diverge // their box models and misalign the overlay from the rendered elements.