import { ChevronLeftIcon, ChevronRightIcon, ExpandIcon, FilePlus2Icon, MinusIcon, PlusIcon, Trash2Icon, TypeIcon, } from "lucide-react"; import { type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, } from "react"; import type { Presentation, PresentationChart, PresentationElement, PresentationFill, PresentationGroup, PresentationGroupChild, PresentationImage, PresentationLine, PresentationPosition, PresentationShape, PresentationTable, PresentationTextStyle, Slide, } from "@opengeni/artifact-tool/reference"; import { cn } from "../../lib/cn"; import { ArtifactSurface } from "./artifact-surface"; import { useOpenGeniText, type OpenGeniText } from "../../messages/runtime"; const MIN_ZOOM = 0.2; const MAX_ZOOM = 4; const RAIL_ITEM_HEIGHT = 116; const RAIL_OVERSCAN = 3; const DEFAULT_RAIL_HEIGHT = 640; const MOBILE_RAIL_ITEM_HEIGHT = 44; const MOBILE_RAIL_OVERSCAN = 3; const DEFAULT_MOBILE_LIST_HEIGHT = 264; const DEFAULT_VIEWPORT = { width: 960, height: 640 }; const SPATIAL_TILE = 256; const MAX_SPATIAL_BUCKETS_PER_OBJECT = 1_024; const CANVAS_OVERSCAN_PX = 96; const MAX_CANVAS_EDGE = 8_192; const MAX_CANVAS_PIXELS = 16_000_000; const MAX_EDITOR_GEOMETRY = 1_000_000; const MAX_TABLE_CELLS_PER_PAINT = 20_000; const MAX_PROJECTION_SLIDES = 100_000; const MAX_PROJECTION_ELEMENTS_PER_SLIDE = 100_000; const MAX_PROJECTION_GROUP_DEPTH = 16; const MAX_PROJECTION_TABLE_CELLS = 1_000_000; const MAX_PROJECTION_TEXT = 1_000_000; type PresentationObject = PresentationElement; type PresentationObjectKind = "shape" | "chart" | "image" | "table" | "group"; type DisplayObject = { id: string; kind: PresentationObjectKind; object: PresentationObject; readOnly: boolean; }; export type PresentationCommit = | { kind: "slide-insert"; slideId: string; index: number; revision?: string | number; } | { kind: "slide-delete"; slideId: string; index: number; revision?: string | number; } | { kind: "object-insert"; slideId: string; objectId: string; index: number; position: PresentationPosition; revision?: string | number; } | { kind: "object-delete"; slideId: string; objectId: string; revision?: string | number; } | { kind: "move" | "resize"; slideId: string; objectId: string; before: PresentationPosition; after: PresentationPosition; revision?: string | number; } | { kind: "text"; slideId: string; objectId: string; before: string; after: string; revision?: string | number; }; export type PresentationCommitHandler = | ((commit: PresentationCommit) => void) | ((commit: PresentationCommit) => Promise); export type PresentationProjectionPosition = Readonly<{ left: number; top: number; width: number; height: number; }>; export type PresentationProjectionFill = string | Readonly<{ type?: "solid"; color: string }>; export type PresentationProjectionLine = Readonly<{ style?: "solid" | "dash" | "dot" | "none"; fill?: PresentationProjectionFill; width?: number; }>; export type PresentationProjectionTextStyle = Readonly<{ fontFamily?: string; fontSize?: number; bold?: boolean; italic?: boolean; underline?: boolean; color?: string; alignment?: "left" | "center" | "right" | "justify"; verticalAlignment?: "top" | "middle" | "bottom"; }>; export type PresentationProjectionTextRun = Readonly<{ text: string; style: PresentationProjectionTextStyle; language?: string | null; }>; export type PresentationProjectionTextParagraph = Readonly<{ runs: readonly PresentationProjectionTextRun[]; alignment: "left" | "center" | "right" | "justify"; }>; export type PresentationProjectionRichText = Readonly<{ paragraphs: readonly PresentationProjectionTextParagraph[]; verticalAlignment: "top" | "middle" | "bottom"; }>; export type PresentationProjectionElementMetadata = Readonly<{ nodeSource?: Readonly<{ kind: "master" | "layout" | "slide"; id: string }>; inherited?: boolean; parentId?: string | null; order?: number; /** Inherited master/layout nodes remain visible but cannot be edited in slide mode. */ readOnly?: boolean; }>; export type PresentationProjectionShape = Readonly<{ kind: "shape"; id: string; name: string; geometry: "textbox" | "rect" | "roundRect" | "ellipse" | "triangle" | "rightArrow" | "line"; position: PresentationProjectionPosition; fill?: PresentationProjectionFill; line?: PresentationProjectionLine; rotation?: number; borderRadius?: number | string; text: string; textStyle?: PresentationProjectionTextStyle; richText?: PresentationProjectionRichText; }> & PresentationProjectionElementMetadata; export type PresentationProjectionChartSeries = Readonly<{ name: string; categories?: readonly string[]; values: readonly number[]; xValues?: readonly number[]; bubbleSizes?: readonly number[]; fill?: PresentationProjectionFill; line?: PresentationProjectionLine; }>; export type PresentationProjectionChart = Readonly<{ kind: "chart"; id: string; name: string; type: "bar" | "line" | "area" | "pie" | "doughnut" | "scatter" | "bubble" | "radar"; position: PresentationProjectionPosition; title?: string; titleRichText?: PresentationProjectionRichText; hasLegend?: boolean; series: readonly PresentationProjectionChartSeries[]; xAxis?: Readonly<{ visible?: boolean; title?: string; min?: number; max?: number }>; yAxis?: Readonly<{ visible?: boolean; title?: string; min?: number; max?: number }>; dataLabels?: Readonly<{ showValue?: boolean; showSeriesName?: boolean; showCategoryName?: boolean; showPercent?: boolean; position?: "center" | "inEnd" | "outEnd"; }>; }> & PresentationProjectionElementMetadata; export type PresentationProjectionImage = Readonly<{ kind: "image"; id: string; name: string; position: PresentationProjectionPosition; /** Host-resolved data/blob/same-origin URL. Arbitrary remote URLs are ignored. */ source?: string; alt?: string; prompt?: string; fit?: "contain" | "cover"; crop?: Readonly<{ left: number; top: number; right: number; bottom: number }>; geometry?: "rect" | "roundRect" | "ellipse"; borderRadius?: number | string; rotation?: number; flipHorizontal?: boolean; flipVertical?: boolean; digest?: string; contentType?: string; intrinsicWidth?: number; intrinsicHeight?: number; }> & PresentationProjectionElementMetadata; export type PresentationProjectionTableCell = Readonly<{ text: string; richText?: PresentationProjectionRichText; fill?: PresentationProjectionFill; textStyle?: PresentationProjectionTextStyle; rowSpan?: number; colSpan?: number; }>; export type PresentationProjectionTable = Readonly<{ kind: "table"; id: string; name: string; position: PresentationProjectionPosition; rows: readonly (readonly (PresentationProjectionTableCell | null)[])[]; columnWidths?: readonly number[]; rowHeights?: readonly number[]; fill?: PresentationProjectionFill; line?: PresentationProjectionLine; textStyle?: PresentationProjectionTextStyle; }> & PresentationProjectionElementMetadata; export type PresentationProjectionGroup = Readonly<{ kind: "group"; id: string; name: string; position: PresentationProjectionPosition; childOffset: Readonly<{ left: number; top: number }>; childExtent: Readonly<{ width: number; height: number }>; rotation?: number; flipHorizontal?: boolean; flipVertical?: boolean; children: readonly PresentationProjectionElement[]; }> & PresentationProjectionElementMetadata; export type PresentationProjectionConnector = Readonly<{ kind: "connector"; id: string; name: string; connectorKind: "straight" | "elbow" | "curved"; position: PresentationProjectionPosition; start: Readonly<{ nodeId: string | null; x: number; y: number }>; end: Readonly<{ nodeId: string | null; x: number; y: number }>; line?: PresentationProjectionLine; }> & PresentationProjectionElementMetadata; export type PresentationProjectionElement = | PresentationProjectionShape | PresentationProjectionChart | PresentationProjectionImage | PresentationProjectionTable | PresentationProjectionGroup | PresentationProjectionConnector; export type PresentationSlideProjection = Readonly<{ id: string; title?: string; background?: PresentationProjectionFill; elements: readonly PresentationProjectionElement[]; layout?: Readonly<{ id: string; name: string; masterId: string | null; background: PresentationProjectionFill; }> | null; notes?: PresentationProjectionRichText | null; }>; /** Immutable render/edit projection; the host keeps the canonical deck off the React thread. */ export type PresentationEditorProjection = Readonly<{ revision: string | number; slideSize: Readonly<{ width: number; height: number }>; slides: readonly PresentationSlideProjection[]; }>; export type PresentationProjectionEditorProps = Omit< PresentationEditorProps, "presentation" | "revision" | "onSlideChange" | "onSelectionChange" | "onCommit" > & { projection: PresentationEditorProjection; commit?: PresentationCommitHandler | undefined; onCommandError?: ((error: Error) => void) | undefined; onSlideChange?: ((slide: PresentationSlideProjection, index: number) => void) | undefined; onSelectionChange?: | ((object: PresentationProjectionElement | null, slide: PresentationSlideProjection) => void) | undefined; }; export type PresentationProjectionArtifactSurfaceProps = PresentationProjectionEditorProps & { title?: string | undefined; showHeader?: boolean | undefined; subtitle?: ReactNode | undefined; actions?: ReactNode | undefined; busy?: boolean | undefined; }; export type PresentationEditorProps = { presentation: Presentation; /** Increment/change after a host applies an out-of-band model mutation. */ revision?: string | number | undefined; activeSlideId?: string | undefined; defaultActiveSlideId?: string | undefined; selectedObjectId?: string | null | undefined; defaultSelectedObjectId?: string | null | undefined; zoom?: number | undefined; defaultZoom?: number | undefined; readOnly?: boolean | undefined; /** Temporarily fence commands while keeping the current editor draft. */ authoringBlockedReason?: "pending_conflict" | "prior_writer" | undefined; className?: string | undefined; onSlideChange?: ((slide: Slide, index: number) => void) | undefined; onSelectionChange?: ((object: PresentationObject | null, slide: Slide) => void) | undefined; onCommit?: PresentationCommitHandler | undefined; onCommandError?: ((error: Error) => void) | undefined; onZoomChange?: ((zoom: number) => void) | undefined; }; export type PresentationArtifactSurfaceProps = PresentationEditorProps & { title?: string | undefined; showHeader?: boolean | undefined; subtitle?: ReactNode | undefined; actions?: ReactNode | undefined; busy?: boolean | undefined; }; type ViewportMetrics = { scrollLeft: number; scrollTop: number; width: number; height: number; stageLeft: number; stageTop: number; }; type DragState = { pointerId: number; mode: "move" | "resize"; object: PresentationObject; rotation: number; start: { x: number; y: number }; before: PresentationPosition; }; type TextEdit = { slideId: string; shape: PresentationShape; before: string; }; type DraftPosition = { objectId: string; position: PresentationPosition; }; type SpatialIndex = { objects: DisplayObject[]; bounds: PresentationPosition[]; buckets: Map; overflowObjects: number[]; }; type CanvasImageEntry = { image: HTMLImageElement; ready: boolean; }; /** Compatibility adapter for the public authoring model. */ export function PresentationEditor(props: PresentationEditorProps) { return ; } /** * Projection-first editor. It creates only a disposable render view; all * durable mutations leave through the async command port. */ export function PresentationProjectionEditor({ projection, commit, onCommandError, onSlideChange, onSelectionChange, readOnly = false, ...props }: PresentationProjectionEditorProps) { const view = useMemo(() => presentationViewFromProjection(projection), [projection]); return ( commit({ ...command, revision: projection.revision } as PresentationCommit) : undefined } onCommandError={onCommandError} onSlideChange={(slide, index) => { const projected = view.slideById.get(slide.id); if (projected) onSlideChange?.(projected, index); }} onSelectionChange={(object, slide) => { const projectedSlide = view.slideById.get(slide.id); if (!projectedSlide) return; onSelectionChange?.( object ? (view.elementById.get(object.id) ?? null) : null, projectedSlide, ); }} /> ); } function nextSlideIndexFromKey(key: string, activeIndex: number, length: number): number | null { if (length === 0) return null; if (key === "ArrowDown") return Math.min(length - 1, activeIndex + 1); if (key === "ArrowUp") return Math.max(0, activeIndex - 1); if (key === "Home") return 0; if (key === "End") return length - 1; return null; } function PresentationEditorCore({ presentation, revision, activeSlideId: controlledSlideId, defaultActiveSlideId, selectedObjectId: controlledSelectionId, defaultSelectedObjectId = null, zoom: controlledZoom, defaultZoom = 0.75, readOnly = false, authoringBlockedReason, className, onSlideChange, onSelectionChange, onCommit, onCommandError, onZoomChange, }: PresentationEditorProps) { const t = useOpenGeniText(); const mutationsBlocked = readOnly || authoringBlockedReason !== undefined; const authoringStatus = readOnly ? null : authoringBlockedReason === "pending_conflict" ? t("artifacts.presentationEditor.anEarlierChangeNeedsAttention") : authoringBlockedReason === "prior_writer" ? t("artifacts.presentationEditor.savingEarlierChanges") : null; const slides = presentation.slides.items; const firstSlideId = slides[0]?.id ?? null; const [localSlideId, setLocalSlideId] = useState( defaultActiveSlideId ?? controlledSlideId ?? firstSlideId, ); const requestedSlideId = controlledSlideId ?? localSlideId; const activeSlide = slides.find((slide) => slide.id === requestedSlideId) ?? slides[0] ?? null; const activeIndex = activeSlide ? slides.indexOf(activeSlide) : -1; const [localSelectionId, setLocalSelectionId] = useState(defaultSelectedObjectId); const selectedId = controlledSelectionId === undefined ? localSelectionId : controlledSelectionId; const [localZoom, setLocalZoom] = useState(() => clampZoom(defaultZoom)); const zoom = clampZoom(controlledZoom ?? localZoom); const [fitMode, setFitMode] = useState(false); const [viewportMeasured, setViewportMeasured] = useState(false); const [modelEpoch, setModelEpoch] = useState(0); const [pendingCommands, setPendingCommands] = useState(0); const [commandFailure, setCommandFailure] = useState<{ message: string; retry: () => void; } | null>(null); const [rail, setRail] = useState({ scrollTop: 0, height: DEFAULT_RAIL_HEIGHT, }); const [mobileSelectorOpen, setMobileSelectorOpen] = useState(false); const [mobileRail, setMobileRail] = useState({ scrollTop: 0, height: DEFAULT_MOBILE_LIST_HEIGHT, }); const [viewport, setViewport] = useState({ scrollLeft: 0, scrollTop: 0, ...DEFAULT_VIEWPORT, stageLeft: 0, stageTop: 0, }); const [draftPosition, setDraftPosition] = useState(null); const [textEdit, setTextEdit] = useState(null); const [textDraft, setTextDraft] = useState(""); const [paintEpoch, setPaintEpoch] = useState(0); const railRef = useRef(null); const mobileSelectorTriggerRef = useRef(null); const mobileSelectorPanelRef = useRef(null); const mobileRailRef = useRef(null); const viewportRef = useRef(null); const stageRef = useRef(null); const canvasRef = useRef(null); const overlayRef = useRef(null); const editorRef = useRef(null); const dragRef = useRef(null); const textEditRef = useRef(null); const imageCacheRef = useRef(new Map()); const mountedRef = useRef(true); const commandScopeRef = useRef(0); const commandVersionsRef = useRef(new Map()); const pendingSlideIdRef = useRef(null); const pendingObjectIdRef = useRef(null); const mobileRailScrollOnOpenRef = useRef(0); const domId = useId().replaceAll(":", ""); const selectionProxyId = `${domId}-presentation-selection`; const instructionsId = `${domId}-presentation-instructions`; const objects = useMemo( () => (activeSlide ? listSlideObjects(activeSlide) : []), // revision is a deliberate host invalidation token; modelEpoch covers local commits. // oxlint-disable-next-line react-hooks/exhaustive-deps [activeSlide, modelEpoch, revision], ); const objectById = useMemo( () => new Map(objects.map((entry) => [entry.id, entry] as const)), [objects], ); const objectIndexById = useMemo( () => new Map(objects.map((entry, index) => [entry.id, index] as const)), [objects], ); const selected = selectedId ? (objectById.get(selectedId) ?? null) : null; const spatialIndex = useMemo( () => buildSpatialIndex(objects, presentation.slideSize), [objects, presentation.slideSize], ); const effectiveSelectedPosition = selected && draftPosition?.objectId === selected.id ? draftPosition.position : selected ? copyPosition(selected.object.position) : null; useEffect(() => { const pendingSlideId = pendingSlideIdRef.current; if ( pendingSlideId && controlledSlideId === undefined && slides.some((slide) => slide.id === pendingSlideId) ) { pendingSlideIdRef.current = null; setLocalSlideId(pendingSlideId); return; } if ( activeSlide && requestedSlideId !== activeSlide.id && controlledSlideId === undefined && pendingSlideId === null ) { setLocalSlideId(activeSlide.id); } }, [activeSlide, controlledSlideId, requestedSlideId, slides]); useEffect(() => { if (selectedId && !objectById.has(selectedId) && controlledSelectionId === undefined) { setLocalSelectionId(null); } }, [controlledSelectionId, objectById, selectedId]); useEffect(() => { const pendingObjectId = pendingObjectIdRef.current; if (pendingObjectId && controlledSelectionId === undefined && objectById.has(pendingObjectId)) { pendingObjectIdRef.current = null; setLocalSelectionId(pendingObjectId); } }, [controlledSelectionId, objectById]); useLayoutEffect(() => { const element = railRef.current; if (!element || typeof ResizeObserver === "undefined") return; const measure = () => setRail((current) => ({ scrollTop: element.scrollTop, height: element.clientHeight || current.height, })); measure(); const observer = new ResizeObserver(measure); observer.observe(element); return () => observer.disconnect(); }, []); useLayoutEffect(() => { if (!mobileSelectorOpen) return; const element = mobileRailRef.current; if (!element) return; element.scrollTop = mobileRailScrollOnOpenRef.current; element.focus(); const measure = () => setMobileRail((current) => ({ scrollTop: element.scrollTop, height: element.clientHeight || current.height, })); measure(); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(measure); observer.observe(element); return () => observer.disconnect(); }, [mobileSelectorOpen]); useEffect(() => { if (!mobileSelectorOpen) return; const onPointerDown = (event: PointerEvent) => { const target = event.target; if (!(target instanceof Node)) return; if (mobileSelectorTriggerRef.current?.contains(target)) return; if (mobileSelectorPanelRef.current?.contains(target)) return; setMobileSelectorOpen(false); }; const onKeyDown = (event: KeyboardEvent) => { if (event.key !== "Escape") return; event.preventDefault(); setMobileSelectorOpen(false); mobileSelectorTriggerRef.current?.focus(); }; document.addEventListener("pointerdown", onPointerDown); document.addEventListener("keydown", onKeyDown); return () => { document.removeEventListener("pointerdown", onPointerDown); document.removeEventListener("keydown", onKeyDown); }; }, [mobileSelectorOpen]); useLayoutEffect(() => { const element = viewportRef.current; if (!element) return; const measure = () => { const stage = stageRef.current; const measuredWidth = element.clientWidth; const measuredHeight = element.clientHeight; const next: ViewportMetrics = { scrollLeft: element.scrollLeft, scrollTop: element.scrollTop, width: measuredWidth || DEFAULT_VIEWPORT.width, height: measuredHeight || DEFAULT_VIEWPORT.height, stageLeft: stage?.offsetLeft ?? 0, stageTop: stage?.offsetTop ?? 0, }; setViewport((current) => (sameViewport(current, next) ? current : next)); if (measuredWidth > 0 && measuredHeight > 0) setViewportMeasured(true); }; measure(); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(measure); observer.observe(element); if (stageRef.current) observer.observe(stageRef.current); return () => observer.disconnect(); }, [activeSlide?.id, presentation.slideSize.height, presentation.slideSize.width, zoom]); useLayoutEffect(() => { const imageCache = imageCacheRef.current; return () => disposeCanvasImageCache(imageCache); }, [activeSlide]); useEffect(() => { const edit = textEditRef.current; const currentEntry = edit ? objectById.get(edit.shape.id) : null; if ( edit && activeSlide?.id === edit.slideId && currentEntry?.kind === "shape" && !currentEntry.readOnly && (currentEntry.object as PresentationShape).geometry !== "line" ) { const rebound = { ...edit, shape: currentEntry.object as PresentationShape, }; textEditRef.current = rebound; setTextEdit(rebound); } else { textEditRef.current = null; setTextEdit(null); } }, [activeSlide?.id, objectById]); useEffect(() => { dragRef.current = null; setDraftPosition(null); commandScopeRef.current += 1; commandVersionsRef.current.clear(); setPendingCommands(0); setCommandFailure(null); }, [activeSlide?.id, revision]); useEffect(() => { const edit = textEditRef.current; if (edit && edit.shape.id !== selectedId) { textEditRef.current = null; setTextEdit(null); } const drag = dragRef.current; if (drag && drag.object.id !== selectedId) { dragRef.current = null; setDraftPosition(null); } }, [selectedId]); useEffect(() => { if (!readOnly) return; dragRef.current = null; setDraftPosition(null); textEditRef.current = null; setTextEdit(null); }, [readOnly]); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; dragRef.current = null; textEditRef.current = null; }; }, []); useEffect(() => { const element = railRef.current; if (!element || activeIndex < 0) return; const top = activeIndex * RAIL_ITEM_HEIGHT; const bottom = top + RAIL_ITEM_HEIGHT; const height = element.clientHeight || rail.height; if (top >= element.scrollTop && bottom <= element.scrollTop + height) return; const nextTop = Math.max(0, top - height / 2); element.scrollTop = nextTop; setRail((current) => ({ ...current, scrollTop: nextTop })); }, [activeIndex, rail.height]); const visibleSlideRegion = useMemo(() => { const slideWidth = presentation.slideSize.width; const slideHeight = presentation.slideSize.height; const stageWidth = slideWidth * zoom; const stageHeight = slideHeight * zoom; if (stageWidth <= viewport.width && stageHeight <= viewport.height) { return { left: 0, top: 0, width: slideWidth, height: slideHeight }; } const overscan = CANVAS_OVERSCAN_PX / zoom; const visibleLeft = Math.max(0, (viewport.scrollLeft - viewport.stageLeft) / zoom); const visibleTop = Math.max(0, (viewport.scrollTop - viewport.stageTop) / zoom); const left = Math.max(0, visibleLeft - overscan); const top = Math.max(0, visibleTop - overscan); return { left, top, width: Math.max(0, Math.min(slideWidth - left, viewport.width / zoom + overscan * 2)), height: Math.max(0, Math.min(slideHeight - top, viewport.height / zoom + overscan * 2)), }; }, [presentation.slideSize.height, presentation.slideSize.width, viewport, zoom]); const visibleObjectIndexes = useMemo(() => { if (!activeSlide) return []; if ( visibleSlideRegion.left === 0 && visibleSlideRegion.top === 0 && visibleSlideRegion.width === presentation.slideSize.width && visibleSlideRegion.height === presentation.slideSize.height ) { return objects.map((_, index) => index); } const visible = querySpatialIndex(spatialIndex, visibleSlideRegion); const selectedIndex = selectedId ? (objectIndexById.get(selectedId) ?? -1) : -1; if (selectedIndex >= 0 && !visible.includes(selectedIndex)) visible.push(selectedIndex); const draftIndex = draftPosition ? (objectIndexById.get(draftPosition.objectId) ?? -1) : -1; if (draftIndex >= 0 && !visible.includes(draftIndex)) visible.push(draftIndex); return visible.sort((a, b) => a - b); }, [ activeSlide, objects, objectIndexById, presentation.slideSize, draftPosition, selectedId, spatialIndex, visibleSlideRegion, ]); useLayoutEffect(() => { const canvas = canvasRef.current; if (!canvas || !activeSlide) return; paintSlideCanvas(t, { canvas, slide: activeSlide, slideSize: presentation.slideSize, zoom, objects, objectIndexes: visibleObjectIndexes, renderRegion: visibleSlideRegion, draftPosition, imageCache: imageCacheRef.current, requestRepaint: () => setPaintEpoch((value) => value + 1), }); }, [ activeSlide, draftPosition, objects, paintEpoch, presentation.slideSize, visibleObjectIndexes, visibleSlideRegion, zoom, t, ]); const selectObject = useCallback( (entry: DisplayObject | null) => { textEditRef.current = null; setTextEdit(null); setDraftPosition(null); if (controlledSelectionId === undefined) setLocalSelectionId(entry?.id ?? null); if (activeSlide) onSelectionChange?.(entry?.object ?? null, activeSlide); }, [activeSlide, controlledSelectionId, onSelectionChange], ); const selectSlide = useCallback( (slide: Slide, index: number) => { if (controlledSlideId === undefined) setLocalSlideId(slide.id); selectObject(null); onSlideChange?.(slide, index); }, [controlledSlideId, onSlideChange, selectObject], ); const changeZoom = useCallback( (next: number) => { const clamped = clampZoom(next); setFitMode(false); if (clamped === zoom) return; if (controlledZoom === undefined) setLocalZoom(clamped); onZoomChange?.(clamped); }, [controlledZoom, onZoomChange, zoom], ); const applyFittedZoom = useCallback(() => { const next = fittedPresentationZoom(viewport, presentation.slideSize); if (next === zoom) return; if (controlledZoom === undefined) setLocalZoom(next); onZoomChange?.(next); }, [controlledZoom, onZoomChange, presentation.slideSize, viewport, zoom]); const fitSlide = useCallback(() => { setFitMode(true); applyFittedZoom(); }, [applyFittedZoom]); useLayoutEffect(() => { if (!viewportMeasured || controlledZoom !== undefined) return; const fitted = fittedPresentationZoom(viewport, presentation.slideSize); if (!fitMode && zoom <= fitted) return; if (!fitMode) setFitMode(true); applyFittedZoom(); }, [ applyFittedZoom, controlledZoom, fitMode, presentation.slideSize, viewport, viewportMeasured, zoom, ]); const runCommit = useCallback( (key: string, command: PresentationCommit, rollback: () => void, retry: () => void) => { if (mutationsBlocked || !onCommit) return; const version = (commandVersionsRef.current.get(key) ?? 0) + 1; commandVersionsRef.current.set(key, version); const scope = commandScopeRef.current; setCommandFailure(null); let result: void | Promise; try { result = onCommit(command); } catch (cause) { if ( !mountedRef.current || commandScopeRef.current !== scope || commandVersionsRef.current.get(key) !== version ) { return; } rollback(); const error = asPresentationError(cause); setCommandFailure({ message: error.message, retry }); onCommandError?.(error); return; } if (!result || typeof result.then !== "function") return; setPendingCommands((current) => current + 1); void Promise.resolve(result).then( () => { if (!mountedRef.current || commandScopeRef.current !== scope) return; setPendingCommands((current) => Math.max(0, current - 1)); }, (cause) => { if (!mountedRef.current || commandScopeRef.current !== scope) return; setPendingCommands((current) => Math.max(0, current - 1)); if (commandVersionsRef.current.get(key) !== version) return; rollback(); const error = asPresentationError(cause); setCommandFailure({ message: error.message, retry }); onCommandError?.(error); }, ); }, [mutationsBlocked, onCommandError, onCommit], ); const commitPosition = useCallback( (entry: DisplayObject, kind: "move" | "resize", next: PresentationPosition) => { if (!activeSlide || mutationsBlocked || entry.readOnly) return; const before = copyPosition(entry.object.position); const after = normalizeEditorPosition(next); if (samePosition(before, after)) return; entry.object.position = after; setModelEpoch((value) => value + 1); const command: PresentationCommit = { kind, slideId: activeSlide.id, objectId: entry.id, before, after, }; const submit = () => runCommit( `position:${activeSlide.id}:${entry.id}`, command, () => { entry.object.position = before; setModelEpoch((value) => value + 1); }, () => { entry.object.position = after; setModelEpoch((value) => value + 1); submit(); }, ); submit(); }, [activeSlide, mutationsBlocked, runCommit], ); const insertSlide = useCallback(() => { if (mutationsBlocked) return; const slideId = randomPresentationId(); const index = activeIndex < 0 ? slides.length : activeIndex + 1; const command: PresentationCommit = { kind: "slide-insert", slideId, index }; const submit = () => { pendingSlideIdRef.current = slideId; runCommit( `slide-insert:${slideId}`, command, () => { if (pendingSlideIdRef.current === slideId) pendingSlideIdRef.current = null; }, submit, ); }; submit(); }, [activeIndex, mutationsBlocked, runCommit, slides.length]); const deleteSlide = useCallback(() => { if (mutationsBlocked || !activeSlide) return; const command: PresentationCommit = { kind: "slide-delete", slideId: activeSlide.id, index: activeIndex, }; const submit = () => runCommit(`slide-delete:${activeSlide.id}`, command, () => undefined, submit); submit(); }, [activeIndex, activeSlide, mutationsBlocked, runCommit]); const insertTextBox = useCallback(() => { if (mutationsBlocked || !activeSlide) return; const objectId = randomPresentationId(); const position = nextTextBoxPosition(objects, presentation.slideSize); const command: PresentationCommit = { kind: "object-insert", slideId: activeSlide.id, objectId, index: activeSlide.elements.length, position, }; const submit = () => { pendingObjectIdRef.current = objectId; runCommit( `object-insert:${activeSlide.id}:${objectId}`, command, () => { if (pendingObjectIdRef.current === objectId) pendingObjectIdRef.current = null; }, submit, ); }; submit(); }, [activeSlide, objects, presentation.slideSize, mutationsBlocked, runCommit]); const deleteObject = useCallback(() => { if (mutationsBlocked || !activeSlide || !selected || selected.readOnly) return; const command: PresentationCommit = { kind: "object-delete", slideId: activeSlide.id, objectId: selected.id, }; const submit = () => runCommit(`object-delete:${activeSlide.id}:${selected.id}`, command, () => undefined, submit); submit(); }, [activeSlide, mutationsBlocked, runCommit, selected]); const startTextEdit = useCallback( (entry: DisplayObject | null) => { if (mutationsBlocked || !entry || entry.readOnly || entry.kind !== "shape") return; const shape = entry.object as PresentationShape; if (shape.geometry === "line") return; const before = shape.text.toString(); if (!activeSlide) return; const edit = { slideId: activeSlide.id, shape, before }; textEditRef.current = edit; setTextEdit(edit); setTextDraft(before); }, [activeSlide, mutationsBlocked], ); useEffect(() => { if (!textEdit) return; editorRef.current?.focus(); editorRef.current?.select(); }, [textEdit]); const finishTextEdit = useCallback( (cancel = false) => { const edit = textEditRef.current; if (!edit || (!cancel && authoringBlockedReason !== undefined)) return; textEditRef.current = null; setTextEdit(null); const { shape, before, slideId } = edit; if (!activeSlide || activeSlide.id !== slideId) return; if (cancel || mutationsBlocked || before === textDraft) return; shape.text.set(textDraft); setModelEpoch((value) => value + 1); const command: PresentationCommit = { kind: "text", slideId, objectId: shape.id, before, after: textDraft, }; const submit = () => runCommit( `text:${slideId}:${shape.id}`, command, () => { shape.text.set(before); setModelEpoch((value) => value + 1); }, () => { shape.text.set(textDraft); setModelEpoch((value) => value + 1); submit(); }, ); submit(); }, [activeSlide, authoringBlockedReason, mutationsBlocked, runCommit, textDraft], ); const cycleObject = useCallback( (direction: -1 | 1) => { if (objects.length === 0) return; const current = selected ? objects.indexOf(selected) : direction === 1 ? -1 : 0; const next = (current + direction + objects.length) % objects.length; selectObject(objects[next] ?? null); }, [objects, selectObject, selected], ); const changeSlideBy = useCallback( (amount: -1 | 1) => { if (activeIndex < 0) return; const nextIndex = Math.max(0, Math.min(slides.length - 1, activeIndex + amount)); const next = slides[nextIndex]; if (next && next !== activeSlide) selectSlide(next, nextIndex); }, [activeIndex, activeSlide, selectSlide, slides], ); const toggleMobileSlideSelector = useCallback(() => { setMobileSelectorOpen((open) => { if (open) return false; const index = Math.max(0, activeIndex); const height = DEFAULT_MOBILE_LIST_HEIGHT; const maxScroll = Math.max(0, slides.length * MOBILE_RAIL_ITEM_HEIGHT - height); const scrollTop = Math.max( 0, Math.min( maxScroll, index * MOBILE_RAIL_ITEM_HEIGHT - height / 2 + MOBILE_RAIL_ITEM_HEIGHT / 2, ), ); mobileRailScrollOnOpenRef.current = scrollTop; setMobileRail({ scrollTop, height }); return true; }); }, [activeIndex, slides.length]); const chooseMobileSlide = useCallback( (slide: Slide, index: number) => { if (slide !== activeSlide) selectSlide(slide, index); setMobileSelectorOpen(false); mobileSelectorTriggerRef.current?.focus(); }, [activeSlide, selectSlide], ); const handleCanvasKeyDown = useCallback( (event: ReactKeyboardEvent) => { if (event.metaKey || event.ctrlKey) { if (event.key === "+" || event.key === "=") { event.preventDefault(); changeZoom(zoom * 1.15); } else if (event.key === "-") { event.preventDefault(); changeZoom(zoom / 1.15); } else if (event.key === "0") { event.preventDefault(); fitSlide(); } return; } if (event.key === "]") { event.preventDefault(); cycleObject(1); return; } if (event.key === "[") { event.preventDefault(); cycleObject(-1); return; } if (event.key === "PageDown") { event.preventDefault(); changeSlideBy(1); return; } if (event.key === "PageUp") { event.preventDefault(); changeSlideBy(-1); return; } if (event.key === "Escape") { event.preventDefault(); selectObject(null); return; } if (event.key === "Enter" || event.key === "F2") { if (selected?.kind === "shape") { event.preventDefault(); startTextEdit(selected); } return; } if (!selected || !event.key.startsWith("Arrow")) return; event.preventDefault(); if (mutationsBlocked || selected.readOnly) return; const amount = event.shiftKey ? 10 : 1; const dx = event.key === "ArrowLeft" ? -amount : event.key === "ArrowRight" ? amount : 0; const dy = event.key === "ArrowUp" ? -amount : event.key === "ArrowDown" ? amount : 0; const before = selected.object.position; commitPosition( selected, event.altKey ? "resize" : "move", event.altKey ? { ...before, width: Math.max(8, before.width + dx), height: Math.max(8, before.height + dy), } : { ...before, left: before.left + dx, top: before.top + dy }, ); }, [ changeSlideBy, changeZoom, commitPosition, cycleObject, fitSlide, mutationsBlocked, selectObject, selected, startTextEdit, zoom, ], ); const pointFromPointer = useCallback( (event: ReactPointerEvent): { x: number; y: number } => { const rect = overlayRef.current?.getBoundingClientRect() ?? event.currentTarget.getBoundingClientRect(); const width = rect.width || presentation.slideSize.width * zoom; const height = rect.height || presentation.slideSize.height * zoom; return { x: ((event.clientX - rect.left) / Math.max(1, width)) * presentation.slideSize.width, y: ((event.clientY - rect.top) / Math.max(1, height)) * presentation.slideSize.height, }; }, [presentation.slideSize.height, presentation.slideSize.width, zoom], ); const beginDrag = useCallback( (event: ReactPointerEvent, entry: DisplayObject, mode: "move" | "resize") => { event.preventDefault(); event.stopPropagation(); selectObject(entry); if (mutationsBlocked || entry.readOnly) return; const start = pointFromPointer(event); const before = copyPosition(entry.object.position); dragRef.current = { pointerId: event.pointerId, mode, object: entry.object, rotation: objectRotation(entry), start, before, }; setDraftPosition({ objectId: entry.id, position: before }); overlayRef.current?.setPointerCapture?.(event.pointerId); }, [pointFromPointer, mutationsBlocked, selectObject], ); const handlePointerDown = useCallback( (event: ReactPointerEvent) => { if (event.button !== 0) return; const point = pointFromPointer(event); const hit = hitTest(spatialIndex, point.x, point.y); if (!hit) { selectObject(null); return; } beginDrag(event, hit, "move"); }, [beginDrag, pointFromPointer, selectObject, spatialIndex], ); const handlePointerMove = useCallback( (event: ReactPointerEvent) => { const drag = dragRef.current; if (!drag || drag.pointerId !== event.pointerId || mutationsBlocked) return; const point = pointFromPointer(event); setDraftPosition({ objectId: drag.object.id, position: positionFromDrag(drag, point) }); }, [mutationsBlocked, pointFromPointer], ); const finishDrag = useCallback( (event: ReactPointerEvent, cancel = false) => { const drag = dragRef.current; if ( !drag || drag.pointerId !== event.pointerId || (!cancel && authoringBlockedReason !== undefined) ) return; dragRef.current = null; const captureTarget = overlayRef.current; if (captureTarget?.hasPointerCapture?.(event.pointerId)) { captureTarget.releasePointerCapture(event.pointerId); } setDraftPosition(null); if (cancel) return; const entry = objectById.get(drag.object.id); if (!entry) return; const point = pointFromPointer(event); commitPosition(entry, drag.mode, positionFromDrag(drag, point)); }, [authoringBlockedReason, commitPosition, objectById, pointFromPointer], ); const handleLostPointerCapture = useCallback( (event: ReactPointerEvent) => { const drag = dragRef.current; if (!drag || drag.pointerId !== event.pointerId || authoringBlockedReason !== undefined) return; dragRef.current = null; setDraftPosition(null); }, [authoringBlockedReason], ); const railStart = Math.max(0, Math.floor(rail.scrollTop / RAIL_ITEM_HEIGHT) - RAIL_OVERSCAN); const railEnd = Math.min( slides.length, Math.ceil((rail.scrollTop + rail.height) / RAIL_ITEM_HEIGHT) + RAIL_OVERSCAN, ); const mobileRailStart = Math.max( 0, Math.floor(mobileRail.scrollTop / MOBILE_RAIL_ITEM_HEIGHT) - MOBILE_RAIL_OVERSCAN, ); const mobileRailEnd = Math.min( slides.length, Math.ceil((mobileRail.scrollTop + mobileRail.height) / MOBILE_RAIL_ITEM_HEIGHT) + MOBILE_RAIL_OVERSCAN, ); const slideCountLabel = activeIndex >= 0 ? `${activeIndex + 1} / ${slides.length}` : `0 / ${slides.length}`; const stageWidth = presentation.slideSize.width * zoom; const stageHeight = presentation.slideSize.height * zoom; return (
0 || authoringBlockedReason === "prior_writer" ? "pending" : "idle" } aria-busy={ pendingCommands > 0 || authoringBlockedReason === "prior_writer" ? "true" : undefined } >
{slideCountLabel} {Math.round(zoom * 100)}%
0 ? t("artifacts.presentationEditor.saving") : mutationsBlocked ? t("artifacts.presentationEditor.viewOnly") : selected ? `${objectLabel(t, selected)}${selected.readOnly ? " · View only" : ""}` : t("artifacts.presentationEditor.selectAnObjectToEdit") } > {commandFailure ? t("artifacts.presentationEditor.changeNotSaved") : authoringStatus ? authoringStatus : pendingCommands > 0 ? t("artifacts.presentationEditor.saving") : mutationsBlocked ? t("artifacts.presentationEditor.viewOnly") : selected ? `${objectLabel(t, selected)}${selected.readOnly ? " · View only" : ""}` : t("artifacts.presentationEditor.selectAnObjectToEdit")}
{commandFailure?.message ?? authoringStatus ?? (pendingCommands > 0 ? t("artifacts.presentationEditor.savingPresentationChanges") : "")}
= railStart && activeIndex < railEnd ? `${domId}-slide-${safeDomId(activeSlide.id)}` : undefined } className="relative hidden w-40 shrink-0 overflow-y-auto border-r border-og-border bg-og-surface-2/70 outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-og-accent sm:block" data-og-slide-rail data-og-window-start={railStart} data-og-window-end={railEnd} onScroll={(event) => { const scrollTop = event.currentTarget.scrollTop; const height = event.currentTarget.clientHeight; setRail((current) => ({ scrollTop, height: height || current.height, })); }} onKeyDown={(event) => { const nextIndex = nextSlideIndexFromKey(event.key, activeIndex, slides.length); if (nextIndex === null) return; event.preventDefault(); const next = slides[nextIndex]; if (next && next !== activeSlide) selectSlide(next, nextIndex); }} >
{slides.slice(railStart, railEnd).map((slide, offset) => { const index = railStart + offset; const active = slide === activeSlide; return ( ); })}
{mobileSelectorOpen ? (
= mobileRailStart && activeIndex < mobileRailEnd ? `${domId}-mobile-slide-${safeDomId(activeSlide.id)}` : undefined } className="max-h-72 overflow-y-auto outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-og-accent" data-og-mobile-slide-list data-og-window-start={mobileRailStart} data-og-window-end={mobileRailEnd} onScroll={(event) => { const scrollTop = event.currentTarget.scrollTop; const height = event.currentTarget.clientHeight; setMobileRail((current) => ({ scrollTop, height: height || current.height, })); }} onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); setMobileSelectorOpen(false); mobileSelectorTriggerRef.current?.focus(); return; } const nextIndex = nextSlideIndexFromKey(event.key, activeIndex, slides.length); if (nextIndex === null) return; event.preventDefault(); const next = slides[nextIndex]; if (!next) return; if (next !== activeSlide) selectSlide(next, nextIndex); const list = event.currentTarget; const top = nextIndex * MOBILE_RAIL_ITEM_HEIGHT; const bottom = top + MOBILE_RAIL_ITEM_HEIGHT; if (top < list.scrollTop) list.scrollTop = top; else if (bottom > list.scrollTop + list.clientHeight) { list.scrollTop = bottom - list.clientHeight; } }} >
{slides.slice(mobileRailStart, mobileRailEnd).map((slide, offset) => { const index = mobileRailStart + offset; const active = slide === activeSlide; return ( ); })}
) : null}
{ const element = event.currentTarget; const width = element.clientWidth || viewport.width; const height = element.clientHeight || viewport.height; const next: ViewportMetrics = { scrollLeft: element.scrollLeft, scrollTop: element.scrollTop, width, height, stageLeft: stageRef.current?.offsetLeft ?? 0, stageTop: stageRef.current?.offsetTop ?? 0, }; setViewport((current) => (sameViewport(current, next) ? current : next)); if (element.clientWidth > 0 && element.clientHeight > 0) setViewportMeasured(true); }} data-og-presentation-viewport > {commandFailure ? (
{commandFailure.message}
) : null} {activeSlide ? (
finishDrag(event)} onPointerCancel={(event) => finishDrag(event, true)} onLostPointerCapture={handleLostPointerCapture} onDoubleClick={() => startTextEdit(selected)} > {selected && effectiveSelectedPosition ? ( {!mutationsBlocked && !selected.readOnly ? ( beginDrag(event, selected, "resize")} /> ) : null} ) : null} {selected ? ( {accessibleObjectLabel(t, selected)} ) : null} {t("artifacts.presentationEditor.useBracketKeysToSelect")} {textEdit && selected?.id === textEdit.shape.id && effectiveSelectedPosition ? (