Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat: whiteboard diagrams, PPTX import/export, button cells (#42, #69, #140)' (#144) from feat/whiteboard-pptx-buttons into main

scott b589a170 fce9f562

+1634
+273
src/diagrams/whiteboard.ts
··· 1 + /** 2 + * Whiteboard & Diagramming — infinite canvas with shape primitives. 3 + * 4 + * Pure logic module: shape model, hit testing, snapping, connections. 5 + * Canvas/SVG rendering handled by the diagrams UI layer. 6 + */ 7 + 8 + export type ShapeKind = 'rectangle' | 'ellipse' | 'diamond' | 'text' | 'freehand'; 9 + export type ArrowEndpoint = { shapeId: string; anchor: 'top' | 'bottom' | 'left' | 'right' | 'center' } | { x: number; y: number }; 10 + 11 + export interface Point { 12 + x: number; 13 + y: number; 14 + } 15 + 16 + export interface Shape { 17 + id: string; 18 + kind: ShapeKind; 19 + x: number; 20 + y: number; 21 + width: number; 22 + height: number; 23 + rotation: number; 24 + label: string; 25 + style: Record<string, string>; 26 + /** Freehand path points (only for freehand shapes) */ 27 + points?: Point[]; 28 + } 29 + 30 + export interface Arrow { 31 + id: string; 32 + from: ArrowEndpoint; 33 + to: ArrowEndpoint; 34 + label: string; 35 + style: Record<string, string>; 36 + } 37 + 38 + export interface WhiteboardState { 39 + shapes: Map<string, Shape>; 40 + arrows: Map<string, Arrow>; 41 + /** Viewport offset */ 42 + panX: number; 43 + panY: number; 44 + zoom: number; 45 + gridSize: number; 46 + snapToGrid: boolean; 47 + } 48 + 49 + let _counter = 0; 50 + 51 + /** 52 + * Create an empty whiteboard. 53 + */ 54 + export function createWhiteboard(gridSize = 20): WhiteboardState { 55 + return { 56 + shapes: new Map(), 57 + arrows: new Map(), 58 + panX: 0, 59 + panY: 0, 60 + zoom: 1, 61 + gridSize, 62 + snapToGrid: true, 63 + }; 64 + } 65 + 66 + /** 67 + * Add a shape to the whiteboard. 68 + */ 69 + export function addShape( 70 + state: WhiteboardState, 71 + kind: ShapeKind, 72 + x: number, 73 + y: number, 74 + width = 120, 75 + height = 80, 76 + label = '', 77 + ): WhiteboardState { 78 + const snapped = state.snapToGrid ? snapPoint(x, y, state.gridSize) : { x, y }; 79 + const shape: Shape = { 80 + id: `shape-${Date.now()}-${++_counter}`, 81 + kind, 82 + x: snapped.x, 83 + y: snapped.y, 84 + width, 85 + height, 86 + rotation: 0, 87 + label, 88 + style: {}, 89 + }; 90 + const shapes = new Map(state.shapes); 91 + shapes.set(shape.id, shape); 92 + return { ...state, shapes }; 93 + } 94 + 95 + /** 96 + * Remove a shape and its connected arrows. 97 + */ 98 + export function removeShape(state: WhiteboardState, shapeId: string): WhiteboardState { 99 + const shapes = new Map(state.shapes); 100 + shapes.delete(shapeId); 101 + 102 + const arrows = new Map(state.arrows); 103 + for (const [id, arrow] of arrows) { 104 + const fromConnected = 'shapeId' in arrow.from && arrow.from.shapeId === shapeId; 105 + const toConnected = 'shapeId' in arrow.to && arrow.to.shapeId === shapeId; 106 + if (fromConnected || toConnected) arrows.delete(id); 107 + } 108 + 109 + return { ...state, shapes, arrows }; 110 + } 111 + 112 + /** 113 + * Move a shape. 114 + */ 115 + export function moveShape( 116 + state: WhiteboardState, 117 + shapeId: string, 118 + x: number, 119 + y: number, 120 + ): WhiteboardState { 121 + const shape = state.shapes.get(shapeId); 122 + if (!shape) return state; 123 + const snapped = state.snapToGrid ? snapPoint(x, y, state.gridSize) : { x, y }; 124 + const shapes = new Map(state.shapes); 125 + shapes.set(shapeId, { ...shape, x: snapped.x, y: snapped.y }); 126 + return { ...state, shapes }; 127 + } 128 + 129 + /** 130 + * Resize a shape. 131 + */ 132 + export function resizeShape( 133 + state: WhiteboardState, 134 + shapeId: string, 135 + width: number, 136 + height: number, 137 + ): WhiteboardState { 138 + const shape = state.shapes.get(shapeId); 139 + if (!shape) return state; 140 + const shapes = new Map(state.shapes); 141 + shapes.set(shapeId, { ...shape, width: Math.max(10, width), height: Math.max(10, height) }); 142 + return { ...state, shapes }; 143 + } 144 + 145 + /** 146 + * Update a shape's label. 147 + */ 148 + export function setShapeLabel( 149 + state: WhiteboardState, 150 + shapeId: string, 151 + label: string, 152 + ): WhiteboardState { 153 + const shape = state.shapes.get(shapeId); 154 + if (!shape) return state; 155 + const shapes = new Map(state.shapes); 156 + shapes.set(shapeId, { ...shape, label }); 157 + return { ...state, shapes }; 158 + } 159 + 160 + /** 161 + * Add an arrow between two endpoints. 162 + */ 163 + export function addArrow( 164 + state: WhiteboardState, 165 + from: ArrowEndpoint, 166 + to: ArrowEndpoint, 167 + label = '', 168 + ): WhiteboardState { 169 + const arrow: Arrow = { 170 + id: `arrow-${Date.now()}-${++_counter}`, 171 + from, 172 + to, 173 + label, 174 + style: {}, 175 + }; 176 + const arrows = new Map(state.arrows); 177 + arrows.set(arrow.id, arrow); 178 + return { ...state, arrows }; 179 + } 180 + 181 + /** 182 + * Remove an arrow. 183 + */ 184 + export function removeArrow(state: WhiteboardState, arrowId: string): WhiteboardState { 185 + const arrows = new Map(state.arrows); 186 + arrows.delete(arrowId); 187 + return { ...state, arrows }; 188 + } 189 + 190 + /** 191 + * Snap a point to the grid. 192 + */ 193 + export function snapPoint(x: number, y: number, gridSize: number): Point { 194 + return { 195 + x: Math.round(x / gridSize) * gridSize, 196 + y: Math.round(y / gridSize) * gridSize, 197 + }; 198 + } 199 + 200 + /** 201 + * Toggle snap-to-grid. 202 + */ 203 + export function toggleSnap(state: WhiteboardState): WhiteboardState { 204 + return { ...state, snapToGrid: !state.snapToGrid }; 205 + } 206 + 207 + /** 208 + * Pan the viewport. 209 + */ 210 + export function pan(state: WhiteboardState, dx: number, dy: number): WhiteboardState { 211 + return { ...state, panX: state.panX + dx, panY: state.panY + dy }; 212 + } 213 + 214 + /** 215 + * Zoom the viewport. 216 + */ 217 + export function setZoom(state: WhiteboardState, zoom: number): WhiteboardState { 218 + return { ...state, zoom: Math.max(0.1, Math.min(5, zoom)) }; 219 + } 220 + 221 + /** 222 + * Hit-test: is a point inside a shape's bounding box? 223 + */ 224 + export function hitTestShape(shape: Shape, px: number, py: number): boolean { 225 + return px >= shape.x && px <= shape.x + shape.width && 226 + py >= shape.y && py <= shape.y + shape.height; 227 + } 228 + 229 + /** 230 + * Find the topmost shape at a point (last added = on top). 231 + */ 232 + export function shapeAtPoint(state: WhiteboardState, px: number, py: number): Shape | null { 233 + let found: Shape | null = null; 234 + for (const shape of state.shapes.values()) { 235 + if (hitTestShape(shape, px, py)) found = shape; 236 + } 237 + return found; 238 + } 239 + 240 + /** 241 + * Get arrows connected to a shape. 242 + */ 243 + export function arrowsForShape(state: WhiteboardState, shapeId: string): Arrow[] { 244 + const result: Arrow[] = []; 245 + for (const arrow of state.arrows.values()) { 246 + const fromConnected = 'shapeId' in arrow.from && arrow.from.shapeId === shapeId; 247 + const toConnected = 'shapeId' in arrow.to && arrow.to.shapeId === shapeId; 248 + if (fromConnected || toConnected) result.push(arrow); 249 + } 250 + return result; 251 + } 252 + 253 + /** 254 + * Get the bounding box of all shapes. 255 + */ 256 + export function getBoundingBox(state: WhiteboardState): { x: number; y: number; width: number; height: number } | null { 257 + if (state.shapes.size === 0) return null; 258 + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; 259 + for (const shape of state.shapes.values()) { 260 + minX = Math.min(minX, shape.x); 261 + minY = Math.min(minY, shape.y); 262 + maxX = Math.max(maxX, shape.x + shape.width); 263 + maxY = Math.max(maxY, shape.y + shape.height); 264 + } 265 + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; 266 + } 267 + 268 + /** 269 + * Get shape and arrow counts. 270 + */ 271 + export function elementCounts(state: WhiteboardState): { shapes: number; arrows: number } { 272 + return { shapes: state.shapes.size, arrows: state.arrows.size }; 273 + }
+280
src/sheets/button-cells.ts
··· 1 + /** 2 + * Button Cells — clickable cells that trigger predefined actions. 3 + * 4 + * Pure logic module: action definitions, validation, execution planning. 5 + * DOM rendering and cell interaction handled by the sheets UI layer. 6 + */ 7 + 8 + export type ButtonAction = 9 + | { type: 'setValue'; targetCell: string; value: string } 10 + | { type: 'runFormula'; targetCell: string; formula: string } 11 + | { type: 'toggleCheckbox'; targetCell: string } 12 + | { type: 'timestamp'; targetCell: string } 13 + | { type: 'clearRange'; startCell: string; endCell: string } 14 + | { type: 'incrementValue'; targetCell: string; amount: number }; 15 + 16 + export interface ButtonConfig { 17 + id: string; 18 + /** Cell ID where the button lives */ 19 + cellId: string; 20 + label: string; 21 + /** Actions to execute in order */ 22 + actions: ButtonAction[]; 23 + /** Optional color theme */ 24 + color: string; 25 + /** Whether confirmation is required before execution */ 26 + requireConfirm: boolean; 27 + } 28 + 29 + export interface ButtonRegistry { 30 + buttons: Map<string, ButtonConfig>; 31 + } 32 + 33 + let _counter = 0; 34 + 35 + /** 36 + * Create an empty button registry. 37 + */ 38 + export function createRegistry(): ButtonRegistry { 39 + return { buttons: new Map() }; 40 + } 41 + 42 + /** 43 + * Register a button on a cell. 44 + */ 45 + export function registerButton( 46 + registry: ButtonRegistry, 47 + cellId: string, 48 + label: string, 49 + actions: ButtonAction[], 50 + color = '#0066cc', 51 + requireConfirm = false, 52 + ): ButtonRegistry { 53 + const config: ButtonConfig = { 54 + id: `btn-${Date.now()}-${++_counter}`, 55 + cellId, 56 + label, 57 + actions, 58 + color, 59 + requireConfirm, 60 + }; 61 + const buttons = new Map(registry.buttons); 62 + buttons.set(config.id, config); 63 + return { buttons }; 64 + } 65 + 66 + /** 67 + * Remove a button. 68 + */ 69 + export function removeButton(registry: ButtonRegistry, buttonId: string): ButtonRegistry { 70 + const buttons = new Map(registry.buttons); 71 + buttons.delete(buttonId); 72 + return { buttons }; 73 + } 74 + 75 + /** 76 + * Get the button for a specific cell (if any). 77 + */ 78 + export function buttonForCell(registry: ButtonRegistry, cellId: string): ButtonConfig | undefined { 79 + for (const btn of registry.buttons.values()) { 80 + if (btn.cellId === cellId) return btn; 81 + } 82 + return undefined; 83 + } 84 + 85 + /** 86 + * Check if a cell has a button. 87 + */ 88 + export function isButtonCell(registry: ButtonRegistry, cellId: string): boolean { 89 + return buttonForCell(registry, cellId) !== undefined; 90 + } 91 + 92 + /** 93 + * Validate a button action. 94 + */ 95 + export function validateAction(action: ButtonAction): { valid: boolean; error: string | null } { 96 + switch (action.type) { 97 + case 'setValue': 98 + if (!action.targetCell) return { valid: false, error: 'Target cell required' }; 99 + if (!isValidCellId(action.targetCell)) return { valid: false, error: 'Invalid target cell' }; 100 + return { valid: true, error: null }; 101 + 102 + case 'runFormula': 103 + if (!action.targetCell) return { valid: false, error: 'Target cell required' }; 104 + if (!action.formula.startsWith('=')) return { valid: false, error: 'Formula must start with =' }; 105 + return { valid: true, error: null }; 106 + 107 + case 'toggleCheckbox': 108 + if (!action.targetCell) return { valid: false, error: 'Target cell required' }; 109 + return { valid: true, error: null }; 110 + 111 + case 'timestamp': 112 + if (!action.targetCell) return { valid: false, error: 'Target cell required' }; 113 + return { valid: true, error: null }; 114 + 115 + case 'clearRange': 116 + if (!action.startCell || !action.endCell) return { valid: false, error: 'Range cells required' }; 117 + if (!isValidCellId(action.startCell) || !isValidCellId(action.endCell)) { 118 + return { valid: false, error: 'Invalid range cells' }; 119 + } 120 + return { valid: true, error: null }; 121 + 122 + case 'incrementValue': 123 + if (!action.targetCell) return { valid: false, error: 'Target cell required' }; 124 + if (action.amount === 0) return { valid: false, error: 'Increment amount cannot be 0' }; 125 + return { valid: true, error: null }; 126 + 127 + default: 128 + return { valid: false, error: 'Unknown action type' }; 129 + } 130 + } 131 + 132 + /** 133 + * Validate all actions in a button config. 134 + */ 135 + export function validateButton(config: ButtonConfig): { valid: boolean; errors: string[] } { 136 + const errors: string[] = []; 137 + if (!config.label) errors.push('Button label is required'); 138 + if (config.actions.length === 0) errors.push('At least one action is required'); 139 + for (let i = 0; i < config.actions.length; i++) { 140 + const result = validateAction(config.actions[i]); 141 + if (!result.valid) errors.push(`Action ${i + 1}: ${result.error}`); 142 + } 143 + return { valid: errors.length === 0, errors }; 144 + } 145 + 146 + /** 147 + * Plan the execution of a button's actions. 148 + * Returns the list of cell writes that would result. 149 + */ 150 + export function planExecution( 151 + config: ButtonConfig, 152 + getCellValue: (cellId: string) => string, 153 + ): Array<{ cellId: string; value: string }> { 154 + const writes: Array<{ cellId: string; value: string }> = []; 155 + 156 + for (const action of config.actions) { 157 + switch (action.type) { 158 + case 'setValue': 159 + writes.push({ cellId: action.targetCell, value: action.value }); 160 + break; 161 + 162 + case 'runFormula': 163 + writes.push({ cellId: action.targetCell, value: action.formula }); 164 + break; 165 + 166 + case 'toggleCheckbox': { 167 + const current = getCellValue(action.targetCell); 168 + const toggled = current === 'true' ? 'false' : 'true'; 169 + writes.push({ cellId: action.targetCell, value: toggled }); 170 + break; 171 + } 172 + 173 + case 'timestamp': 174 + writes.push({ cellId: action.targetCell, value: new Date().toISOString() }); 175 + break; 176 + 177 + case 'clearRange': { 178 + const cells = expandRange(action.startCell, action.endCell); 179 + for (const cellId of cells) { 180 + writes.push({ cellId, value: '' }); 181 + } 182 + break; 183 + } 184 + 185 + case 'incrementValue': { 186 + const current = parseFloat(getCellValue(action.targetCell)) || 0; 187 + writes.push({ cellId: action.targetCell, value: String(current + action.amount) }); 188 + break; 189 + } 190 + } 191 + } 192 + 193 + return writes; 194 + } 195 + 196 + /** 197 + * Get all cells that a button's actions would affect. 198 + */ 199 + export function affectedCells(config: ButtonConfig): string[] { 200 + const cells = new Set<string>(); 201 + for (const action of config.actions) { 202 + if ('targetCell' in action) cells.add(action.targetCell); 203 + if (action.type === 'clearRange') { 204 + const range = expandRange(action.startCell, action.endCell); 205 + for (const c of range) cells.add(c); 206 + } 207 + } 208 + return Array.from(cells); 209 + } 210 + 211 + /** 212 + * Count buttons in the registry. 213 + */ 214 + export function buttonCount(registry: ButtonRegistry): number { 215 + return registry.buttons.size; 216 + } 217 + 218 + /** 219 + * Get all button configs as an array. 220 + */ 221 + export function allButtons(registry: ButtonRegistry): ButtonConfig[] { 222 + return Array.from(registry.buttons.values()); 223 + } 224 + 225 + /** 226 + * Update a button's label. 227 + */ 228 + export function updateButtonLabel( 229 + registry: ButtonRegistry, 230 + buttonId: string, 231 + label: string, 232 + ): ButtonRegistry { 233 + const btn = registry.buttons.get(buttonId); 234 + if (!btn) return registry; 235 + const buttons = new Map(registry.buttons); 236 + buttons.set(buttonId, { ...btn, label }); 237 + return { buttons }; 238 + } 239 + 240 + /** Check if a string is a valid cell ID (e.g., A1, BC123) */ 241 + function isValidCellId(cellId: string): boolean { 242 + return /^[A-Z]+\d+$/.test(cellId); 243 + } 244 + 245 + /** Expand a range like A1:C3 into individual cell IDs */ 246 + function expandRange(start: string, end: string): string[] { 247 + const startCol = start.replace(/\d+/g, ''); 248 + const startRow = parseInt(start.replace(/[A-Z]+/g, ''), 10); 249 + const endCol = end.replace(/\d+/g, ''); 250 + const endRow = parseInt(end.replace(/[A-Z]+/g, ''), 10); 251 + 252 + const startColNum = colToNumber(startCol); 253 + const endColNum = colToNumber(endCol); 254 + 255 + const cells: string[] = []; 256 + for (let c = startColNum; c <= endColNum; c++) { 257 + for (let r = startRow; r <= endRow; r++) { 258 + cells.push(`${numberToCol(c)}${r}`); 259 + } 260 + } 261 + return cells; 262 + } 263 + 264 + function colToNumber(col: string): number { 265 + let n = 0; 266 + for (let i = 0; i < col.length; i++) { 267 + n = n * 26 + (col.charCodeAt(i) - 64); 268 + } 269 + return n; 270 + } 271 + 272 + function numberToCol(n: number): string { 273 + let result = ''; 274 + while (n > 0) { 275 + const rem = (n - 1) % 26; 276 + result = String.fromCharCode(65 + rem) + result; 277 + n = Math.floor((n - 1) / 26); 278 + } 279 + return result; 280 + }
+240
src/slides/pptx-export.ts
··· 1 + /** 2 + * PPTX Import / PDF Export — slide data extraction and conversion. 3 + * 4 + * Pure logic module: data model mapping, element conversion, layout mapping. 5 + * Actual file I/O (pptxgenjs, html2pdf) handled by the slides UI layer. 6 + */ 7 + 8 + export interface ImportedSlide { 9 + index: number; 10 + background: string; 11 + elements: ImportedElement[]; 12 + notes: string; 13 + } 14 + 15 + export interface ImportedElement { 16 + type: 'text' | 'image' | 'shape'; 17 + x: number; 18 + y: number; 19 + width: number; 20 + height: number; 21 + content: string; 22 + style: Record<string, string>; 23 + } 24 + 25 + export interface ExportConfig { 26 + format: 'pdf' | 'pptx' | 'png'; 27 + /** Include speaker notes (PDF only) */ 28 + includeNotes: boolean; 29 + /** Slides to export (null = all) */ 30 + slideRange: [number, number] | null; 31 + /** Quality for image exports (0-1) */ 32 + quality: number; 33 + } 34 + 35 + export interface ExportSlide { 36 + index: number; 37 + background: string; 38 + elements: ExportElement[]; 39 + notes: string; 40 + transition: string; 41 + } 42 + 43 + export interface ExportElement { 44 + type: string; 45 + x: number; 46 + y: number; 47 + width: number; 48 + height: number; 49 + content: string; 50 + style: Record<string, string>; 51 + } 52 + 53 + /** 54 + * Create default export config. 55 + */ 56 + export function createExportConfig(format: ExportConfig['format'] = 'pdf'): ExportConfig { 57 + return { 58 + format, 59 + includeNotes: true, 60 + slideRange: null, 61 + quality: 0.92, 62 + }; 63 + } 64 + 65 + /** 66 + * Validate export config. 67 + */ 68 + export function validateExportConfig(config: ExportConfig): { valid: boolean; errors: string[] } { 69 + const errors: string[] = []; 70 + 71 + if (!['pdf', 'pptx', 'png'].includes(config.format)) { 72 + errors.push(`Invalid format: ${config.format}`); 73 + } 74 + 75 + if (config.quality < 0 || config.quality > 1) { 76 + errors.push('Quality must be between 0 and 1'); 77 + } 78 + 79 + if (config.slideRange) { 80 + if (config.slideRange[0] < 0) errors.push('Slide range start must be >= 0'); 81 + if (config.slideRange[1] < config.slideRange[0]) errors.push('Slide range end must be >= start'); 82 + } 83 + 84 + return { valid: errors.length === 0, errors }; 85 + } 86 + 87 + /** 88 + * Filter slides by export range. 89 + */ 90 + export function filterSlideRange<T extends { index: number }>( 91 + slides: T[], 92 + range: [number, number] | null, 93 + ): T[] { 94 + if (!range) return slides; 95 + return slides.filter(s => s.index >= range[0] && s.index <= range[1]); 96 + } 97 + 98 + /** 99 + * Map imported PPTX element types to our internal types. 100 + */ 101 + export function mapElementType(pptxType: string): ImportedElement['type'] { 102 + const mapping: Record<string, ImportedElement['type']> = { 103 + 'sp': 'shape', 104 + 'pic': 'image', 105 + 'txBody': 'text', 106 + 'graphicFrame': 'shape', 107 + 'cxnSp': 'shape', 108 + 'text': 'text', 109 + 'image': 'image', 110 + 'shape': 'shape', 111 + }; 112 + return mapping[pptxType] ?? 'shape'; 113 + } 114 + 115 + /** 116 + * Convert PPTX EMU (English Metric Units) to pixels. 117 + * 1 inch = 914400 EMU, at 96 DPI → 1 pixel = 9525 EMU. 118 + */ 119 + export function emuToPixels(emu: number): number { 120 + return Math.round(emu / 9525); 121 + } 122 + 123 + /** 124 + * Convert pixels to EMU. 125 + */ 126 + export function pixelsToEmu(px: number): number { 127 + return Math.round(px * 9525); 128 + } 129 + 130 + /** 131 + * Normalize an imported element's position to our slide coordinate system. 132 + * Our slides are 960x540; PPTX is typically 10" x 7.5" (9144000 x 6858000 EMU). 133 + */ 134 + export function normalizePosition( 135 + x: number, 136 + y: number, 137 + width: number, 138 + height: number, 139 + sourceWidth: number, 140 + sourceHeight: number, 141 + targetWidth = 960, 142 + targetHeight = 540, 143 + ): { x: number; y: number; width: number; height: number } { 144 + const scaleX = targetWidth / sourceWidth; 145 + const scaleY = targetHeight / sourceHeight; 146 + return { 147 + x: Math.round(x * scaleX), 148 + y: Math.round(y * scaleY), 149 + width: Math.round(width * scaleX), 150 + height: Math.round(height * scaleY), 151 + }; 152 + } 153 + 154 + /** 155 + * Create an ImportedElement from raw data. 156 + */ 157 + export function createImportedElement( 158 + type: string, 159 + x: number, 160 + y: number, 161 + width: number, 162 + height: number, 163 + content: string, 164 + ): ImportedElement { 165 + return { 166 + type: mapElementType(type), 167 + x, y, width, height, 168 + content, 169 + style: {}, 170 + }; 171 + } 172 + 173 + /** 174 + * Create an ImportedSlide. 175 + */ 176 + export function createImportedSlide( 177 + index: number, 178 + elements: ImportedElement[] = [], 179 + background = '#ffffff', 180 + notes = '', 181 + ): ImportedSlide { 182 + return { index, background, elements, notes }; 183 + } 184 + 185 + /** 186 + * Count total elements across imported slides. 187 + */ 188 + export function totalImportedElements(slides: ImportedSlide[]): number { 189 + return slides.reduce((sum, s) => sum + s.elements.length, 0); 190 + } 191 + 192 + /** 193 + * Generate a filename for the export. 194 + */ 195 + export function exportFilename(deckName: string, config: ExportConfig): string { 196 + const safe = deckName.replace(/[^a-zA-Z0-9_-]/g, '_').replace(/_+/g, '_'); 197 + const ext = config.format === 'pptx' ? 'pptx' : config.format === 'png' ? 'png' : 'pdf'; 198 + return `${safe || 'presentation'}.${ext}`; 199 + } 200 + 201 + /** 202 + * Get the MIME type for an export format. 203 + */ 204 + export function exportMimeType(format: ExportConfig['format']): string { 205 + switch (format) { 206 + case 'pdf': return 'application/pdf'; 207 + case 'pptx': return 'application/vnd.openxmlformats-officedocument.presentationml.presentation'; 208 + case 'png': return 'image/png'; 209 + } 210 + } 211 + 212 + /** 213 + * Estimate export file size (rough heuristic). 214 + */ 215 + export function estimateExportSize(slideCount: number, format: ExportConfig['format']): number { 216 + const perSlide: Record<string, number> = { 217 + pdf: 50 * 1024, // ~50KB per slide 218 + pptx: 100 * 1024, // ~100KB per slide 219 + png: 200 * 1024, // ~200KB per slide 220 + }; 221 + return slideCount * (perSlide[format] ?? 50 * 1024); 222 + } 223 + 224 + /** 225 + * Get supported import formats. 226 + */ 227 + export function supportedImportFormats(): string[] { 228 + return ['pptx']; 229 + } 230 + 231 + /** 232 + * Get supported export formats. 233 + */ 234 + export function supportedExportFormats(): Array<{ format: ExportConfig['format']; label: string }> { 235 + return [ 236 + { format: 'pdf', label: 'PDF Document' }, 237 + { format: 'pptx', label: 'PowerPoint' }, 238 + { format: 'png', label: 'PNG Images' }, 239 + ]; 240 + }
+294
tests/button-cells.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createRegistry, 4 + registerButton, 5 + removeButton, 6 + buttonForCell, 7 + isButtonCell, 8 + validateAction, 9 + validateButton, 10 + planExecution, 11 + affectedCells, 12 + buttonCount, 13 + allButtons, 14 + updateButtonLabel, 15 + type ButtonAction, 16 + type ButtonConfig, 17 + } from '../src/sheets/button-cells'; 18 + 19 + describe('button-cells', () => { 20 + describe('createRegistry', () => { 21 + it('creates an empty registry', () => { 22 + const reg = createRegistry(); 23 + expect(reg.buttons.size).toBe(0); 24 + }); 25 + }); 26 + 27 + describe('registerButton / removeButton', () => { 28 + it('registers a button', () => { 29 + const reg = createRegistry(); 30 + const action: ButtonAction = { type: 'setValue', targetCell: 'A1', value: 'Done' }; 31 + const updated = registerButton(reg, 'B1', 'Mark Done', [action]); 32 + expect(buttonCount(updated)).toBe(1); 33 + }); 34 + 35 + it('removes a button', () => { 36 + let reg = createRegistry(); 37 + const action: ButtonAction = { type: 'setValue', targetCell: 'A1', value: 'x' }; 38 + reg = registerButton(reg, 'B1', 'Click Me', [action]); 39 + const btnId = [...reg.buttons.keys()][0]; 40 + const updated = removeButton(reg, btnId); 41 + expect(buttonCount(updated)).toBe(0); 42 + }); 43 + 44 + it('preserves other buttons on remove', () => { 45 + let reg = createRegistry(); 46 + const action: ButtonAction = { type: 'setValue', targetCell: 'A1', value: 'x' }; 47 + reg = registerButton(reg, 'B1', 'Btn1', [action]); 48 + reg = registerButton(reg, 'B2', 'Btn2', [action]); 49 + const firstId = [...reg.buttons.keys()][0]; 50 + const updated = removeButton(reg, firstId); 51 + expect(buttonCount(updated)).toBe(1); 52 + }); 53 + }); 54 + 55 + describe('buttonForCell / isButtonCell', () => { 56 + it('finds button by cell ID', () => { 57 + let reg = createRegistry(); 58 + const action: ButtonAction = { type: 'timestamp', targetCell: 'C1' }; 59 + reg = registerButton(reg, 'D1', 'Stamp', [action]); 60 + const btn = buttonForCell(reg, 'D1'); 61 + expect(btn).toBeDefined(); 62 + expect(btn!.label).toBe('Stamp'); 63 + }); 64 + 65 + it('returns undefined for cell without button', () => { 66 + expect(buttonForCell(createRegistry(), 'A1')).toBeUndefined(); 67 + }); 68 + 69 + it('isButtonCell returns correct boolean', () => { 70 + let reg = createRegistry(); 71 + reg = registerButton(reg, 'A1', 'X', [{ type: 'timestamp', targetCell: 'B1' }]); 72 + expect(isButtonCell(reg, 'A1')).toBe(true); 73 + expect(isButtonCell(reg, 'A2')).toBe(false); 74 + }); 75 + }); 76 + 77 + describe('validateAction', () => { 78 + it('validates setValue action', () => { 79 + expect(validateAction({ type: 'setValue', targetCell: 'A1', value: 'x' }).valid).toBe(true); 80 + }); 81 + 82 + it('rejects setValue without target', () => { 83 + expect(validateAction({ type: 'setValue', targetCell: '', value: 'x' }).valid).toBe(false); 84 + }); 85 + 86 + it('rejects setValue with invalid cell ID', () => { 87 + expect(validateAction({ type: 'setValue', targetCell: '123', value: 'x' }).valid).toBe(false); 88 + }); 89 + 90 + it('validates runFormula action', () => { 91 + expect(validateAction({ type: 'runFormula', targetCell: 'A1', formula: '=SUM(B1:B10)' }).valid).toBe(true); 92 + }); 93 + 94 + it('rejects formula without = prefix', () => { 95 + expect(validateAction({ type: 'runFormula', targetCell: 'A1', formula: 'SUM(B1:B10)' }).valid).toBe(false); 96 + }); 97 + 98 + it('validates toggleCheckbox', () => { 99 + expect(validateAction({ type: 'toggleCheckbox', targetCell: 'A1' }).valid).toBe(true); 100 + }); 101 + 102 + it('validates timestamp', () => { 103 + expect(validateAction({ type: 'timestamp', targetCell: 'A1' }).valid).toBe(true); 104 + }); 105 + 106 + it('validates clearRange', () => { 107 + expect(validateAction({ type: 'clearRange', startCell: 'A1', endCell: 'C3' }).valid).toBe(true); 108 + }); 109 + 110 + it('rejects clearRange with invalid cells', () => { 111 + expect(validateAction({ type: 'clearRange', startCell: '', endCell: 'C3' }).valid).toBe(false); 112 + }); 113 + 114 + it('validates incrementValue', () => { 115 + expect(validateAction({ type: 'incrementValue', targetCell: 'A1', amount: 5 }).valid).toBe(true); 116 + }); 117 + 118 + it('rejects increment of 0', () => { 119 + expect(validateAction({ type: 'incrementValue', targetCell: 'A1', amount: 0 }).valid).toBe(false); 120 + }); 121 + }); 122 + 123 + describe('validateButton', () => { 124 + it('validates a complete button config', () => { 125 + const config: ButtonConfig = { 126 + id: 'test', 127 + cellId: 'A1', 128 + label: 'Click', 129 + actions: [{ type: 'setValue', targetCell: 'B1', value: 'done' }], 130 + color: '#0066cc', 131 + requireConfirm: false, 132 + }; 133 + const result = validateButton(config); 134 + expect(result.valid).toBe(true); 135 + }); 136 + 137 + it('rejects empty label', () => { 138 + const config: ButtonConfig = { 139 + id: 'test', cellId: 'A1', label: '', actions: [{ type: 'timestamp', targetCell: 'B1' }], 140 + color: '#000', requireConfirm: false, 141 + }; 142 + expect(validateButton(config).valid).toBe(false); 143 + }); 144 + 145 + it('rejects empty actions', () => { 146 + const config: ButtonConfig = { 147 + id: 'test', cellId: 'A1', label: 'Click', actions: [], 148 + color: '#000', requireConfirm: false, 149 + }; 150 + expect(validateButton(config).valid).toBe(false); 151 + }); 152 + 153 + it('collects errors from invalid actions', () => { 154 + const config: ButtonConfig = { 155 + id: 'test', cellId: 'A1', label: 'Click', 156 + actions: [ 157 + { type: 'setValue', targetCell: '', value: 'x' }, 158 + { type: 'incrementValue', targetCell: 'A1', amount: 0 }, 159 + ], 160 + color: '#000', requireConfirm: false, 161 + }; 162 + const result = validateButton(config); 163 + expect(result.valid).toBe(false); 164 + expect(result.errors).toHaveLength(2); 165 + }); 166 + }); 167 + 168 + describe('planExecution', () => { 169 + const getCellValue = (cellId: string) => { 170 + const values: Record<string, string> = { A1: '10', B1: 'true', C1: 'hello' }; 171 + return values[cellId] ?? ''; 172 + }; 173 + 174 + it('plans setValue writes', () => { 175 + const config: ButtonConfig = { 176 + id: 't', cellId: 'Z1', label: 'Go', color: '', requireConfirm: false, 177 + actions: [{ type: 'setValue', targetCell: 'D1', value: 'done' }], 178 + }; 179 + const writes = planExecution(config, getCellValue); 180 + expect(writes).toEqual([{ cellId: 'D1', value: 'done' }]); 181 + }); 182 + 183 + it('plans toggleCheckbox (true→false)', () => { 184 + const config: ButtonConfig = { 185 + id: 't', cellId: 'Z1', label: 'Toggle', color: '', requireConfirm: false, 186 + actions: [{ type: 'toggleCheckbox', targetCell: 'B1' }], 187 + }; 188 + const writes = planExecution(config, getCellValue); 189 + expect(writes).toEqual([{ cellId: 'B1', value: 'false' }]); 190 + }); 191 + 192 + it('plans toggleCheckbox (false→true)', () => { 193 + const getValue = (id: string) => id === 'B1' ? 'false' : ''; 194 + const config: ButtonConfig = { 195 + id: 't', cellId: 'Z1', label: 'Toggle', color: '', requireConfirm: false, 196 + actions: [{ type: 'toggleCheckbox', targetCell: 'B1' }], 197 + }; 198 + const writes = planExecution(config, getValue); 199 + expect(writes).toEqual([{ cellId: 'B1', value: 'true' }]); 200 + }); 201 + 202 + it('plans incrementValue', () => { 203 + const config: ButtonConfig = { 204 + id: 't', cellId: 'Z1', label: 'Inc', color: '', requireConfirm: false, 205 + actions: [{ type: 'incrementValue', targetCell: 'A1', amount: 5 }], 206 + }; 207 + const writes = planExecution(config, getCellValue); 208 + expect(writes).toEqual([{ cellId: 'A1', value: '15' }]); 209 + }); 210 + 211 + it('plans clearRange', () => { 212 + const config: ButtonConfig = { 213 + id: 't', cellId: 'Z1', label: 'Clear', color: '', requireConfirm: false, 214 + actions: [{ type: 'clearRange', startCell: 'A1', endCell: 'B2' }], 215 + }; 216 + const writes = planExecution(config, getCellValue); 217 + expect(writes).toHaveLength(4); // A1, A2, B1, B2 218 + expect(writes.every(w => w.value === '')).toBe(true); 219 + }); 220 + 221 + it('plans multiple actions in order', () => { 222 + const config: ButtonConfig = { 223 + id: 't', cellId: 'Z1', label: 'Multi', color: '', requireConfirm: false, 224 + actions: [ 225 + { type: 'setValue', targetCell: 'D1', value: 'started' }, 226 + { type: 'timestamp', targetCell: 'E1' }, 227 + ], 228 + }; 229 + const writes = planExecution(config, getCellValue); 230 + expect(writes).toHaveLength(2); 231 + expect(writes[0].value).toBe('started'); 232 + expect(writes[1].cellId).toBe('E1'); 233 + }); 234 + }); 235 + 236 + describe('affectedCells', () => { 237 + it('returns target cells from actions', () => { 238 + const config: ButtonConfig = { 239 + id: 't', cellId: 'Z1', label: 'X', color: '', requireConfirm: false, 240 + actions: [ 241 + { type: 'setValue', targetCell: 'A1', value: 'x' }, 242 + { type: 'timestamp', targetCell: 'B1' }, 243 + ], 244 + }; 245 + const cells = affectedCells(config); 246 + expect(cells).toContain('A1'); 247 + expect(cells).toContain('B1'); 248 + }); 249 + 250 + it('expands clearRange cells', () => { 251 + const config: ButtonConfig = { 252 + id: 't', cellId: 'Z1', label: 'X', color: '', requireConfirm: false, 253 + actions: [{ type: 'clearRange', startCell: 'A1', endCell: 'B2' }], 254 + }; 255 + const cells = affectedCells(config); 256 + expect(cells).toHaveLength(4); 257 + }); 258 + 259 + it('deduplicates cells', () => { 260 + const config: ButtonConfig = { 261 + id: 't', cellId: 'Z1', label: 'X', color: '', requireConfirm: false, 262 + actions: [ 263 + { type: 'setValue', targetCell: 'A1', value: 'x' }, 264 + { type: 'incrementValue', targetCell: 'A1', amount: 1 }, 265 + ], 266 + }; 267 + expect(affectedCells(config)).toHaveLength(1); 268 + }); 269 + }); 270 + 271 + describe('allButtons', () => { 272 + it('returns all buttons as array', () => { 273 + let reg = createRegistry(); 274 + reg = registerButton(reg, 'A1', 'Btn1', [{ type: 'timestamp', targetCell: 'B1' }]); 275 + reg = registerButton(reg, 'A2', 'Btn2', [{ type: 'timestamp', targetCell: 'B2' }]); 276 + expect(allButtons(reg)).toHaveLength(2); 277 + }); 278 + }); 279 + 280 + describe('updateButtonLabel', () => { 281 + it('updates the label', () => { 282 + let reg = createRegistry(); 283 + reg = registerButton(reg, 'A1', 'Old', [{ type: 'timestamp', targetCell: 'B1' }]); 284 + const btnId = [...reg.buttons.keys()][0]; 285 + const updated = updateButtonLabel(reg, btnId, 'New'); 286 + expect(updated.buttons.get(btnId)!.label).toBe('New'); 287 + }); 288 + 289 + it('returns unchanged for missing button', () => { 290 + const reg = createRegistry(); 291 + expect(updateButtonLabel(reg, 'fake', 'X')).toBe(reg); 292 + }); 293 + }); 294 + });
+253
tests/pptx-export.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createExportConfig, 4 + validateExportConfig, 5 + filterSlideRange, 6 + mapElementType, 7 + emuToPixels, 8 + pixelsToEmu, 9 + normalizePosition, 10 + createImportedElement, 11 + createImportedSlide, 12 + totalImportedElements, 13 + exportFilename, 14 + exportMimeType, 15 + estimateExportSize, 16 + supportedImportFormats, 17 + supportedExportFormats, 18 + } from '../src/slides/pptx-export'; 19 + 20 + describe('pptx-export', () => { 21 + describe('createExportConfig', () => { 22 + it('creates default PDF config', () => { 23 + const config = createExportConfig(); 24 + expect(config.format).toBe('pdf'); 25 + expect(config.includeNotes).toBe(true); 26 + expect(config.slideRange).toBeNull(); 27 + expect(config.quality).toBe(0.92); 28 + }); 29 + 30 + it('accepts custom format', () => { 31 + expect(createExportConfig('pptx').format).toBe('pptx'); 32 + expect(createExportConfig('png').format).toBe('png'); 33 + }); 34 + }); 35 + 36 + describe('validateExportConfig', () => { 37 + it('validates a good config', () => { 38 + const result = validateExportConfig(createExportConfig()); 39 + expect(result.valid).toBe(true); 40 + expect(result.errors).toHaveLength(0); 41 + }); 42 + 43 + it('rejects invalid format', () => { 44 + const config = { ...createExportConfig(), format: 'doc' as any }; 45 + const result = validateExportConfig(config); 46 + expect(result.valid).toBe(false); 47 + expect(result.errors[0]).toContain('Invalid format'); 48 + }); 49 + 50 + it('rejects out-of-range quality', () => { 51 + const config = { ...createExportConfig(), quality: 1.5 }; 52 + expect(validateExportConfig(config).valid).toBe(false); 53 + }); 54 + 55 + it('rejects invalid slide range', () => { 56 + const config = { ...createExportConfig(), slideRange: [5, 2] as [number, number] }; 57 + expect(validateExportConfig(config).valid).toBe(false); 58 + }); 59 + 60 + it('rejects negative slide range start', () => { 61 + const config = { ...createExportConfig(), slideRange: [-1, 5] as [number, number] }; 62 + expect(validateExportConfig(config).valid).toBe(false); 63 + }); 64 + }); 65 + 66 + describe('filterSlideRange', () => { 67 + const slides = [ 68 + { index: 0, data: 'a' }, 69 + { index: 1, data: 'b' }, 70 + { index: 2, data: 'c' }, 71 + { index: 3, data: 'd' }, 72 + ]; 73 + 74 + it('returns all slides when range is null', () => { 75 + expect(filterSlideRange(slides, null)).toHaveLength(4); 76 + }); 77 + 78 + it('filters by range', () => { 79 + const filtered = filterSlideRange(slides, [1, 2]); 80 + expect(filtered).toHaveLength(2); 81 + expect(filtered[0].index).toBe(1); 82 + expect(filtered[1].index).toBe(2); 83 + }); 84 + 85 + it('handles single-slide range', () => { 86 + expect(filterSlideRange(slides, [2, 2])).toHaveLength(1); 87 + }); 88 + }); 89 + 90 + describe('mapElementType', () => { 91 + it('maps PPTX element types', () => { 92 + expect(mapElementType('sp')).toBe('shape'); 93 + expect(mapElementType('pic')).toBe('image'); 94 + expect(mapElementType('txBody')).toBe('text'); 95 + }); 96 + 97 + it('maps generic types', () => { 98 + expect(mapElementType('text')).toBe('text'); 99 + expect(mapElementType('image')).toBe('image'); 100 + expect(mapElementType('shape')).toBe('shape'); 101 + }); 102 + 103 + it('defaults to shape for unknown types', () => { 104 + expect(mapElementType('unknown')).toBe('shape'); 105 + }); 106 + }); 107 + 108 + describe('emuToPixels / pixelsToEmu', () => { 109 + it('converts EMU to pixels', () => { 110 + expect(emuToPixels(9525)).toBe(1); 111 + expect(emuToPixels(914400)).toBe(96); // 1 inch at 96 DPI 112 + }); 113 + 114 + it('converts pixels to EMU', () => { 115 + expect(pixelsToEmu(1)).toBe(9525); 116 + expect(pixelsToEmu(96)).toBe(914400); 117 + }); 118 + 119 + it('round-trips correctly', () => { 120 + expect(emuToPixels(pixelsToEmu(100))).toBe(100); 121 + }); 122 + }); 123 + 124 + describe('normalizePosition', () => { 125 + it('scales from PPTX to slide coordinates', () => { 126 + // PPTX standard: 9144000 x 6858000 EMU 127 + const result = normalizePosition( 128 + 4572000, 3429000, // center of PPTX 129 + 1000000, 500000, // element size 130 + 9144000, 6858000, // PPTX dimensions 131 + ); 132 + expect(result.x).toBe(480); // half of 960 133 + expect(result.y).toBe(270); // half of 540 134 + }); 135 + 136 + it('handles custom target dimensions', () => { 137 + const result = normalizePosition(50, 50, 10, 10, 100, 100, 200, 200); 138 + expect(result.x).toBe(100); 139 + expect(result.y).toBe(100); 140 + expect(result.width).toBe(20); 141 + expect(result.height).toBe(20); 142 + }); 143 + }); 144 + 145 + describe('createImportedElement', () => { 146 + it('creates an imported element', () => { 147 + const el = createImportedElement('text', 10, 20, 100, 50, 'Hello'); 148 + expect(el.type).toBe('text'); 149 + expect(el.x).toBe(10); 150 + expect(el.content).toBe('Hello'); 151 + expect(el.style).toEqual({}); 152 + }); 153 + 154 + it('maps PPTX types', () => { 155 + expect(createImportedElement('pic', 0, 0, 10, 10, '').type).toBe('image'); 156 + }); 157 + }); 158 + 159 + describe('createImportedSlide', () => { 160 + it('creates an imported slide', () => { 161 + const slide = createImportedSlide(0); 162 + expect(slide.index).toBe(0); 163 + expect(slide.background).toBe('#ffffff'); 164 + expect(slide.elements).toHaveLength(0); 165 + expect(slide.notes).toBe(''); 166 + }); 167 + 168 + it('accepts elements and notes', () => { 169 + const el = createImportedElement('text', 0, 0, 100, 50, 'Hi'); 170 + const slide = createImportedSlide(1, [el], '#000000', 'Speaker notes'); 171 + expect(slide.elements).toHaveLength(1); 172 + expect(slide.notes).toBe('Speaker notes'); 173 + }); 174 + }); 175 + 176 + describe('totalImportedElements', () => { 177 + it('counts elements across slides', () => { 178 + const slides = [ 179 + createImportedSlide(0, [ 180 + createImportedElement('text', 0, 0, 10, 10, ''), 181 + createImportedElement('text', 0, 0, 10, 10, ''), 182 + ]), 183 + createImportedSlide(1, [ 184 + createImportedElement('text', 0, 0, 10, 10, ''), 185 + ]), 186 + ]; 187 + expect(totalImportedElements(slides)).toBe(3); 188 + }); 189 + 190 + it('returns 0 for empty slides', () => { 191 + expect(totalImportedElements([])).toBe(0); 192 + }); 193 + }); 194 + 195 + describe('exportFilename', () => { 196 + it('generates PDF filename', () => { 197 + expect(exportFilename('My Deck', createExportConfig('pdf'))).toBe('My_Deck.pdf'); 198 + }); 199 + 200 + it('generates PPTX filename', () => { 201 + expect(exportFilename('Sales Q1', createExportConfig('pptx'))).toBe('Sales_Q1.pptx'); 202 + }); 203 + 204 + it('generates PNG filename', () => { 205 + expect(exportFilename('Slides', createExportConfig('png'))).toBe('Slides.png'); 206 + }); 207 + 208 + it('sanitizes special characters', () => { 209 + expect(exportFilename('a/b:c?d', createExportConfig())).toBe('a_b_c_d.pdf'); 210 + }); 211 + 212 + it('uses default name for empty input', () => { 213 + expect(exportFilename('', createExportConfig())).toBe('presentation.pdf'); 214 + }); 215 + }); 216 + 217 + describe('exportMimeType', () => { 218 + it('returns correct MIME types', () => { 219 + expect(exportMimeType('pdf')).toBe('application/pdf'); 220 + expect(exportMimeType('pptx')).toContain('presentationml'); 221 + expect(exportMimeType('png')).toBe('image/png'); 222 + }); 223 + }); 224 + 225 + describe('estimateExportSize', () => { 226 + it('estimates size based on slide count', () => { 227 + const size = estimateExportSize(10, 'pdf'); 228 + expect(size).toBe(10 * 50 * 1024); 229 + }); 230 + 231 + it('PPTX is larger than PDF', () => { 232 + expect(estimateExportSize(1, 'pptx')).toBeGreaterThan(estimateExportSize(1, 'pdf')); 233 + }); 234 + }); 235 + 236 + describe('supportedImportFormats', () => { 237 + it('includes pptx', () => { 238 + expect(supportedImportFormats()).toContain('pptx'); 239 + }); 240 + }); 241 + 242 + describe('supportedExportFormats', () => { 243 + it('returns 3 formats', () => { 244 + expect(supportedExportFormats()).toHaveLength(3); 245 + }); 246 + 247 + it('all have labels', () => { 248 + for (const fmt of supportedExportFormats()) { 249 + expect(fmt.label).toBeTruthy(); 250 + } 251 + }); 252 + }); 253 + });
+294
tests/whiteboard.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createWhiteboard, 4 + addShape, 5 + removeShape, 6 + moveShape, 7 + resizeShape, 8 + setShapeLabel, 9 + addArrow, 10 + removeArrow, 11 + snapPoint, 12 + toggleSnap, 13 + pan, 14 + setZoom, 15 + hitTestShape, 16 + shapeAtPoint, 17 + arrowsForShape, 18 + getBoundingBox, 19 + elementCounts, 20 + } from '../src/diagrams/whiteboard'; 21 + 22 + describe('whiteboard', () => { 23 + describe('createWhiteboard', () => { 24 + it('creates an empty whiteboard', () => { 25 + const wb = createWhiteboard(); 26 + expect(wb.shapes.size).toBe(0); 27 + expect(wb.arrows.size).toBe(0); 28 + expect(wb.panX).toBe(0); 29 + expect(wb.panY).toBe(0); 30 + expect(wb.zoom).toBe(1); 31 + expect(wb.gridSize).toBe(20); 32 + expect(wb.snapToGrid).toBe(true); 33 + }); 34 + 35 + it('accepts custom grid size', () => { 36 + expect(createWhiteboard(10).gridSize).toBe(10); 37 + }); 38 + }); 39 + 40 + describe('addShape', () => { 41 + it('adds a shape to the whiteboard', () => { 42 + const wb = createWhiteboard(); 43 + const updated = addShape(wb, 'rectangle', 100, 200); 44 + expect(updated.shapes.size).toBe(1); 45 + const shape = [...updated.shapes.values()][0]; 46 + expect(shape.kind).toBe('rectangle'); 47 + expect(shape.width).toBe(120); 48 + expect(shape.height).toBe(80); 49 + }); 50 + 51 + it('snaps to grid by default', () => { 52 + const wb = createWhiteboard(20); 53 + const updated = addShape(wb, 'rectangle', 105, 213); 54 + const shape = [...updated.shapes.values()][0]; 55 + expect(shape.x).toBe(100); 56 + expect(shape.y).toBe(220); 57 + }); 58 + 59 + it('does not snap when snap is off', () => { 60 + let wb = createWhiteboard(20); 61 + wb = toggleSnap(wb); 62 + const updated = addShape(wb, 'rectangle', 105, 213); 63 + const shape = [...updated.shapes.values()][0]; 64 + expect(shape.x).toBe(105); 65 + expect(shape.y).toBe(213); 66 + }); 67 + 68 + it('sets label', () => { 69 + const wb = createWhiteboard(); 70 + const updated = addShape(wb, 'text', 0, 0, 100, 50, 'Hello'); 71 + const shape = [...updated.shapes.values()][0]; 72 + expect(shape.label).toBe('Hello'); 73 + }); 74 + }); 75 + 76 + describe('removeShape', () => { 77 + it('removes a shape', () => { 78 + let wb = createWhiteboard(); 79 + wb = addShape(wb, 'rectangle', 0, 0); 80 + const shapeId = [...wb.shapes.keys()][0]; 81 + const updated = removeShape(wb, shapeId); 82 + expect(updated.shapes.size).toBe(0); 83 + }); 84 + 85 + it('removes connected arrows', () => { 86 + let wb = createWhiteboard(); 87 + wb = addShape(wb, 'rectangle', 0, 0); 88 + wb = addShape(wb, 'rectangle', 200, 0); 89 + const [id1, id2] = [...wb.shapes.keys()]; 90 + wb = addArrow(wb, { shapeId: id1, anchor: 'right' }, { shapeId: id2, anchor: 'left' }); 91 + expect(wb.arrows.size).toBe(1); 92 + const updated = removeShape(wb, id1); 93 + expect(updated.arrows.size).toBe(0); 94 + }); 95 + }); 96 + 97 + describe('moveShape', () => { 98 + it('moves a shape with grid snapping', () => { 99 + let wb = createWhiteboard(20); 100 + wb = addShape(wb, 'rectangle', 0, 0); 101 + const shapeId = [...wb.shapes.keys()][0]; 102 + const updated = moveShape(wb, shapeId, 55, 67); 103 + const shape = updated.shapes.get(shapeId)!; 104 + expect(shape.x).toBe(60); 105 + expect(shape.y).toBe(60); 106 + }); 107 + 108 + it('returns unchanged for non-existent shape', () => { 109 + const wb = createWhiteboard(); 110 + expect(moveShape(wb, 'fake', 0, 0)).toBe(wb); 111 + }); 112 + }); 113 + 114 + describe('resizeShape', () => { 115 + it('resizes a shape', () => { 116 + let wb = createWhiteboard(); 117 + wb = addShape(wb, 'rectangle', 0, 0); 118 + const shapeId = [...wb.shapes.keys()][0]; 119 + const updated = resizeShape(wb, shapeId, 200, 150); 120 + expect(updated.shapes.get(shapeId)!.width).toBe(200); 121 + expect(updated.shapes.get(shapeId)!.height).toBe(150); 122 + }); 123 + 124 + it('enforces minimum size of 10', () => { 125 + let wb = createWhiteboard(); 126 + wb = addShape(wb, 'rectangle', 0, 0); 127 + const shapeId = [...wb.shapes.keys()][0]; 128 + const updated = resizeShape(wb, shapeId, 5, 3); 129 + expect(updated.shapes.get(shapeId)!.width).toBe(10); 130 + expect(updated.shapes.get(shapeId)!.height).toBe(10); 131 + }); 132 + }); 133 + 134 + describe('setShapeLabel', () => { 135 + it('updates the label', () => { 136 + let wb = createWhiteboard(); 137 + wb = addShape(wb, 'text', 0, 0); 138 + const shapeId = [...wb.shapes.keys()][0]; 139 + const updated = setShapeLabel(wb, shapeId, 'New Label'); 140 + expect(updated.shapes.get(shapeId)!.label).toBe('New Label'); 141 + }); 142 + }); 143 + 144 + describe('addArrow / removeArrow', () => { 145 + it('adds an arrow between shapes', () => { 146 + let wb = createWhiteboard(); 147 + wb = addShape(wb, 'rectangle', 0, 0); 148 + wb = addShape(wb, 'rectangle', 200, 0); 149 + const [id1, id2] = [...wb.shapes.keys()]; 150 + const updated = addArrow(wb, { shapeId: id1, anchor: 'right' }, { shapeId: id2, anchor: 'left' }); 151 + expect(updated.arrows.size).toBe(1); 152 + }); 153 + 154 + it('adds an arrow to a free point', () => { 155 + let wb = createWhiteboard(); 156 + wb = addShape(wb, 'rectangle', 0, 0); 157 + const id = [...wb.shapes.keys()][0]; 158 + const updated = addArrow(wb, { shapeId: id, anchor: 'right' }, { x: 300, y: 100 }); 159 + expect(updated.arrows.size).toBe(1); 160 + }); 161 + 162 + it('removes an arrow', () => { 163 + let wb = createWhiteboard(); 164 + wb = addArrow(wb, { x: 0, y: 0 }, { x: 100, y: 100 }); 165 + const arrowId = [...wb.arrows.keys()][0]; 166 + const updated = removeArrow(wb, arrowId); 167 + expect(updated.arrows.size).toBe(0); 168 + }); 169 + }); 170 + 171 + describe('snapPoint', () => { 172 + it('snaps to nearest grid point', () => { 173 + expect(snapPoint(13, 27, 20)).toEqual({ x: 20, y: 20 }); 174 + expect(snapPoint(30, 50, 20)).toEqual({ x: 40, y: 60 }); 175 + }); 176 + 177 + it('snaps exact values correctly', () => { 178 + expect(snapPoint(40, 60, 20)).toEqual({ x: 40, y: 60 }); 179 + }); 180 + }); 181 + 182 + describe('toggleSnap', () => { 183 + it('toggles snap-to-grid', () => { 184 + const wb = createWhiteboard(); 185 + expect(wb.snapToGrid).toBe(true); 186 + expect(toggleSnap(wb).snapToGrid).toBe(false); 187 + }); 188 + }); 189 + 190 + describe('pan', () => { 191 + it('offsets the viewport', () => { 192 + const wb = createWhiteboard(); 193 + const panned = pan(wb, 50, -30); 194 + expect(panned.panX).toBe(50); 195 + expect(panned.panY).toBe(-30); 196 + }); 197 + 198 + it('accumulates pans', () => { 199 + let wb = createWhiteboard(); 200 + wb = pan(wb, 10, 20); 201 + wb = pan(wb, 30, -5); 202 + expect(wb.panX).toBe(40); 203 + expect(wb.panY).toBe(15); 204 + }); 205 + }); 206 + 207 + describe('setZoom', () => { 208 + it('sets zoom level', () => { 209 + const wb = createWhiteboard(); 210 + expect(setZoom(wb, 2).zoom).toBe(2); 211 + }); 212 + 213 + it('clamps zoom to 0.1-5', () => { 214 + const wb = createWhiteboard(); 215 + expect(setZoom(wb, 0.01).zoom).toBe(0.1); 216 + expect(setZoom(wb, 10).zoom).toBe(5); 217 + }); 218 + }); 219 + 220 + describe('hitTestShape', () => { 221 + const shape = { id: 's1', kind: 'rectangle' as const, x: 100, y: 100, width: 50, height: 30, rotation: 0, label: '', style: {} }; 222 + 223 + it('returns true for point inside', () => { 224 + expect(hitTestShape(shape, 120, 115)).toBe(true); 225 + }); 226 + 227 + it('returns true for point on edge', () => { 228 + expect(hitTestShape(shape, 100, 100)).toBe(true); 229 + expect(hitTestShape(shape, 150, 130)).toBe(true); 230 + }); 231 + 232 + it('returns false for point outside', () => { 233 + expect(hitTestShape(shape, 99, 100)).toBe(false); 234 + expect(hitTestShape(shape, 120, 131)).toBe(false); 235 + }); 236 + }); 237 + 238 + describe('shapeAtPoint', () => { 239 + it('finds shape at point', () => { 240 + let wb = createWhiteboard(); 241 + wb = addShape(wb, 'rectangle', 0, 0, 100, 100); 242 + const shape = shapeAtPoint(wb, 50, 50); 243 + expect(shape).not.toBeNull(); 244 + }); 245 + 246 + it('returns null for empty space', () => { 247 + let wb = createWhiteboard(); 248 + wb = addShape(wb, 'rectangle', 0, 0, 50, 50); 249 + expect(shapeAtPoint(wb, 200, 200)).toBeNull(); 250 + }); 251 + }); 252 + 253 + describe('arrowsForShape', () => { 254 + it('finds arrows connected to a shape', () => { 255 + let wb = createWhiteboard(); 256 + wb = addShape(wb, 'rectangle', 0, 0); 257 + wb = addShape(wb, 'rectangle', 200, 0); 258 + const [id1, id2] = [...wb.shapes.keys()]; 259 + wb = addArrow(wb, { shapeId: id1, anchor: 'right' }, { shapeId: id2, anchor: 'left' }); 260 + wb = addArrow(wb, { x: 0, y: 0 }, { x: 500, y: 500 }); // unconnected 261 + expect(arrowsForShape(wb, id1)).toHaveLength(1); 262 + expect(arrowsForShape(wb, id2)).toHaveLength(1); 263 + }); 264 + }); 265 + 266 + describe('getBoundingBox', () => { 267 + it('returns null for empty whiteboard', () => { 268 + expect(getBoundingBox(createWhiteboard())).toBeNull(); 269 + }); 270 + 271 + it('returns bounding box of all shapes', () => { 272 + let wb = createWhiteboard(); 273 + wb = addShape(wb, 'rectangle', 0, 0, 100, 50); 274 + wb = addShape(wb, 'rectangle', 200, 100, 80, 60); 275 + const bb = getBoundingBox(wb)!; 276 + expect(bb.x).toBe(0); 277 + expect(bb.y).toBe(0); 278 + expect(bb.width).toBe(280); 279 + expect(bb.height).toBe(160); 280 + }); 281 + }); 282 + 283 + describe('elementCounts', () => { 284 + it('counts shapes and arrows', () => { 285 + let wb = createWhiteboard(); 286 + wb = addShape(wb, 'rectangle', 0, 0); 287 + wb = addShape(wb, 'ellipse', 100, 0); 288 + wb = addArrow(wb, { x: 0, y: 0 }, { x: 100, y: 0 }); 289 + const counts = elementCounts(wb); 290 + expect(counts.shapes).toBe(2); 291 + expect(counts.arrows).toBe(1); 292 + }); 293 + }); 294 + });