396 lines
16 KiB
TypeScript
396 lines
16 KiB
TypeScript
'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, unknown>) => 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<SlideContent, SlideSelection> {
|
||
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<SceneDataController>(
|
||
() => ({
|
||
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]);
|
||
}
|