Full document, spreadsheet, slideshow, and diagram tooling
1/**
2 * Slides Canvas Engine — fixed-ratio slide rendering and element layout.
3 *
4 * Pure logic module: slide model, element positioning, layer ordering.
5 * DOM/canvas rendering handled by the slides UI layer.
6 */
7
8export type ElementType = 'text' | 'image' | 'shape' | 'code' | 'chart' | 'embed' | 'table' | 'video' | 'audio';
9export type ShapeType = 'rectangle' | 'ellipse' | 'triangle' | 'arrow' | 'line';
10
11export interface SlideElement {
12 id: string;
13 type: ElementType;
14 x: number;
15 y: number;
16 width: number;
17 height: number;
18 rotation: number;
19 /** Layer order (higher = on top) */
20 zIndex: number;
21 /** Element-specific content */
22 content: string;
23 /** For shapes */
24 shapeType?: ShapeType;
25 /** Style properties */
26 style: Record<string, string>;
27}
28
29export interface Slide {
30 id: string;
31 elements: SlideElement[];
32 background: string;
33 notes: string;
34}
35
36export interface DeckState {
37 slides: Slide[];
38 currentSlide: number;
39 /** Fixed aspect ratio (width/height) */
40 aspectRatio: number;
41 /** Custom master slide definitions stored with deck (optional) */
42 masters?: import('./master-slides.js').MasterSlide[];
43 /** Maps slide ID to master ID (optional) */
44 masterAssignments?: Record<string, string>;
45}
46
47let _slideCounter = 0;
48let _elementCounter = 0;
49
50export const DEFAULT_ASPECT_RATIO = 16 / 9;
51export const SLIDE_WIDTH = 960;
52export const SLIDE_HEIGHT = 540;
53
54/**
55 * Create an empty deck.
56 */
57export function createDeck(): DeckState {
58 const slide = createSlide();
59 return { slides: [slide], currentSlide: 0, aspectRatio: DEFAULT_ASPECT_RATIO };
60}
61
62/**
63 * Create a new slide.
64 */
65export function createSlide(background = '#ffffff'): Slide {
66 return {
67 id: `slide-${Date.now()}-${++_slideCounter}`,
68 elements: [],
69 background,
70 notes: '',
71 };
72}
73
74/**
75 * Add a slide at a position.
76 */
77export function addSlide(
78 state: DeckState,
79 index?: number,
80 background?: string,
81): DeckState {
82 const slide = createSlide(background);
83 const slides = [...state.slides];
84 const pos = index ?? slides.length;
85 slides.splice(pos, 0, slide);
86 return { ...state, slides };
87}
88
89/**
90 * Remove a slide.
91 */
92export function removeSlide(state: DeckState, index: number): DeckState {
93 if (state.slides.length <= 1) return state;
94 const slides = state.slides.filter((_, i) => i !== index);
95 const currentSlide = Math.min(state.currentSlide, slides.length - 1);
96 return { ...state, slides, currentSlide };
97}
98
99/**
100 * Move a slide from one position to another.
101 */
102export function moveSlide(state: DeckState, from: number, to: number): DeckState {
103 const slides = [...state.slides];
104 const [moved] = slides.splice(from, 1);
105 slides.splice(to, 0, moved);
106 return { ...state, slides };
107}
108
109/**
110 * Duplicate a slide.
111 */
112export function duplicateSlide(state: DeckState, index: number): DeckState {
113 const original = state.slides[index];
114 if (!original) return state;
115 const copy: Slide = {
116 ...createSlide(original.background),
117 elements: original.elements.map(e => ({ ...e, id: `el-${Date.now()}-${++_elementCounter}` })),
118 notes: original.notes,
119 };
120 const slides = [...state.slides];
121 slides.splice(index + 1, 0, copy);
122 return { ...state, slides };
123}
124
125/**
126 * Go to a specific slide.
127 */
128export function goToSlide(state: DeckState, index: number): DeckState {
129 const clamped = Math.max(0, Math.min(index, state.slides.length - 1));
130 return { ...state, currentSlide: clamped };
131}
132
133/**
134 * Add an element to the current slide.
135 */
136export function addElement(
137 state: DeckState,
138 type: ElementType,
139 x: number,
140 y: number,
141 width: number,
142 height: number,
143 content = '',
144 style: Record<string, string> = {},
145): DeckState {
146 const element: SlideElement = {
147 id: `el-${Date.now()}-${++_elementCounter}`,
148 type,
149 x, y, width, height,
150 rotation: 0,
151 zIndex: currentSlide(state).elements.length,
152 content,
153 style,
154 };
155 return updateCurrentSlide(state, slide => ({
156 ...slide,
157 elements: [...slide.elements, element],
158 }));
159}
160
161/**
162 * Remove an element from the current slide.
163 */
164export function removeElement(state: DeckState, elementId: string): DeckState {
165 return updateCurrentSlide(state, slide => ({
166 ...slide,
167 elements: slide.elements.filter(e => e.id !== elementId),
168 }));
169}
170
171/**
172 * Move an element.
173 */
174export function moveElement(
175 state: DeckState,
176 elementId: string,
177 x: number,
178 y: number,
179): DeckState {
180 return updateElement(state, elementId, { x, y });
181}
182
183/**
184 * Resize an element.
185 */
186export function resizeElement(
187 state: DeckState,
188 elementId: string,
189 width: number,
190 height: number,
191): DeckState {
192 return updateElement(state, elementId, {
193 width: Math.max(10, width),
194 height: Math.max(10, height),
195 });
196}
197
198/**
199 * Bring an element to front.
200 */
201export function bringToFront(state: DeckState, elementId: string): DeckState {
202 const slide = currentSlide(state);
203 const others = slide.elements.filter(e => e.id !== elementId);
204 const maxZ = others.length > 0 ? Math.max(...others.map(e => e.zIndex)) : 0;
205 return updateElement(state, elementId, { zIndex: maxZ + 1 });
206}
207
208/**
209 * Send an element to back.
210 */
211export function sendToBack(state: DeckState, elementId: string): DeckState {
212 const slide = currentSlide(state);
213 const others = slide.elements.filter(e => e.id !== elementId);
214 const minZ = others.length > 0 ? Math.min(...others.map(e => e.zIndex)) : 0;
215 return updateElement(state, elementId, { zIndex: minZ - 1 });
216}
217
218/**
219 * Get the current slide.
220 */
221export function currentSlide(state: DeckState): Slide {
222 return state.slides[state.currentSlide];
223}
224
225/**
226 * Get slide count.
227 */
228export function slideCount(state: DeckState): number {
229 return state.slides.length;
230}
231
232/**
233 * Get element count on current slide.
234 */
235export function elementCount(state: DeckState): number {
236 return currentSlide(state).elements.length;
237}
238
239/** Internal: update an element's properties */
240function updateElement(
241 state: DeckState,
242 elementId: string,
243 updates: Partial<SlideElement>,
244): DeckState {
245 return updateCurrentSlide(state, slide => ({
246 ...slide,
247 elements: slide.elements.map(e =>
248 e.id === elementId ? { ...e, ...updates } : e,
249 ),
250 }));
251}
252
253/** Internal: update the current slide */
254function updateCurrentSlide(
255 state: DeckState,
256 updater: (slide: Slide) => Slide,
257): DeckState {
258 const slides = state.slides.map((s, i) =>
259 i === state.currentSlide ? updater(s) : s,
260 );
261 return { ...state, slides };
262}