'use client'; import { produce } from 'immer'; import { Image as ImageIcon, PaintBucket, Type } from 'lucide-react'; import React, { useEffect, useLayoutEffect, useMemo, useRef } from 'react'; import type { SceneDataController } from '@/lib/contexts/scene-context'; import type { InsertPaletteItem, SurfaceState } from '@/lib/edit/scene-editor-surface'; import { useI18n } from '@/lib/hooks/use-i18n'; import { createElementId } from '@/lib/edit/element-id'; import { createDefaultChartElement, createDefaultImageElement, createDefaultSlide, createDefaultTableElement, } from '@/lib/edit/slide-edit-elements'; import { defaultRichTextAttrs } from '@/lib/prosemirror/utils'; import { useCanvasStore } from '@/lib/store/canvas'; import { useStageStore } from '@/lib/store/stage'; import type { SlideContent } from '@/lib/types/stage'; import type { ChartType, PPTElement, PPTImageElement, SlideBackground } from '@openmaic/dsl'; import { ImagePicker } from './ImagePicker'; import { BackgroundControl } from './BackgroundControl'; import { useSlideEditSession } from './slide-edit-session'; import { resolveEditingElementId, resolveSelectedElement } from './editing-state'; import { isEditorRendererEnabled } from '@/lib/config/feature-flags'; export interface SlideSelection { readonly activeElementIds: readonly string[]; } export function buildInsertItems( t: (k: string, options?: Record) => string, // The currently-armed creating type, or undefined when nothing is armed. The // text item toggles `creatingElement` (no auto-insert): the active canvas // then captures the next click/drag and creates a text box at that rect. creatingType?: string, ): InsertPaletteItem[] { const armText = () => { const cs = useCanvasStore.getState(); cs.setCreatingElement(creatingType === 'text' ? null : { type: 'text' }); }; const items: InsertPaletteItem[] = [ { id: 'insert-text', label: t('edit.insert.textBox'), tooltip: t('edit.insert.textBox'), icon: React.createElement(Type, { className: 'h-4 w-4' }), active: creatingType === 'text', onInvoke: armText, }, { id: 'insert-image', label: t('edit.insert.image'), tooltip: t('edit.insert.image'), icon: React.createElement(ImageIcon, { className: 'h-4 w-4' }), onInvoke: () => {}, // popover-only: CommandBar's InsertButton ignores onInvoke when popoverContent is set popoverContent: () => React.createElement(ImagePicker, { onPick: insertImageElement, }), }, ]; items.push({ // Slide-level (not element-anchored): set the slide background. Rides the // always-visible insert strip so it stays reachable with nothing selected. id: 'slide-background', label: t('edit.background.label'), tooltip: t('edit.background.label'), icon: React.createElement(PaintBucket, { className: 'h-4 w-4' }), onInvoke: () => {}, // popover-only: see insert-image above popoverContent: () => React.createElement(BackgroundControl), }); return items; } // Default insertion size for an image whose natural dimensions are unknown // (e.g. the URL fails to load). Larger sizes get scaled to fit under MAX_W / // MAX_H while preserving the natural aspect ratio. const IMAGE_MAX_W = 600; const IMAGE_MAX_H = 400; /** * Insert an image element, sized to preserve the source's natural aspect * ratio (scaled down to fit MAX_W × MAX_H, never upscaled). The op is * dispatched on `Image` load; if the source fails to load, we still insert * at the factory's hardcoded default so the user sees something. */ export function insertImageElement(src: string): void { const id = createElementId('image'); // Bind the insert to the scene that was active at click time. Image // sizing is resolved asynchronously (Image.onload), and the user may // switch slides before it resolves — without this guard the element // would be applied to whatever session is current when onload fires, // i.e. inserted into the wrong slide. const targetSceneId = useSlideEditSession.getState().sceneId; const dispatch = (width?: number, height?: number) => { if (useSlideEditSession.getState().sceneId !== targetSceneId) return; const base = createDefaultImageElement(id, src); const element = width && height ? { ...base, width, height } : base; useSlideEditSession.getState().applyOp({ type: 'element.add', element }); }; if (typeof window === 'undefined') { dispatch(); return; } const img = new window.Image(); img.onload = () => { const ratio = img.naturalWidth / img.naturalHeight; let width = img.naturalWidth; let height = img.naturalHeight; if (width > IMAGE_MAX_W) { width = IMAGE_MAX_W; height = width / ratio; } if (height > IMAGE_MAX_H) { height = IMAGE_MAX_H; width = height * ratio; } dispatch(Math.round(width), Math.round(height)); }; img.onerror = () => dispatch(); img.src = src; } /** Insert an empty table and select it through the normal slide edit session. */ export function insertTableElement(rows: number, columns: number): void { const id = createElementId('table'); const element = createDefaultTableElement(id, rows, columns); useSlideEditSession.getState().applyOp({ type: 'element.add', element }); useCanvasStore.getState().setActiveElementIdList([id]); } /** Insert a chart and select it through the normal slide edit session. */ export function insertChartElement(chartType: ChartType): void { const id = createElementId('chart'); const element = createDefaultChartElement(id, chartType); useSlideEditSession.getState().applyOp({ type: 'element.add', element }); useCanvasStore.getState().setActiveElementIdList([id]); } /** Delete a slide element and clear the canvas selection. */ export function deleteSlideElement(elementId: string): void { const session = useSlideEditSession.getState(); session.applyOp({ type: 'element.delete', elementId }); useCanvasStore.getState().setActiveElementIdList([]); } /** * Move an element to the front (top) or back (bottom) of the z-order. * Two-way only — intermediate forward/backward steps stay AI's domain. */ export function reorderSlideElement(elementId: string, edge: 'front' | 'back'): void { const present = useSlideEditSession.getState().history?.present ?? null; if (!present) return; const elements = present.canvas.elements; const currentIndex = elements.findIndex((el) => el.id === elementId); if (currentIndex === -1) return; const index = edge === 'front' ? elements.length - 1 : 0; // Already at the target edge — skip so we don't push an empty undo step. if (currentIndex === index) return; useSlideEditSession.getState().applyOp({ type: 'element.reorder', elementId, index }); } /** * Replace an image element's source. Clears any stale `clip`: the new source's * aspect ratio may differ, so the old crop rect would no longer be meaningful. */ export function replaceImageSrc(elementId: string, src: string): void { useSlideEditSession .getState() .applyOp({ type: 'element.update', elementId, patch: { src, clip: undefined } }); } /** Toggle horizontal/vertical flip on an image element. */ export function toggleImageFlip(el: PPTImageElement, axis: 'H' | 'V'): void { const patch = axis === 'H' ? { flipH: !el.flipH } : { flipV: !el.flipV }; useSlideEditSession.getState().applyOp({ type: 'element.update', elementId: el.id, patch }); } /** Set the slide-level background (solid color or image). */ export function updateSlideBackground(background: SlideBackground): void { useSlideEditSession.getState().applyOp({ type: 'slide.update', patch: { background } }); } const EMPTY_SLIDE: SlideContent = { type: 'slide', canvas: createDefaultSlide('') }; function currentSlideContent(sceneId: string): SlideContent | null { const scene = useStageStore.getState().scenes.find((s) => s.id === sceneId); return scene && scene.type === 'slide' ? (scene.content as SlideContent) : null; } /** * Resolves the slide content the surface should read from: the in-memory * edit-session present, else the canonical stage scene, else an empty slide. */ export function useResolvedSlideContent(): SlideContent { const history = useSlideEditSession((s) => s.history); const sessionSceneId = useSlideEditSession((s) => s.sceneId); return ( history?.present ?? (sessionSceneId ? currentSlideContent(sessionSceneId) : null) ?? EMPTY_SLIDE ); } /** * The slide surface's `useSurfaceState`. Pure read over the shared * session store + the renderer's selection store. */ export function useSlideSurfaceState(): SurfaceState { const { t } = useI18n(); const history = useSlideEditSession((s) => s.history); const activeElementIds = useCanvasStore.use.activeElementIdList(); const creatingElement = useCanvasStore.use.creatingElement(); const rendererEditorEnabled = isEditorRendererEnabled(); const content = useResolvedSlideContent(); return { content, selection: { activeElementIds }, hasSelection: activeElementIds.length > 0, history: { canUndo: !!history && history.past.length > 0, canRedo: !!history && history.future.length > 0, undo: () => useSlideEditSession.getState().undo(), redo: () => useSlideEditSession.getState().redo(), }, insertItems: rendererEditorEnabled ? [] : buildInsertItems(t, creatingElement?.type), // Every element type carries its own actions on a selection-anchored bar // (AnchoredTextBar / AnchoredElementBar) — the surface contributes no // top-center FloatingToolbar actions. floatingActions: [], commands: [], hints: [], }; } interface SlideCanvasController { readonly controller: SceneDataController; /** * Spread onto the canvas wrapper. Tracks whether a pointer gesture is in * flight so a renderer commit can be classified as a real user edit vs * ResizeObserver normalization (which fires with no pointer gesture). */ readonly gestureProps: { readonly onPointerDownCapture: () => void; readonly onPointerUpCapture: () => void; readonly onPointerCancelCapture: () => void; }; } /** * Owns the edit-entry lifecycle for the slide canvas: seeds the in-memory * undo history from the live scene and exposes the scene-context * controller. The controller's writes flow through `slide-edit-session` * which auto-saves them to the canonical `useStageStore` (no staging, no * "restore unsaved" UX — the stage store is the source of truth). */ export function useSlideCanvasController(): SlideCanvasController { const sceneId = useStageStore((s) => { const scene = s.scenes.find((x) => x.id === s.currentSceneId) ?? null; return scene && scene.type === 'slide' ? scene.id : ''; }); // Re-render (and thus re-feed SceneProvider's getSnapshot) on every // history move (apply / commit / undo / redo). useSlideEditSession((s) => s.history); // True only while a pointer gesture is in flight. The renderer commits a // geometry edit synchronously inside its mouseup handler (still within // the gesture); its ResizeObserver text-normalization commits later with // no gesture. Cleared on a macrotask after pointerup so the synchronous // commit still observes `true`. const gestureRef = useRef(false); useEffect(() => { const finishGesture = () => { setTimeout(() => { gestureRef.current = false; useSlideEditSession.getState().setGestureActive(false); }, 0); }; window.addEventListener('pointerup', finishGesture); window.addEventListener('pointercancel', finishGesture); return () => { window.removeEventListener('pointerup', finishGesture); window.removeEventListener('pointercancel', finishGesture); }; }, []); const gestureProps = useMemo( () => ({ onPointerDownCapture: () => { gestureRef.current = true; useSlideEditSession.getState().setGestureActive(true); }, onPointerUpCapture: () => { setTimeout(() => { gestureRef.current = false; useSlideEditSession.getState().setGestureActive(false); }, 0); }, onPointerCancelCapture: () => { setTimeout(() => { gestureRef.current = false; useSlideEditSession.getState().setGestureActive(false); }, 0); }, }), [], ); useEffect(() => { if (!sceneId) return; const content = currentSlideContent(sceneId); if (content && useSlideEditSession.getState().sceneId !== sceneId) { useSlideEditSession.getState().seed(sceneId, content); } }, [sceneId]); useEffect(() => () => useSlideEditSession.getState().end(), []); const controller = useMemo( () => ({ sceneId, sceneType: 'slide', // Read from the canonical stage store; the session writes through to // it on every history move so this is always the up-to-date content. getSnapshot: () => currentSlideContent(sceneId) ?? EMPTY_SLIDE, updateSceneData: (updater) => { const base = useSlideEditSession.getState().history?.present ?? currentSlideContent(sceneId); if (!base) return; const next = produce(base, updater as (draft: SlideContent) => void); useSlideEditSession.getState().commitContent(next, gestureRef.current); }, }), [sceneId], ); return { controller, gestureProps, }; } /** * The id of the text element currently being edited — i.e. the sole selected * element, when it is a text element. "" means "not editing text". Drives both * the AnchoredTextBar and the canvas store's `editingElementId`. */ export function useEditingTextElementId(requestedId?: string): string { const activeElementIds = useCanvasStore.use.activeElementIdList(); const content = useResolvedSlideContent(); return resolveEditingElementId(activeElementIds, content.canvas.elements, requestedId); } /** * The single selected non-text element (image / shape / line / …), or null — * drives the type-aware AnchoredElementBar. Text elements get their own * AnchoredTextBar. Returns the element (not just its id) so the bar can branch * on element type for image-specific controls. */ export function useSelectedNonTextElement(): PPTElement | null { const activeElementIds = useCanvasStore.use.activeElementIdList(); const content = useResolvedSlideContent(); const el = resolveSelectedElement(activeElementIds, content.canvas.elements); return el && el.type !== 'text' ? el : null; } /** * Mirrors the surface's editing-element decision into the canvas store's * `editingElementId` flag, which the renderer's `TextElementOperate` reads. * useLayoutEffect so the renderer suppresses the dashed frame in the same * commit the selection changes — no one-frame flicker. Cleared on unmount. */ export function useSyncEditingElementId(editingElementId: string, enabled = true): void { const setEditingElementId = useCanvasStore.use.setEditingElementId(); const setRichTextAttrs = useCanvasStore.use.setRichtextAttrs(); // Track the previous editing id so we only reset attrs on element-to-element // *transitions*. Resetting on the first selection (or initial mount with a // restored selection) would briefly flash neutral defaults — `color #000`, // `fontsize 16px` — before the focusing ProseMirror repopulates the real // values, which is more jarring than skipping the reset there. const prevEditingElementId = useRef(''); useLayoutEffect(() => { if (!enabled) return; setEditingElementId(editingElementId); if (prevEditingElementId.current && prevEditingElementId.current !== editingElementId) { // `richTextAttrs` is a single shared store updated by whichever // ProseMirror was last focused. Without this reset on switch, the // format bar visibly carries the previous element's toggle states // (B, I, alignment, …) until the new element's ProseMirror takes // focus and writes its own attrs. setRichTextAttrs(defaultRichTextAttrs); } prevEditingElementId.current = editingElementId; return () => setEditingElementId(''); }, [editingElementId, enabled, setEditingElementId, setRichTextAttrs]); }