import { current, produce } from 'immer'; import type { SlideContent } from '@/lib/types/stage'; import type { PPTElement, Slide } from '@openmaic/dsl'; import { getElementListRange } from '@/lib/utils/element'; type ElementPatch = Partial; type ElementPropName = string; // Cap undo history so long editing sessions don't grow memory unbounded. export const MAX_HISTORY = 50; export type SlideElementAlignCommand = | 'top' | 'bottom' | 'left' | 'right' | 'vertical' | 'horizontal' | 'center'; // slide.update is for slide metadata only (theme, background, viewport, etc). // Element and animation collections must be mutated through their dedicated // ops so undo/redo, serialization, and (future) PPTX round-trip stay coherent. export type SlideMetaPatch = Partial>; export type SlideEditOperation = | { type: 'slide.update'; patch: SlideMetaPatch; } | { type: 'element.add'; element: PPTElement; index?: number; } | { type: 'element.update'; elementId: string; patch: ElementPatch; } | { type: 'element.updateMany'; elementIds: string[]; patch: ElementPatch; } | { type: 'element.delete'; elementId: string; } | { type: 'element.deleteMany'; elementIds: string[]; } | { type: 'element.reorder'; elementId: string; index: number; } | { type: 'element.duplicate'; elementIds: string[]; idMap: Record; offset?: { x: number; y: number; }; } | { type: 'element.align'; elementIds: string[]; command: SlideElementAlignCommand; } | { type: 'element.removeProps'; elementId: string; propNames: ElementPropName[]; } | { type: 'text.updateContent'; elementId: string; content: string; }; export interface SlideEditHistory { past: SlideContent[]; present: SlideContent; future: SlideContent[]; } export function createSlideEditHistory(initial: SlideContent): SlideEditHistory { return { past: [], // Defensive clone: initial comes from outside immer, so the caller could // still mutate it after construction. Internal history snapshots are // immer-produced and already frozen, so we never re-clone them. present: cloneSlideContent(initial), future: [], }; } export function applySlideEditOperation( content: SlideContent, operation: SlideEditOperation, ): SlideContent; export function applySlideEditOperation( history: SlideEditHistory, operation: SlideEditOperation, ): SlideEditHistory; export function applySlideEditOperation( target: SlideContent | SlideEditHistory, operation: SlideEditOperation, ): SlideContent | SlideEditHistory { if (isSlideEditHistory(target)) { const next = applyOperationToContent(target.present, operation); // immer's produce returns the same reference when the recipe didn't // mutate the draft (e.g. element.update against a missing id). Skip the // history push so undo doesn't replay empty steps. if (next === target.present) return target; return { past: capHistory([...target.past, target.present]), present: next, future: [], }; } return applyOperationToContent(target, operation); } export function undoSlideEditOperation(history: SlideEditHistory): SlideEditHistory { if (history.past.length === 0) return history; const previous = history.past[history.past.length - 1]; return { past: history.past.slice(0, -1), present: previous, future: [history.present, ...history.future], }; } export function redoSlideEditOperation(history: SlideEditHistory): SlideEditHistory { if (history.future.length === 0) return history; const next = history.future[0]; return { past: capHistory([...history.past, history.present]), present: next, future: history.future.slice(1), }; } function applyOperationToContent( content: SlideContent, operation: SlideEditOperation, ): SlideContent { return produce(content, (draft) => { switch (operation.type) { case 'slide.update': { // Type-level narrowing via SlideMetaPatch already forbids elements / // animations, but a runtime guard closes the `as any` escape hatch // at call sites. Those collections must go through their dedicated // ops so undo/redo / serialization stays single-source. if ('elements' in operation.patch || 'animations' in operation.patch) { throw new Error( 'slide.update: use dedicated element / animation ops to mutate those collections', ); } Object.assign(draft.canvas, operation.patch); return; } case 'element.add': { if (draft.canvas.elements.some((el) => el.id === operation.element.id)) { throw new Error(`element.add: id "${operation.element.id}" already exists`); } const index = typeof operation.index === 'number' ? Math.max(0, Math.min(operation.index, draft.canvas.elements.length)) : draft.canvas.elements.length; draft.canvas.elements.splice(index, 0, cloneElement(operation.element)); return; } case 'element.update': { const element = draft.canvas.elements.find((item) => item.id === operation.elementId); if (!element) return; Object.assign(element, operation.patch); return; } case 'element.updateMany': { const elementIds = new Set(operation.elementIds); draft.canvas.elements.forEach((element) => { if (elementIds.has(element.id)) Object.assign(element, operation.patch); }); return; } case 'element.delete': { // Pre-check so deleting a missing id is a real no-op (same content ref) // — without this, the unconditional .filter assignment would always // count as a mutation and bloat undo history with empty steps. if (!draft.canvas.elements.some((el) => el.id === operation.elementId)) return; draft.canvas.elements = draft.canvas.elements.filter( (element) => element.id !== operation.elementId, ); if (draft.canvas.animations) { draft.canvas.animations = draft.canvas.animations.filter( (animation) => animation.elId !== operation.elementId, ); } return; } case 'element.deleteMany': { const elementIds = new Set(operation.elementIds); if (!draft.canvas.elements.some((el) => elementIds.has(el.id))) return; draft.canvas.elements = draft.canvas.elements.filter( (element) => !elementIds.has(element.id), ); if (draft.canvas.animations) { draft.canvas.animations = draft.canvas.animations.filter( (animation) => !elementIds.has(animation.elId), ); } return; } case 'element.reorder': { const currentIndex = draft.canvas.elements.findIndex( (element) => element.id === operation.elementId, ); if (currentIndex === -1) return; const [element] = draft.canvas.elements.splice(currentIndex, 1); const nextIndex = Math.max(0, Math.min(operation.index, draft.canvas.elements.length)); draft.canvas.elements.splice(nextIndex, 0, element); return; } case 'element.duplicate': { const missing = operation.elementIds.filter((id) => !operation.idMap[id]); if (missing.length > 0) { throw new Error(`element.duplicate: idMap missing entries for [${missing.join(', ')}]`); } const existing = new Set(draft.canvas.elements.map((el) => el.id)); const collisions = operation.elementIds .map((id) => operation.idMap[id]) .filter((newId) => existing.has(newId)); if (collisions.length > 0) { throw new Error( `element.duplicate: new ids collide with existing elements: [${collisions.join(', ')}]`, ); } const offset = operation.offset ?? { x: 20, y: 20 }; const elementIds = new Set(operation.elementIds); const duplicatedElements = draft.canvas.elements .filter((element) => elementIds.has(element.id)) .map((element) => { // Deep clone via current() + structuredClone so the duplicate // doesn't share nested references (start/end tuples, outline, // points, etc) with the source. immer's COW would handle most // mutations safely, but future ops that operate on nested // arrays in-place (sort/reverse/splice) would silently leak — // keep the kernel's invariants independent of which mutation // shape future op consumers pick. const source = structuredClone(current(element)) as PPTElement; return { ...source, id: operation.idMap[source.id], left: source.left + offset.x, top: source.top + offset.y, }; }); draft.canvas.elements.push(...duplicatedElements); return; } case 'element.align': { alignElementsToCanvas(draft.canvas, operation.elementIds, operation.command); return; } case 'element.removeProps': { const element = draft.canvas.elements.find((item) => item.id === operation.elementId); if (!element) return; operation.propNames.forEach((propName) => { delete (element as Record)[propName]; }); return; } case 'text.updateContent': { const element = draft.canvas.elements.find((item) => item.id === operation.elementId); if (!element || element.type !== 'text') return; element.content = operation.content; return; } } }); } function isSlideEditHistory(target: SlideContent | SlideEditHistory): target is SlideEditHistory { return 'present' in target && 'past' in target && 'future' in target; } function cloneSlideContent(content: SlideContent): SlideContent { return structuredClone(content); } function cloneElement(element: PPTElement): PPTElement { return structuredClone(element); } function capHistory(past: SlideContent[]): SlideContent[] { return past.length > MAX_HISTORY ? past.slice(past.length - MAX_HISTORY) : past; } function alignElementsToCanvas( slide: Slide, elementIds: string[], command: SlideElementAlignCommand, ) { const selectedIds = new Set(elementIds); const selectedElements = slide.elements.filter((element) => selectedIds.has(element.id)); if (selectedElements.length === 0) return; // Reuse the canonical geometry helper so line/rotated elements compute the // right bounding box. The local fork that lived here treated lines as // height 0 and ignored rotation. const range = getElementListRange(selectedElements); const viewportWidth = slide.viewportSize; const viewportHeight = slide.viewportSize * slide.viewportRatio; let offsetX = 0; let offsetY = 0; switch (command) { case 'center': offsetX = range.minX + (range.maxX - range.minX) / 2 - viewportWidth / 2; offsetY = range.minY + (range.maxY - range.minY) / 2 - viewportHeight / 2; break; case 'top': offsetY = range.minY; break; case 'vertical': offsetY = range.minY + (range.maxY - range.minY) / 2 - viewportHeight / 2; break; case 'bottom': offsetY = range.maxY - viewportHeight; break; case 'left': offsetX = range.minX; break; case 'horizontal': offsetX = range.minX + (range.maxX - range.minX) / 2 - viewportWidth / 2; break; case 'right': offsetX = range.maxX - viewportWidth; break; } slide.elements.forEach((element) => { if (!selectedIds.has(element.id)) return; element.left -= offsetX; element.top -= offsetY; }); }