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 'refactor/whiteboard-decompose' (#297) from refactor/whiteboard-decompose into main

scott 64ebfb4c 82d3c651

+705 -527
+156
src/diagrams/whiteboard-geometry.ts
··· 1 + /** 2 + * Whiteboard geometry — snapping, hit testing, resize handles, freehand paths, 3 + * and arrow edge anchors. 4 + */ 5 + 6 + import type { Point, Shape, ResizeHandle, WhiteboardState } from './whiteboard-types.js'; 7 + 8 + // --- Snapping --- 9 + 10 + /** 11 + * Snap a point to the grid. 12 + */ 13 + export function snapPoint(x: number, y: number, gridSize: number): Point { 14 + return { 15 + x: Math.round(x / gridSize) * gridSize, 16 + y: Math.round(y / gridSize) * gridSize, 17 + }; 18 + } 19 + 20 + // --- Hit testing --- 21 + 22 + /** 23 + * Hit-test: is a point inside a shape's bounding box? 24 + */ 25 + export function hitTestShape(shape: Shape, px: number, py: number): boolean { 26 + return px >= shape.x && px <= shape.x + shape.width && 27 + py >= shape.y && py <= shape.y + shape.height; 28 + } 29 + 30 + /** 31 + * Find all shapes whose bounding boxes intersect a rectangle. 32 + */ 33 + export function shapesInRect( 34 + state: WhiteboardState, 35 + rect: { x: number; y: number; width: number; height: number }, 36 + ): string[] { 37 + const result: string[] = []; 38 + for (const shape of state.shapes.values()) { 39 + if (shape.x + shape.width >= rect.x && shape.x <= rect.x + rect.width && 40 + shape.y + shape.height >= rect.y && shape.y <= rect.y + rect.height) { 41 + result.push(shape.id); 42 + } 43 + } 44 + return result; 45 + } 46 + 47 + // --- Resize handles --- 48 + 49 + /** 50 + * Get the 8 resize handle positions for a shape. 51 + */ 52 + export function getResizeHandles(shape: Shape): Array<{ handle: ResizeHandle; x: number; y: number }> { 53 + const { x, y, width: w, height: h } = shape; 54 + return [ 55 + { handle: 'nw', x, y }, 56 + { handle: 'n', x: x + w / 2, y }, 57 + { handle: 'ne', x: x + w, y }, 58 + { handle: 'e', x: x + w, y: y + h / 2 }, 59 + { handle: 'se', x: x + w, y: y + h }, 60 + { handle: 's', x: x + w / 2, y: y + h }, 61 + { handle: 'sw', x, y: y + h }, 62 + { handle: 'w', x, y: y + h / 2 }, 63 + ]; 64 + } 65 + 66 + /** 67 + * Check if a point hits any resize handle. 68 + */ 69 + export function hitTestResizeHandle(shape: Shape, px: number, py: number, radius = 6): ResizeHandle | null { 70 + for (const h of getResizeHandles(shape)) { 71 + if (Math.abs(px - h.x) <= radius && Math.abs(py - h.y) <= radius) return h.handle; 72 + } 73 + return null; 74 + } 75 + 76 + /** 77 + * Compute new shape bounds after dragging a resize handle by (dx, dy). 78 + */ 79 + export function applyResize( 80 + shape: { x: number; y: number; width: number; height: number }, 81 + handle: ResizeHandle, 82 + dx: number, 83 + dy: number, 84 + ): { x: number; y: number; width: number; height: number } { 85 + let { x, y, width, height } = shape; 86 + const MIN = 10; 87 + switch (handle) { 88 + case 'se': width += dx; height += dy; break; 89 + case 'e': width += dx; break; 90 + case 's': height += dy; break; 91 + case 'nw': x += dx; y += dy; width -= dx; height -= dy; break; 92 + case 'n': y += dy; height -= dy; break; 93 + case 'ne': y += dy; width += dx; height -= dy; break; 94 + case 'sw': x += dx; width -= dx; height += dy; break; 95 + case 'w': x += dx; width -= dx; break; 96 + } 97 + // Clamp minimum size — if width/height would be < MIN, undo the position shift 98 + if (width < MIN) { if (handle.includes('w')) x -= (MIN - width); width = MIN; } 99 + if (height < MIN) { if (handle.includes('n')) y -= (MIN - height); height = MIN; } 100 + return { x, y, width, height }; 101 + } 102 + 103 + // --- Smoothed freehand --- 104 + 105 + /** 106 + * Convert points to a smooth Catmull-Rom spline SVG path. 107 + */ 108 + export function pointsToCatmullRomPath(points: Point[], tension = 6): string { 109 + if (points.length === 0) return ''; 110 + if (points.length === 1) return `M${points[0].x},${points[0].y}`; 111 + if (points.length === 2) return `M${points[0].x},${points[0].y} L${points[1].x},${points[1].y}`; 112 + 113 + const pts = [points[0], ...points, points[points.length - 1]]; 114 + let d = `M${points[0].x},${points[0].y}`; 115 + 116 + for (let i = 0; i < pts.length - 3; i++) { 117 + const p0 = pts[i], p1 = pts[i + 1], p2 = pts[i + 2], p3 = pts[i + 3]; 118 + const cp1x = p1.x + (p2.x - p0.x) / tension; 119 + const cp1y = p1.y + (p2.y - p0.y) / tension; 120 + const cp2x = p2.x - (p3.x - p1.x) / tension; 121 + const cp2y = p2.y - (p3.y - p1.y) / tension; 122 + d += ` C${cp1x},${cp1y} ${cp2x},${cp2y} ${p2.x},${p2.y}`; 123 + } 124 + return d; 125 + } 126 + 127 + // --- Arrow edge anchors --- 128 + 129 + function clamp(val: number, min: number, max: number): number { 130 + return Math.max(min, Math.min(max, val)); 131 + } 132 + 133 + /** 134 + * Find the nearest edge anchor point on a shape relative to an external point. 135 + */ 136 + export function nearestEdgeAnchor( 137 + shape: Shape, 138 + px: number, 139 + py: number, 140 + ): { anchor: 'top' | 'bottom' | 'left' | 'right'; x: number; y: number } { 141 + const cx = shape.x + shape.width / 2; 142 + const cy = shape.y + shape.height / 2; 143 + const angle = Math.atan2(py - cy, px - cx); 144 + const absAngle = Math.abs(angle); 145 + const aspectThreshold = Math.atan2(shape.height / 2, shape.width / 2); 146 + 147 + if (absAngle < aspectThreshold) { 148 + return { anchor: 'right', x: shape.x + shape.width, y: clamp(cy, shape.y, shape.y + shape.height) }; 149 + } else if (absAngle > Math.PI - aspectThreshold) { 150 + return { anchor: 'left', x: shape.x, y: clamp(cy, shape.y, shape.y + shape.height) }; 151 + } else if (angle < 0) { 152 + return { anchor: 'top', x: clamp(cx, shape.x, shape.x + shape.width), y: shape.y }; 153 + } else { 154 + return { anchor: 'bottom', x: clamp(cx, shape.x, shape.x + shape.width), y: shape.y + shape.height }; 155 + } 156 + }
+203
src/diagrams/whiteboard-layout.ts
··· 1 + /** 2 + * Whiteboard layout — z-order, alignment, distribution, and flip operations. 3 + */ 4 + 5 + import type { Shape, WhiteboardState } from './whiteboard-types.js'; 6 + 7 + // --- Z-order --- 8 + 9 + /** 10 + * Move shapes to the end of the Map (topmost z-order). 11 + */ 12 + export function bringToFront(state: WhiteboardState, shapeIds: Iterable<string>): WhiteboardState { 13 + const idSet = new Set(shapeIds); 14 + if (idSet.size === 0) return state; 15 + const shapes = new Map<string, Shape>(); 16 + const moved: Array<[string, Shape]> = []; 17 + for (const [id, shape] of state.shapes) { 18 + if (idSet.has(id)) { 19 + moved.push([id, shape]); 20 + } else { 21 + shapes.set(id, shape); 22 + } 23 + } 24 + for (const [id, shape] of moved) shapes.set(id, shape); 25 + return { ...state, shapes }; 26 + } 27 + 28 + /** 29 + * Move shapes to the start of the Map (bottommost z-order). 30 + */ 31 + export function sendToBack(state: WhiteboardState, shapeIds: Iterable<string>): WhiteboardState { 32 + const idSet = new Set(shapeIds); 33 + if (idSet.size === 0) return state; 34 + const shapes = new Map<string, Shape>(); 35 + const kept: Array<[string, Shape]> = []; 36 + for (const [id, shape] of state.shapes) { 37 + if (idSet.has(id)) { 38 + shapes.set(id, shape); 39 + } else { 40 + kept.push([id, shape]); 41 + } 42 + } 43 + for (const [id, shape] of kept) shapes.set(id, shape); 44 + return { ...state, shapes }; 45 + } 46 + 47 + /** 48 + * Move a shape one position forward in z-order (swap with the next entry). 49 + */ 50 + export function bringForward(state: WhiteboardState, shapeId: string): WhiteboardState { 51 + if (!state.shapes.has(shapeId)) return state; 52 + const entries = [...state.shapes.entries()]; 53 + const idx = entries.findIndex(([id]) => id === shapeId); 54 + if (idx === entries.length - 1) return state; // already at front 55 + [entries[idx], entries[idx + 1]] = [entries[idx + 1], entries[idx]]; 56 + return { ...state, shapes: new Map(entries) }; 57 + } 58 + 59 + /** 60 + * Move a shape one position backward in z-order (swap with the previous entry). 61 + */ 62 + export function sendBackward(state: WhiteboardState, shapeId: string): WhiteboardState { 63 + if (!state.shapes.has(shapeId)) return state; 64 + const entries = [...state.shapes.entries()]; 65 + const idx = entries.findIndex(([id]) => id === shapeId); 66 + if (idx === 0) return state; // already at back 67 + [entries[idx - 1], entries[idx]] = [entries[idx], entries[idx - 1]]; 68 + return { ...state, shapes: new Map(entries) }; 69 + } 70 + 71 + // --- Alignment --- 72 + 73 + /** 74 + * Align multiple shapes along an axis. 75 + */ 76 + export function alignShapes( 77 + state: WhiteboardState, 78 + shapeIds: string[], 79 + alignment: 'left' | 'center-h' | 'right' | 'top' | 'center-v' | 'bottom', 80 + ): WhiteboardState { 81 + const resolved = shapeIds.map(id => state.shapes.get(id)).filter((s): s is Shape => s != null); 82 + if (resolved.length < 2) return state; 83 + 84 + let target: number; 85 + switch (alignment) { 86 + case 'left': 87 + target = Math.min(...resolved.map(s => s.x)); 88 + break; 89 + case 'right': 90 + target = Math.max(...resolved.map(s => s.x + s.width)); 91 + break; 92 + case 'center-h': 93 + target = resolved.reduce((sum, s) => sum + s.x + s.width / 2, 0) / resolved.length; 94 + break; 95 + case 'top': 96 + target = Math.min(...resolved.map(s => s.y)); 97 + break; 98 + case 'bottom': 99 + target = Math.max(...resolved.map(s => s.y + s.height)); 100 + break; 101 + case 'center-v': 102 + target = resolved.reduce((sum, s) => sum + s.y + s.height / 2, 0) / resolved.length; 103 + break; 104 + } 105 + 106 + const shapes = new Map(state.shapes); 107 + for (const s of resolved) { 108 + let nx = s.x; 109 + let ny = s.y; 110 + switch (alignment) { 111 + case 'left': nx = target; break; 112 + case 'right': nx = target - s.width; break; 113 + case 'center-h': nx = target - s.width / 2; break; 114 + case 'top': ny = target; break; 115 + case 'bottom': ny = target - s.height; break; 116 + case 'center-v': ny = target - s.height / 2; break; 117 + } 118 + shapes.set(s.id, { ...s, x: nx, y: ny }); 119 + } 120 + return { ...state, shapes }; 121 + } 122 + 123 + // --- Distribution --- 124 + 125 + /** 126 + * Evenly distribute shapes along an axis. 127 + * Shapes are sorted by position, then spaced evenly between the first and last. 128 + */ 129 + export function distributeShapes( 130 + state: WhiteboardState, 131 + shapeIds: string[], 132 + axis: 'horizontal' | 'vertical', 133 + ): WhiteboardState { 134 + const resolved = shapeIds.map(id => state.shapes.get(id)).filter((s): s is Shape => s != null); 135 + if (resolved.length < 3) return state; 136 + 137 + const sorted = [...resolved].sort((a, b) => 138 + axis === 'horizontal' ? a.x - b.x : a.y - b.y, 139 + ); 140 + 141 + const first = sorted[0]; 142 + const last = sorted[sorted.length - 1]; 143 + 144 + let totalSpan: number; 145 + let totalShapeSize: number; 146 + if (axis === 'horizontal') { 147 + totalSpan = (last.x + last.width) - first.x; 148 + totalShapeSize = sorted.reduce((sum, s) => sum + s.width, 0); 149 + } else { 150 + totalSpan = (last.y + last.height) - first.y; 151 + totalShapeSize = sorted.reduce((sum, s) => sum + s.height, 0); 152 + } 153 + 154 + const gap = (totalSpan - totalShapeSize) / (sorted.length - 1); 155 + const shapes = new Map(state.shapes); 156 + 157 + let cursor = axis === 'horizontal' ? first.x : first.y; 158 + for (const s of sorted) { 159 + if (axis === 'horizontal') { 160 + shapes.set(s.id, { ...s, x: cursor }); 161 + cursor += s.width + gap; 162 + } else { 163 + shapes.set(s.id, { ...s, y: cursor }); 164 + cursor += s.height + gap; 165 + } 166 + } 167 + return { ...state, shapes }; 168 + } 169 + 170 + // --- Flip --- 171 + 172 + /** 173 + * Flip shape positions around the center of their collective bounding box. 174 + * For horizontal: mirror x positions. For vertical: mirror y positions. 175 + */ 176 + export function flipShapes( 177 + state: WhiteboardState, 178 + shapeIds: string[], 179 + axis: 'horizontal' | 'vertical', 180 + ): WhiteboardState { 181 + const resolved = shapeIds.map(id => state.shapes.get(id)).filter((s): s is Shape => s != null); 182 + if (resolved.length < 2) return state; 183 + 184 + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; 185 + for (const s of resolved) { 186 + minX = Math.min(minX, s.x); 187 + minY = Math.min(minY, s.y); 188 + maxX = Math.max(maxX, s.x + s.width); 189 + maxY = Math.max(maxY, s.y + s.height); 190 + } 191 + 192 + const shapes = new Map(state.shapes); 193 + for (const s of resolved) { 194 + if (axis === 'horizontal') { 195 + const newX = minX + (maxX - (s.x + s.width)); 196 + shapes.set(s.id, { ...s, x: newX }); 197 + } else { 198 + const newY = minY + (maxY - (s.y + s.height)); 199 + shapes.set(s.id, { ...s, y: newY }); 200 + } 201 + } 202 + return { ...state, shapes }; 203 + }
+150
src/diagrams/whiteboard-transforms.ts
··· 1 + /** 2 + * Whiteboard transforms — rotation, style, opacity, font, and group queries. 3 + * 4 + * Pure transforms that don't generate IDs. Functions that create new entities 5 + * (groupShapes, duplicateShapes) stay in whiteboard.ts where the counter lives. 6 + */ 7 + 8 + import type { WhiteboardState } from './whiteboard-types.js'; 9 + 10 + // --- Rotation --- 11 + 12 + /** 13 + * Rotate a shape by a given angle (degrees). Accumulates with existing rotation. 14 + */ 15 + export function rotateShape( 16 + state: WhiteboardState, 17 + shapeId: string, 18 + angle: number, 19 + ): WhiteboardState { 20 + const shape = state.shapes.get(shapeId); 21 + if (!shape) return state; 22 + const shapes = new Map(state.shapes); 23 + shapes.set(shapeId, { 24 + ...shape, 25 + rotation: (((shape.rotation + angle) % 360) + 360) % 360, 26 + }); 27 + return { ...state, shapes }; 28 + } 29 + 30 + /** 31 + * Set a shape's rotation to an absolute value. 32 + */ 33 + export function setShapeRotation( 34 + state: WhiteboardState, 35 + shapeId: string, 36 + rotation: number, 37 + ): WhiteboardState { 38 + const shape = state.shapes.get(shapeId); 39 + if (!shape) return state; 40 + const shapes = new Map(state.shapes); 41 + shapes.set(shapeId, { 42 + ...shape, 43 + rotation: ((rotation % 360) + 360) % 360, 44 + }); 45 + return { ...state, shapes }; 46 + } 47 + 48 + // --- Style --- 49 + 50 + /** 51 + * Update style properties on shapes. 52 + */ 53 + export function setShapeStyle( 54 + state: WhiteboardState, 55 + shapeIds: Iterable<string>, 56 + styleUpdate: Record<string, string>, 57 + ): WhiteboardState { 58 + const shapes = new Map(state.shapes); 59 + for (const id of shapeIds) { 60 + const shape = shapes.get(id); 61 + if (shape) { 62 + shapes.set(id, { 63 + ...shape, 64 + style: { ...shape.style, ...styleUpdate }, 65 + }); 66 + } 67 + } 68 + return { ...state, shapes }; 69 + } 70 + 71 + /** 72 + * Set opacity on shapes. 73 + */ 74 + export function setShapeOpacity( 75 + state: WhiteboardState, 76 + shapeIds: Iterable<string>, 77 + opacity: number, 78 + ): WhiteboardState { 79 + const clamped = Math.max(0, Math.min(1, opacity)); 80 + const shapes = new Map(state.shapes); 81 + for (const id of shapeIds) { 82 + const shape = shapes.get(id); 83 + if (shape) shapes.set(id, { ...shape, opacity: clamped }); 84 + } 85 + return { ...state, shapes }; 86 + } 87 + 88 + /** 89 + * Set font family on shapes. 90 + */ 91 + export function setShapeFontFamily( 92 + state: WhiteboardState, 93 + shapeIds: Iterable<string>, 94 + fontFamily: string, 95 + ): WhiteboardState { 96 + const shapes = new Map(state.shapes); 97 + for (const id of shapeIds) { 98 + const shape = shapes.get(id); 99 + if (shape) shapes.set(id, { ...shape, fontFamily }); 100 + } 101 + return { ...state, shapes }; 102 + } 103 + 104 + /** 105 + * Set font size on shapes. 106 + */ 107 + export function setShapeFontSize( 108 + state: WhiteboardState, 109 + shapeIds: Iterable<string>, 110 + fontSize: number, 111 + ): WhiteboardState { 112 + const shapes = new Map(state.shapes); 113 + for (const id of shapeIds) { 114 + const shape = shapes.get(id); 115 + if (shape) shapes.set(id, { ...shape, fontSize: Math.max(8, fontSize) }); 116 + } 117 + return { ...state, shapes }; 118 + } 119 + 120 + // --- Group queries --- 121 + 122 + /** 123 + * Ungroup shapes — remove groupId from all shapes in the group. 124 + */ 125 + export function ungroupShapes( 126 + state: WhiteboardState, 127 + groupId: string, 128 + ): WhiteboardState { 129 + const shapes = new Map(state.shapes); 130 + for (const [id, shape] of shapes) { 131 + if (shape.groupId === groupId) { 132 + shapes.set(id, { ...shape, groupId: undefined }); 133 + } 134 + } 135 + return { ...state, shapes }; 136 + } 137 + 138 + /** 139 + * Get all shape IDs in a group. 140 + */ 141 + export function getGroupMembers( 142 + state: WhiteboardState, 143 + groupId: string, 144 + ): string[] { 145 + const result: string[] = []; 146 + for (const [id, shape] of state.shapes) { 147 + if (shape.groupId === groupId) result.push(id); 148 + } 149 + return result; 150 + }
+53
src/diagrams/whiteboard-types.ts
··· 1 + /** 2 + * Whiteboard type definitions — shape model, arrow endpoints, board state. 3 + */ 4 + 5 + export type ShapeKind = 'rectangle' | 'ellipse' | 'diamond' | 'text' | 'freehand' | 'line' | 'triangle' | 'star' | 'hexagon' | 'cloud' | 'cylinder' | 'parallelogram' | 'note'; 6 + export type ArrowEndpoint = { shapeId: string; anchor: 'top' | 'bottom' | 'left' | 'right' | 'center' } | { x: number; y: number }; 7 + 8 + export interface Point { 9 + x: number; 10 + y: number; 11 + } 12 + 13 + export interface Shape { 14 + id: string; 15 + kind: ShapeKind; 16 + x: number; 17 + y: number; 18 + width: number; 19 + height: number; 20 + rotation: number; 21 + label: string; 22 + style: Record<string, string>; 23 + opacity: number; 24 + /** Freehand path points (only for freehand shapes) */ 25 + points?: Point[]; 26 + /** Group ID this shape belongs to (undefined = ungrouped) */ 27 + groupId?: string; 28 + /** Font family for labels */ 29 + fontFamily?: string; 30 + /** Font size for labels */ 31 + fontSize?: number; 32 + } 33 + 34 + export interface Arrow { 35 + id: string; 36 + from: ArrowEndpoint; 37 + to: ArrowEndpoint; 38 + label: string; 39 + style: Record<string, string>; 40 + } 41 + 42 + export interface WhiteboardState { 43 + shapes: Map<string, Shape>; 44 + arrows: Map<string, Arrow>; 45 + /** Viewport offset */ 46 + panX: number; 47 + panY: number; 48 + zoom: number; 49 + gridSize: number; 50 + snapToGrid: boolean; 51 + } 52 + 53 + export type ResizeHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w';
+143 -527
src/diagrams/whiteboard.ts
··· 3 3 * 4 4 * Pure logic module: shape model, hit testing, snapping, connections. 5 5 * Canvas/SVG rendering handled by the diagrams UI layer. 6 + * 7 + * Implementation is split across focused modules: 8 + * - whiteboard-types.ts — type definitions 9 + * - whiteboard-geometry.ts — snapping, hit testing, resize, freehand, anchors 10 + * - whiteboard-layout.ts — z-order, alignment, distribution, flip 11 + * - whiteboard-transforms.ts — rotation, style, opacity, font, group queries 12 + * - whiteboard.ts (this) — core CRUD, grouping, duplication 13 + * 14 + * All public API is re-exported from this file for backward compatibility. 6 15 */ 7 16 8 - export type ShapeKind = 'rectangle' | 'ellipse' | 'diamond' | 'text' | 'freehand' | 'line' | 'triangle' | 'star' | 'hexagon' | 'cloud' | 'cylinder' | 'parallelogram' | 'note'; 9 - export type ArrowEndpoint = { shapeId: string; anchor: 'top' | 'bottom' | 'left' | 'right' | 'center' } | { x: number; y: number }; 17 + import type { 18 + Shape, 19 + Arrow, 20 + ArrowEndpoint, 21 + ShapeKind, 22 + WhiteboardState, 23 + } from './whiteboard-types.js'; 24 + import { snapPoint, hitTestShape } from './whiteboard-geometry.js'; 10 25 11 - export interface Point { 12 - x: number; 13 - y: number; 14 - } 26 + // --- Re-exports (barrel) --- 15 27 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 - opacity: number; 27 - /** Freehand path points (only for freehand shapes) */ 28 - points?: Point[]; 29 - /** Group ID this shape belongs to (undefined = ungrouped) */ 30 - groupId?: string; 31 - /** Font family for labels */ 32 - fontFamily?: string; 33 - /** Font size for labels */ 34 - fontSize?: number; 35 - } 28 + export type { 29 + ShapeKind, 30 + ArrowEndpoint, 31 + Point, 32 + Shape, 33 + Arrow, 34 + WhiteboardState, 35 + ResizeHandle, 36 + } from './whiteboard-types.js'; 36 37 37 - export interface Arrow { 38 - id: string; 39 - from: ArrowEndpoint; 40 - to: ArrowEndpoint; 41 - label: string; 42 - style: Record<string, string>; 43 - } 38 + export { 39 + snapPoint, 40 + hitTestShape, 41 + shapesInRect, 42 + getResizeHandles, 43 + hitTestResizeHandle, 44 + applyResize, 45 + pointsToCatmullRomPath, 46 + nearestEdgeAnchor, 47 + } from './whiteboard-geometry.js'; 44 48 45 - export interface WhiteboardState { 46 - shapes: Map<string, Shape>; 47 - arrows: Map<string, Arrow>; 48 - /** Viewport offset */ 49 - panX: number; 50 - panY: number; 51 - zoom: number; 52 - gridSize: number; 53 - snapToGrid: boolean; 54 - } 49 + export { 50 + bringToFront, 51 + sendToBack, 52 + bringForward, 53 + sendBackward, 54 + alignShapes, 55 + distributeShapes, 56 + flipShapes, 57 + } from './whiteboard-layout.js'; 58 + 59 + export { 60 + rotateShape, 61 + setShapeRotation, 62 + setShapeStyle, 63 + setShapeOpacity, 64 + setShapeFontFamily, 65 + setShapeFontSize, 66 + ungroupShapes, 67 + getGroupMembers, 68 + } from './whiteboard-transforms.js'; 69 + 70 + // --- ID generation --- 55 71 56 72 let _counter = 0; 73 + 74 + // --- Core CRUD --- 57 75 58 76 /** 59 77 * Create an empty whiteboard. ··· 82 100 height = 80, 83 101 label = '', 84 102 ): WhiteboardState { 85 - const snapped = state.snapToGrid ? snapPoint(x, y, state.gridSize) : { x, y }; 103 + const snapped = state.snapToGrid 104 + ? snapPoint(x, y, state.gridSize) 105 + : { x, y }; 86 106 const shape: Shape = { 87 107 id: `shape-${Date.now()}-${++_counter}`, 88 108 kind, ··· 103 123 /** 104 124 * Remove a shape and its connected arrows. 105 125 */ 106 - export function removeShape(state: WhiteboardState, shapeId: string): WhiteboardState { 126 + export function removeShape( 127 + state: WhiteboardState, 128 + shapeId: string, 129 + ): WhiteboardState { 107 130 const shapes = new Map(state.shapes); 108 131 shapes.delete(shapeId); 109 132 110 133 const arrows = new Map(state.arrows); 111 134 for (const [id, arrow] of arrows) { 112 - const fromConnected = 'shapeId' in arrow.from && arrow.from.shapeId === shapeId; 113 - const toConnected = 'shapeId' in arrow.to && arrow.to.shapeId === shapeId; 135 + const fromConnected = 136 + 'shapeId' in arrow.from && arrow.from.shapeId === shapeId; 137 + const toConnected = 138 + 'shapeId' in arrow.to && arrow.to.shapeId === shapeId; 114 139 if (fromConnected || toConnected) arrows.delete(id); 115 140 } 116 141 ··· 128 153 ): WhiteboardState { 129 154 const shape = state.shapes.get(shapeId); 130 155 if (!shape) return state; 131 - const snapped = state.snapToGrid ? snapPoint(x, y, state.gridSize) : { x, y }; 156 + const snapped = state.snapToGrid 157 + ? snapPoint(x, y, state.gridSize) 158 + : { x, y }; 132 159 const shapes = new Map(state.shapes); 133 160 shapes.set(shapeId, { ...shape, x: snapped.x, y: snapped.y }); 134 161 return { ...state, shapes }; ··· 146 173 const shape = state.shapes.get(shapeId); 147 174 if (!shape) return state; 148 175 const shapes = new Map(state.shapes); 149 - shapes.set(shapeId, { ...shape, width: Math.max(10, width), height: Math.max(10, height) }); 176 + shapes.set(shapeId, { 177 + ...shape, 178 + width: Math.max(10, width), 179 + height: Math.max(10, height), 180 + }); 150 181 return { ...state, shapes }; 151 182 } 152 183 ··· 189 220 /** 190 221 * Remove an arrow. 191 222 */ 192 - export function removeArrow(state: WhiteboardState, arrowId: string): WhiteboardState { 223 + export function removeArrow( 224 + state: WhiteboardState, 225 + arrowId: string, 226 + ): WhiteboardState { 193 227 const arrows = new Map(state.arrows); 194 228 arrows.delete(arrowId); 195 229 return { ...state, arrows }; 196 230 } 197 231 198 - /** 199 - * Snap a point to the grid. 200 - */ 201 - export function snapPoint(x: number, y: number, gridSize: number): Point { 202 - return { 203 - x: Math.round(x / gridSize) * gridSize, 204 - y: Math.round(y / gridSize) * gridSize, 205 - }; 206 - } 232 + // --- Viewport --- 207 233 208 234 /** 209 235 * Toggle snap-to-grid. ··· 215 241 /** 216 242 * Pan the viewport. 217 243 */ 218 - export function pan(state: WhiteboardState, dx: number, dy: number): WhiteboardState { 244 + export function pan( 245 + state: WhiteboardState, 246 + dx: number, 247 + dy: number, 248 + ): WhiteboardState { 219 249 return { ...state, panX: state.panX + dx, panY: state.panY + dy }; 220 250 } 221 251 222 252 /** 223 253 * Zoom the viewport. 224 254 */ 225 - export function setZoom(state: WhiteboardState, zoom: number): WhiteboardState { 255 + export function setZoom( 256 + state: WhiteboardState, 257 + zoom: number, 258 + ): WhiteboardState { 226 259 return { ...state, zoom: Math.max(0.1, Math.min(5, zoom)) }; 227 260 } 228 261 229 - /** 230 - * Hit-test: is a point inside a shape's bounding box? 231 - */ 232 - export function hitTestShape(shape: Shape, px: number, py: number): boolean { 233 - return px >= shape.x && px <= shape.x + shape.width && 234 - py >= shape.y && py <= shape.y + shape.height; 235 - } 262 + // --- Queries --- 236 263 237 264 /** 238 265 * Find the topmost shape at a point (last added = on top). 239 266 */ 240 - export function shapeAtPoint(state: WhiteboardState, px: number, py: number): Shape | null { 267 + export function shapeAtPoint( 268 + state: WhiteboardState, 269 + px: number, 270 + py: number, 271 + ): Shape | null { 241 272 let found: Shape | null = null; 242 273 for (const shape of state.shapes.values()) { 243 274 if (hitTestShape(shape, px, py)) found = shape; ··· 248 279 /** 249 280 * Get arrows connected to a shape. 250 281 */ 251 - export function arrowsForShape(state: WhiteboardState, shapeId: string): Arrow[] { 282 + export function arrowsForShape( 283 + state: WhiteboardState, 284 + shapeId: string, 285 + ): Arrow[] { 252 286 const result: Arrow[] = []; 253 287 for (const arrow of state.arrows.values()) { 254 - const fromConnected = 'shapeId' in arrow.from && arrow.from.shapeId === shapeId; 255 - const toConnected = 'shapeId' in arrow.to && arrow.to.shapeId === shapeId; 288 + const fromConnected = 289 + 'shapeId' in arrow.from && arrow.from.shapeId === shapeId; 290 + const toConnected = 291 + 'shapeId' in arrow.to && arrow.to.shapeId === shapeId; 256 292 if (fromConnected || toConnected) result.push(arrow); 257 293 } 258 294 return result; ··· 261 297 /** 262 298 * Get the bounding box of all shapes. 263 299 */ 264 - export function getBoundingBox(state: WhiteboardState): { x: number; y: number; width: number; height: number } | null { 300 + export function getBoundingBox( 301 + state: WhiteboardState, 302 + ): { x: number; y: number; width: number; height: number } | null { 265 303 if (state.shapes.size === 0) return null; 266 - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; 304 + let minX = Infinity; 305 + let minY = Infinity; 306 + let maxX = -Infinity; 307 + let maxY = -Infinity; 267 308 for (const shape of state.shapes.values()) { 268 309 minX = Math.min(minX, shape.x); 269 310 minY = Math.min(minY, shape.y); ··· 276 317 /** 277 318 * Get shape and arrow counts. 278 319 */ 279 - export function elementCounts(state: WhiteboardState): { shapes: number; arrows: number } { 320 + export function elementCounts( 321 + state: WhiteboardState, 322 + ): { shapes: number; arrows: number } { 280 323 return { shapes: state.shapes.size, arrows: state.arrows.size }; 281 324 } 282 325 283 326 // --- Multi-selection --- 284 - 285 - /** 286 - * Find all shapes whose bounding boxes intersect a rectangle. 287 - */ 288 - export function shapesInRect( 289 - state: WhiteboardState, 290 - rect: { x: number; y: number; width: number; height: number }, 291 - ): string[] { 292 - const result: string[] = []; 293 - for (const shape of state.shapes.values()) { 294 - if (shape.x + shape.width >= rect.x && shape.x <= rect.x + rect.width && 295 - shape.y + shape.height >= rect.y && shape.y <= rect.y + rect.height) { 296 - result.push(shape.id); 297 - } 298 - } 299 - return result; 300 - } 301 327 302 328 /** 303 329 * Move multiple shapes by a delta. ··· 314 340 if (!shape) continue; 315 341 const nx = shape.x + dx; 316 342 const ny = shape.y + dy; 317 - const snapped = state.snapToGrid ? snapPoint(nx, ny, state.gridSize) : { x: nx, y: ny }; 343 + const snapped = state.snapToGrid 344 + ? snapPoint(nx, ny, state.gridSize) 345 + : { x: nx, y: ny }; 318 346 shapes.set(id, { ...shape, x: snapped.x, y: snapped.y }); 319 347 } 320 348 return { ...state, shapes }; ··· 323 351 /** 324 352 * Remove multiple shapes and their connected arrows in one pass. 325 353 */ 326 - export function removeShapes(state: WhiteboardState, shapeIds: Iterable<string>): WhiteboardState { 354 + export function removeShapes( 355 + state: WhiteboardState, 356 + shapeIds: Iterable<string>, 357 + ): WhiteboardState { 327 358 const idSet = new Set(shapeIds); 328 359 if (idSet.size === 0) return state; 329 360 const shapes = new Map(state.shapes); ··· 331 362 332 363 const arrows = new Map(state.arrows); 333 364 for (const [aid, arrow] of arrows) { 334 - const fromConnected = 'shapeId' in arrow.from && idSet.has(arrow.from.shapeId); 335 - const toConnected = 'shapeId' in arrow.to && idSet.has(arrow.to.shapeId); 365 + const fromConnected = 366 + 'shapeId' in arrow.from && idSet.has(arrow.from.shapeId); 367 + const toConnected = 368 + 'shapeId' in arrow.to && idSet.has(arrow.to.shapeId); 336 369 if (fromConnected || toConnected) arrows.delete(aid); 337 370 } 338 371 return { ...state, shapes, arrows }; 339 372 } 340 373 341 - // --- Resize handles --- 342 - 343 - export type ResizeHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'; 344 - 345 - /** 346 - * Get the 8 resize handle positions for a shape. 347 - */ 348 - export function getResizeHandles(shape: Shape): Array<{ handle: ResizeHandle; x: number; y: number }> { 349 - const { x, y, width: w, height: h } = shape; 350 - return [ 351 - { handle: 'nw', x, y }, 352 - { handle: 'n', x: x + w / 2, y }, 353 - { handle: 'ne', x: x + w, y }, 354 - { handle: 'e', x: x + w, y: y + h / 2 }, 355 - { handle: 'se', x: x + w, y: y + h }, 356 - { handle: 's', x: x + w / 2, y: y + h }, 357 - { handle: 'sw', x, y: y + h }, 358 - { handle: 'w', x, y: y + h / 2 }, 359 - ]; 360 - } 361 - 362 - /** 363 - * Check if a point hits any resize handle. 364 - */ 365 - export function hitTestResizeHandle(shape: Shape, px: number, py: number, radius = 6): ResizeHandle | null { 366 - for (const h of getResizeHandles(shape)) { 367 - if (Math.abs(px - h.x) <= radius && Math.abs(py - h.y) <= radius) return h.handle; 368 - } 369 - return null; 370 - } 371 - 372 - /** 373 - * Compute new shape bounds after dragging a resize handle by (dx, dy). 374 - */ 375 - export function applyResize( 376 - shape: { x: number; y: number; width: number; height: number }, 377 - handle: ResizeHandle, 378 - dx: number, 379 - dy: number, 380 - ): { x: number; y: number; width: number; height: number } { 381 - let { x, y, width, height } = shape; 382 - const MIN = 10; 383 - switch (handle) { 384 - case 'se': width += dx; height += dy; break; 385 - case 'e': width += dx; break; 386 - case 's': height += dy; break; 387 - case 'nw': x += dx; y += dy; width -= dx; height -= dy; break; 388 - case 'n': y += dy; height -= dy; break; 389 - case 'ne': y += dy; width += dx; height -= dy; break; 390 - case 'sw': x += dx; width -= dx; height += dy; break; 391 - case 'w': x += dx; width -= dx; break; 392 - } 393 - // Clamp minimum size — if width/height would be < MIN, undo the position shift 394 - if (width < MIN) { if (handle.includes('w')) x -= (MIN - width); width = MIN; } 395 - if (height < MIN) { if (handle.includes('n')) y -= (MIN - height); height = MIN; } 396 - return { x, y, width, height }; 397 - } 398 - 399 - // --- Smoothed freehand --- 400 - 401 - /** 402 - * Convert points to a smooth Catmull-Rom spline SVG path. 403 - */ 404 - export function pointsToCatmullRomPath(points: Point[], tension = 6): string { 405 - if (points.length === 0) return ''; 406 - if (points.length === 1) return `M${points[0].x},${points[0].y}`; 407 - if (points.length === 2) return `M${points[0].x},${points[0].y} L${points[1].x},${points[1].y}`; 408 - 409 - const pts = [points[0], ...points, points[points.length - 1]]; 410 - let d = `M${points[0].x},${points[0].y}`; 411 - 412 - for (let i = 0; i < pts.length - 3; i++) { 413 - const p0 = pts[i], p1 = pts[i + 1], p2 = pts[i + 2], p3 = pts[i + 3]; 414 - const cp1x = p1.x + (p2.x - p0.x) / tension; 415 - const cp1y = p1.y + (p2.y - p0.y) / tension; 416 - const cp2x = p2.x - (p3.x - p1.x) / tension; 417 - const cp2y = p2.y - (p3.y - p1.y) / tension; 418 - d += ` C${cp1x},${cp1y} ${cp2x},${cp2y} ${p2.x},${p2.y}`; 419 - } 420 - return d; 421 - } 422 - 423 - // --- Arrow edge anchors --- 424 - 425 - function clamp(val: number, min: number, max: number): number { 426 - return Math.max(min, Math.min(max, val)); 427 - } 428 - 429 - /** 430 - * Find the nearest edge anchor point on a shape relative to an external point. 431 - */ 432 - export function nearestEdgeAnchor( 433 - shape: Shape, 434 - px: number, 435 - py: number, 436 - ): { anchor: 'top' | 'bottom' | 'left' | 'right'; x: number; y: number } { 437 - const cx = shape.x + shape.width / 2; 438 - const cy = shape.y + shape.height / 2; 439 - const angle = Math.atan2(py - cy, px - cx); 440 - const absAngle = Math.abs(angle); 441 - const aspectThreshold = Math.atan2(shape.height / 2, shape.width / 2); 442 - 443 - if (absAngle < aspectThreshold) { 444 - return { anchor: 'right', x: shape.x + shape.width, y: clamp(cy, shape.y, shape.y + shape.height) }; 445 - } else if (absAngle > Math.PI - aspectThreshold) { 446 - return { anchor: 'left', x: shape.x, y: clamp(cy, shape.y, shape.y + shape.height) }; 447 - } else if (angle < 0) { 448 - return { anchor: 'top', x: clamp(cx, shape.x, shape.x + shape.width), y: shape.y }; 449 - } else { 450 - return { anchor: 'bottom', x: clamp(cx, shape.x, shape.x + shape.width), y: shape.y + shape.height }; 451 - } 452 - } 453 - 454 - // --- Z-order --- 455 - 456 - /** 457 - * Move shapes to the end of the Map (topmost z-order). 458 - */ 459 - export function bringToFront(state: WhiteboardState, shapeIds: Iterable<string>): WhiteboardState { 460 - const idSet = new Set(shapeIds); 461 - if (idSet.size === 0) return state; 462 - const shapes = new Map<string, Shape>(); 463 - const moved: Array<[string, Shape]> = []; 464 - for (const [id, shape] of state.shapes) { 465 - if (idSet.has(id)) { 466 - moved.push([id, shape]); 467 - } else { 468 - shapes.set(id, shape); 469 - } 470 - } 471 - for (const [id, shape] of moved) shapes.set(id, shape); 472 - return { ...state, shapes }; 473 - } 474 - 475 - /** 476 - * Move shapes to the start of the Map (bottommost z-order). 477 - */ 478 - export function sendToBack(state: WhiteboardState, shapeIds: Iterable<string>): WhiteboardState { 479 - const idSet = new Set(shapeIds); 480 - if (idSet.size === 0) return state; 481 - const shapes = new Map<string, Shape>(); 482 - const kept: Array<[string, Shape]> = []; 483 - for (const [id, shape] of state.shapes) { 484 - if (idSet.has(id)) { 485 - shapes.set(id, shape); 486 - } else { 487 - kept.push([id, shape]); 488 - } 489 - } 490 - for (const [id, shape] of kept) shapes.set(id, shape); 491 - return { ...state, shapes }; 492 - } 493 - 494 - /** 495 - * Move a shape one position forward in z-order (swap with the next entry). 496 - */ 497 - export function bringForward(state: WhiteboardState, shapeId: string): WhiteboardState { 498 - if (!state.shapes.has(shapeId)) return state; 499 - const entries = [...state.shapes.entries()]; 500 - const idx = entries.findIndex(([id]) => id === shapeId); 501 - if (idx === entries.length - 1) return state; // already at front 502 - [entries[idx], entries[idx + 1]] = [entries[idx + 1], entries[idx]]; 503 - return { ...state, shapes: new Map(entries) }; 504 - } 505 - 506 - /** 507 - * Move a shape one position backward in z-order (swap with the previous entry). 508 - */ 509 - export function sendBackward(state: WhiteboardState, shapeId: string): WhiteboardState { 510 - if (!state.shapes.has(shapeId)) return state; 511 - const entries = [...state.shapes.entries()]; 512 - const idx = entries.findIndex(([id]) => id === shapeId); 513 - if (idx === 0) return state; // already at back 514 - [entries[idx - 1], entries[idx]] = [entries[idx], entries[idx - 1]]; 515 - return { ...state, shapes: new Map(entries) }; 516 - } 517 - 518 - // --- Alignment & Distribution --- 519 - 520 - /** 521 - * Align multiple shapes along an axis. 522 - */ 523 - export function alignShapes( 524 - state: WhiteboardState, 525 - shapeIds: string[], 526 - alignment: 'left' | 'center-h' | 'right' | 'top' | 'center-v' | 'bottom', 527 - ): WhiteboardState { 528 - const resolved = shapeIds.map(id => state.shapes.get(id)).filter((s): s is Shape => s != null); 529 - if (resolved.length < 2) return state; 530 - 531 - let target: number; 532 - switch (alignment) { 533 - case 'left': 534 - target = Math.min(...resolved.map(s => s.x)); 535 - break; 536 - case 'right': 537 - target = Math.max(...resolved.map(s => s.x + s.width)); 538 - break; 539 - case 'center-h': 540 - target = resolved.reduce((sum, s) => sum + s.x + s.width / 2, 0) / resolved.length; 541 - break; 542 - case 'top': 543 - target = Math.min(...resolved.map(s => s.y)); 544 - break; 545 - case 'bottom': 546 - target = Math.max(...resolved.map(s => s.y + s.height)); 547 - break; 548 - case 'center-v': 549 - target = resolved.reduce((sum, s) => sum + s.y + s.height / 2, 0) / resolved.length; 550 - break; 551 - } 552 - 553 - const shapes = new Map(state.shapes); 554 - for (const s of resolved) { 555 - let nx = s.x; 556 - let ny = s.y; 557 - switch (alignment) { 558 - case 'left': nx = target; break; 559 - case 'right': nx = target - s.width; break; 560 - case 'center-h': nx = target - s.width / 2; break; 561 - case 'top': ny = target; break; 562 - case 'bottom': ny = target - s.height; break; 563 - case 'center-v': ny = target - s.height / 2; break; 564 - } 565 - shapes.set(s.id, { ...s, x: nx, y: ny }); 566 - } 567 - return { ...state, shapes }; 568 - } 374 + // --- Grouping --- 569 375 570 376 /** 571 - * Evenly distribute shapes along an axis. 572 - * Shapes are sorted by position, then spaced evenly between the first and last. 377 + * Group shapes together. Returns new state + the group ID. 573 378 */ 574 - export function distributeShapes( 379 + export function groupShapes( 575 380 state: WhiteboardState, 576 381 shapeIds: string[], 577 - axis: 'horizontal' | 'vertical', 578 - ): WhiteboardState { 579 - const resolved = shapeIds.map(id => state.shapes.get(id)).filter((s): s is Shape => s != null); 580 - if (resolved.length < 3) return state; 581 - 582 - const sorted = [...resolved].sort((a, b) => 583 - axis === 'horizontal' ? a.x - b.x : a.y - b.y, 584 - ); 585 - 586 - const first = sorted[0]; 587 - const last = sorted[sorted.length - 1]; 588 - 589 - let totalSpan: number; 590 - let totalShapeSize: number; 591 - if (axis === 'horizontal') { 592 - totalSpan = (last.x + last.width) - first.x; 593 - totalShapeSize = sorted.reduce((sum, s) => sum + s.width, 0); 594 - } else { 595 - totalSpan = (last.y + last.height) - first.y; 596 - totalShapeSize = sorted.reduce((sum, s) => sum + s.height, 0); 597 - } 598 - 599 - const gap = (totalSpan - totalShapeSize) / (sorted.length - 1); 600 - const shapes = new Map(state.shapes); 601 - 602 - let cursor = axis === 'horizontal' ? first.x : first.y; 603 - for (const s of sorted) { 604 - if (axis === 'horizontal') { 605 - shapes.set(s.id, { ...s, x: cursor }); 606 - cursor += s.width + gap; 607 - } else { 608 - shapes.set(s.id, { ...s, y: cursor }); 609 - cursor += s.height + gap; 610 - } 611 - } 612 - return { ...state, shapes }; 613 - } 614 - 615 - // --- Flip --- 616 - 617 - /** 618 - * Flip shape positions around the center of their collective bounding box. 619 - * For horizontal: mirror x positions. For vertical: mirror y positions. 620 - */ 621 - export function flipShapes( 622 - state: WhiteboardState, 623 - shapeIds: string[], 624 - axis: 'horizontal' | 'vertical', 625 - ): WhiteboardState { 626 - const resolved = shapeIds.map(id => state.shapes.get(id)).filter((s): s is Shape => s != null); 627 - if (resolved.length < 2) return state; 628 - 629 - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; 630 - for (const s of resolved) { 631 - minX = Math.min(minX, s.x); 632 - minY = Math.min(minY, s.y); 633 - maxX = Math.max(maxX, s.x + s.width); 634 - maxY = Math.max(maxY, s.y + s.height); 635 - } 636 - 637 - const shapes = new Map(state.shapes); 638 - for (const s of resolved) { 639 - if (axis === 'horizontal') { 640 - const newX = minX + (maxX - (s.x + s.width)); 641 - shapes.set(s.id, { ...s, x: newX }); 642 - } else { 643 - const newY = minY + (maxY - (s.y + s.height)); 644 - shapes.set(s.id, { ...s, y: newY }); 645 - } 646 - } 647 - return { ...state, shapes }; 648 - } 649 - 650 - // --- Grouping --- 651 - 652 - /** 653 - * Group shapes together. Returns new state + the group ID. 654 - */ 655 - export function groupShapes(state: WhiteboardState, shapeIds: string[]): { state: WhiteboardState; groupId: string } { 382 + ): { state: WhiteboardState; groupId: string } { 656 383 if (shapeIds.length < 2) return { state, groupId: '' }; 657 384 const groupId = `group-${Date.now()}-${++_counter}`; 658 385 const shapes = new Map(state.shapes); ··· 663 390 return { state: { ...state, shapes }, groupId }; 664 391 } 665 392 666 - /** 667 - * Ungroup shapes — remove groupId from all shapes in the group. 668 - */ 669 - export function ungroupShapes(state: WhiteboardState, groupId: string): WhiteboardState { 670 - const shapes = new Map(state.shapes); 671 - for (const [id, shape] of shapes) { 672 - if (shape.groupId === groupId) { 673 - shapes.set(id, { ...shape, groupId: undefined }); 674 - } 675 - } 676 - return { ...state, shapes }; 677 - } 678 - 679 - /** 680 - * Get all shape IDs in a group. 681 - */ 682 - export function getGroupMembers(state: WhiteboardState, groupId: string): string[] { 683 - const result: string[] = []; 684 - for (const [id, shape] of state.shapes) { 685 - if (shape.groupId === groupId) result.push(id); 686 - } 687 - return result; 688 - } 689 - 690 - // --- Rotation --- 691 - 692 - /** 693 - * Rotate a shape by a given angle (degrees). Accumulates with existing rotation. 694 - */ 695 - export function rotateShape(state: WhiteboardState, shapeId: string, angle: number): WhiteboardState { 696 - const shape = state.shapes.get(shapeId); 697 - if (!shape) return state; 698 - const shapes = new Map(state.shapes); 699 - shapes.set(shapeId, { ...shape, rotation: ((shape.rotation + angle) % 360 + 360) % 360 }); 700 - return { ...state, shapes }; 701 - } 702 - 703 - /** 704 - * Set a shape's rotation to an absolute value. 705 - */ 706 - export function setShapeRotation(state: WhiteboardState, shapeId: string, rotation: number): WhiteboardState { 707 - const shape = state.shapes.get(shapeId); 708 - if (!shape) return state; 709 - const shapes = new Map(state.shapes); 710 - shapes.set(shapeId, { ...shape, rotation: ((rotation % 360) + 360) % 360 }); 711 - return { ...state, shapes }; 712 - } 713 - 714 - // --- Style --- 715 - 716 - /** 717 - * Update style properties on shapes. 718 - */ 719 - export function setShapeStyle( 720 - state: WhiteboardState, 721 - shapeIds: Iterable<string>, 722 - styleUpdate: Record<string, string>, 723 - ): WhiteboardState { 724 - const shapes = new Map(state.shapes); 725 - for (const id of shapeIds) { 726 - const shape = shapes.get(id); 727 - if (shape) { 728 - shapes.set(id, { ...shape, style: { ...shape.style, ...styleUpdate } }); 729 - } 730 - } 731 - return { ...state, shapes }; 732 - } 733 - 734 - /** 735 - * Set opacity on shapes. 736 - */ 737 - export function setShapeOpacity( 738 - state: WhiteboardState, 739 - shapeIds: Iterable<string>, 740 - opacity: number, 741 - ): WhiteboardState { 742 - const clamped = Math.max(0, Math.min(1, opacity)); 743 - const shapes = new Map(state.shapes); 744 - for (const id of shapeIds) { 745 - const shape = shapes.get(id); 746 - if (shape) shapes.set(id, { ...shape, opacity: clamped }); 747 - } 748 - return { ...state, shapes }; 749 - } 750 - 751 - /** 752 - * Set font family on shapes. 753 - */ 754 - export function setShapeFontFamily( 755 - state: WhiteboardState, 756 - shapeIds: Iterable<string>, 757 - fontFamily: string, 758 - ): WhiteboardState { 759 - const shapes = new Map(state.shapes); 760 - for (const id of shapeIds) { 761 - const shape = shapes.get(id); 762 - if (shape) shapes.set(id, { ...shape, fontFamily }); 763 - } 764 - return { ...state, shapes }; 765 - } 766 - 767 - /** 768 - * Set font size on shapes. 769 - */ 770 - export function setShapeFontSize( 771 - state: WhiteboardState, 772 - shapeIds: Iterable<string>, 773 - fontSize: number, 774 - ): WhiteboardState { 775 - const shapes = new Map(state.shapes); 776 - for (const id of shapeIds) { 777 - const shape = shapes.get(id); 778 - if (shape) shapes.set(id, { ...shape, fontSize: Math.max(8, fontSize) }); 779 - } 780 - return { ...state, shapes }; 781 - } 782 - 783 393 // --- Copy / Duplicate --- 784 394 785 395 /** 786 - * Duplicate shapes with an offset. Returns new state + mapping of old→new IDs. 396 + * Duplicate shapes with an offset. Returns new state + mapping of old->new IDs. 787 397 */ 788 398 export function duplicateShapes( 789 399 state: WhiteboardState, ··· 808 418 y: shape.y + offsetY, 809 419 groupId: undefined, 810 420 style: { ...shape.style }, 811 - points: shape.points ? shape.points.map(p => ({ ...p })) : undefined, 421 + points: shape.points ? shape.points.map((p) => ({ ...p })) : undefined, 812 422 }); 813 423 } 814 424 ··· 824 434 arrows.set(newArrowId, { 825 435 ...arrow, 826 436 id: newArrowId, 827 - from: { ...(arrow.from as any), shapeId: idMap.get(fromId)! }, 828 - to: { ...(arrow.to as any), shapeId: idMap.get(toId)! }, 437 + from: { 438 + ...(arrow.from as { shapeId: string; anchor: string }), 439 + shapeId: idMap.get(fromId)!, 440 + }, 441 + to: { 442 + ...(arrow.to as { shapeId: string; anchor: string }), 443 + shapeId: idMap.get(toId)!, 444 + }, 829 445 }); 830 446 } 831 447 }