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

Configure Feed

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

feat(diagrams): tldraw parity — 27 features for full whiteboard

Complete diagrams whiteboard with tldraw-equivalent feature set:

- Undo/redo (history.ts), copy/paste/duplicate, inline text editing
- Style panel: fill, stroke, width, dash style, opacity, font family/size
- Z-order, group/ungroup, align/distribute, flip, rotation handle
- SVG/PNG export (export.ts), 7 new shapes (triangle, star, hexagon,
cloud, cylinder, parallelogram, note), line tool, highlighter, eraser
- Context menu, keyboard shortcuts dialog, focus mode, hand tool
- Snap guides, edge scrolling, alt+drag duplicate, touch pinch-to-zoom
- Scrollable sheet tabs when many tabs exist

Closes #313-#325, #328-#330, #332-#338, #341-#342, #345-#347

+3239 -54
+32 -4
CHANGELOG.md
··· 5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 7 8 + ## [0.22.0] — 2026-04-03 9 + 10 + ### Added 11 + - Full tldraw-parity feature set for diagrams whiteboard (epic #313) 12 + - Undo/redo system with Cmd+Z/Cmd+Shift+Z, 100-step history (#314) 13 + - Copy/paste/duplicate shapes with Cmd+C/V/D (#315) 14 + - Inline text editing — double-click shapes to edit labels in place (#316) 15 + - Style panel: fill/stroke color, stroke width/style, opacity, font family/size (#317) 16 + - Z-order controls: bring to front/back with Cmd+]/[ (#318) 17 + - Group/ungroup shapes with Cmd+G/Cmd+Shift+G (#319) 18 + - SVG and PNG export for selected shapes or full canvas (#320) 19 + - 7 new shape types: triangle, star, hexagon, cloud, cylinder, parallelogram, note (#321) 20 + - Arrow edge-anchor routing with labels (#322) 21 + - Align (left/center/right/top/middle/bottom) and distribute tools (#323) 22 + - Rotation handle with Shift-snap to 15-degree increments (#324) 23 + - Eraser tool (X key) that removes shapes by drag (#325) 24 + - Smart snap guides: alignment lines when dragging near other shapes (#328) 25 + - Stroke styles: solid, dashed, dotted (#329) 26 + - Font options: sans-serif, serif, monospace, handwriting + configurable size (#330) 27 + - Touch support: pinch-to-zoom, two-finger pan (#332) 28 + - Opacity slider for shapes (#333) 29 + - Line tool (L key): multi-point polyline with click-to-place, double-click to finish (#334) 30 + - Highlighter tool with semi-transparent strokes (#335) 31 + - Sticky note shape with folded corner and yellow default fill (#336) 32 + - Flip shapes horizontally/vertically (#337) 33 + - Right-click context menu with copy, paste, z-order, group, delete (#338) 34 + - Keyboard shortcuts dialog (? or Cmd+Alt+/) (#341) 35 + - Focus mode: hide toolbar/UI for distraction-free editing (Cmd+.) (#342) 36 + - Hand tool (H key) as dedicated pan tool (#345) 37 + - Edge scrolling: auto-pan when dragging near canvas edges (#346) 38 + - Alt+drag to duplicate shapes while dragging (#347) 39 + 8 40 ## [0.21.0] — 2026-04-03 9 41 10 42 ### Added ··· 16 48 - Visual arrow routing with edge anchors and snap-to-target hover feedback (#312) 17 49 - Multi-shape move and batch delete operations 18 50 - Space+drag and middle-click panning 19 - 20 - ### Changed 21 - - Diagram tool mode no longer auto-reverts to select after placing a shape 22 - - Shape selection clicks target shape center for sub-pixel robustness in E2E tests 23 51 24 52 ## [0.20.0] — 2026-04-01 25 53
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.21.0", 3 + "version": "0.22.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+186 -2
src/css/app.css
··· 2689 2689 flex-shrink: 0; 2690 2690 overflow-x: auto; 2691 2691 overflow-y: hidden; 2692 - scrollbar-width: none; 2692 + scrollbar-width: thin; 2693 + scrollbar-color: var(--color-border) transparent; 2693 2694 } 2694 - .sheet-tabs::-webkit-scrollbar { display: none; } 2695 + .sheet-tabs::-webkit-scrollbar { height: 4px; } 2696 + .sheet-tabs::-webkit-scrollbar-thumb { background: var(--color-border); border-radius: 2px; } 2697 + .sheet-tabs::-webkit-scrollbar-track { background: transparent; } 2695 2698 2696 2699 .sheet-tab { 2697 2700 font-family: var(--font-body); ··· 8202 8205 .arrow-hover-target polygon { 8203 8206 stroke: var(--color-teal); 8204 8207 stroke-dasharray: 4 2; 8208 + } 8209 + 8210 + .rotation-handle { 8211 + fill: white; 8212 + stroke: var(--color-primary); 8213 + stroke-width: 1.5; 8214 + cursor: grab; 8215 + } 8216 + .rotation-handle:active { cursor: grabbing; } 8217 + 8218 + .rotation-line { 8219 + pointer-events: none; 8220 + } 8221 + 8222 + .freehand-preview { 8223 + pointer-events: none; 8224 + } 8225 + 8226 + .inline-text-edit textarea { 8227 + width: 100%; 8228 + height: 100%; 8229 + border: none; 8230 + background: transparent; 8231 + resize: none; 8232 + outline: none; 8233 + text-align: center; 8234 + color: var(--color-text); 8235 + padding: 4px; 8236 + box-sizing: border-box; 8237 + } 8238 + 8239 + .diagram-arrow { 8240 + cursor: pointer; 8241 + } 8242 + 8243 + /* Style panel (right sidebar) */ 8244 + .diagrams-style-panel { 8245 + position: absolute; 8246 + top: var(--space-sm); 8247 + right: var(--space-sm); 8248 + width: 220px; 8249 + background: var(--color-surface); 8250 + border: 1px solid var(--color-border); 8251 + border-radius: var(--radius-md); 8252 + box-shadow: var(--shadow-md); 8253 + padding: var(--space-sm); 8254 + z-index: 10; 8255 + } 8256 + 8257 + .diagrams-style-row { 8258 + display: flex; 8259 + gap: var(--space-sm); 8260 + margin-bottom: var(--space-xs); 8261 + } 8262 + .diagrams-style-row label { 8263 + flex: 1; 8264 + display: flex; 8265 + flex-direction: column; 8266 + font-size: 0.7rem; 8267 + color: var(--color-text-muted); 8268 + gap: 2px; 8269 + } 8270 + .diagrams-style-row input[type="color"] { 8271 + width: 100%; 8272 + height: 24px; 8273 + border: 1px solid var(--color-border); 8274 + border-radius: var(--radius-sm); 8275 + padding: 1px; 8276 + cursor: pointer; 8277 + background: none; 8278 + } 8279 + .diagrams-style-row select, 8280 + .diagrams-style-row input[type="number"] { 8281 + width: 100%; 8282 + padding: 2px 4px; 8283 + border: 1px solid var(--color-border); 8284 + border-radius: var(--radius-sm); 8285 + background: var(--color-bg); 8286 + color: var(--color-text); 8287 + font-size: 0.75rem; 8288 + } 8289 + .diagrams-style-row input[type="range"] { 8290 + width: 100%; 8291 + accent-color: var(--color-primary); 8292 + } 8293 + 8294 + /* Toolbar divider for diagrams */ 8295 + .diagrams-toolbar .toolbar-divider { 8296 + width: 1px; 8297 + height: 20px; 8298 + background: var(--color-border); 8299 + margin: 0 4px; 8300 + } 8301 + 8302 + /* Context menu */ 8303 + .diagrams-context-menu { 8304 + position: fixed; 8305 + background: var(--color-surface); 8306 + border: 1px solid var(--color-border); 8307 + border-radius: var(--radius-md); 8308 + box-shadow: var(--shadow-lg); 8309 + padding: 4px 0; 8310 + z-index: 100; 8311 + min-width: 160px; 8312 + } 8313 + .diagrams-context-menu button { 8314 + display: flex; 8315 + align-items: center; 8316 + gap: var(--space-sm); 8317 + width: 100%; 8318 + padding: 6px 12px; 8319 + border: none; 8320 + background: none; 8321 + color: var(--color-text); 8322 + font-size: 0.8rem; 8323 + cursor: pointer; 8324 + text-align: left; 8325 + } 8326 + .diagrams-context-menu button:hover { 8327 + background: var(--color-btn-hover-bg); 8328 + } 8329 + .diagrams-context-menu .menu-divider { 8330 + height: 1px; 8331 + background: var(--color-border); 8332 + margin: 4px 0; 8333 + } 8334 + .diagrams-context-menu .shortcut { 8335 + margin-left: auto; 8336 + font-size: 0.65rem; 8337 + color: var(--color-text-muted); 8338 + } 8339 + 8340 + /* Snap guides */ 8341 + .snap-guide { 8342 + stroke: var(--color-teal); 8343 + stroke-width: 0.5; 8344 + stroke-dasharray: 3 2; 8345 + pointer-events: none; 8346 + } 8347 + 8348 + /* Shortcuts dialog */ 8349 + .diagrams-shortcuts-dialog { 8350 + position: fixed; 8351 + top: 50%; 8352 + left: 50%; 8353 + transform: translate(-50%, -50%); 8354 + background: var(--color-surface); 8355 + border: 1px solid var(--color-border); 8356 + border-radius: var(--radius-lg); 8357 + box-shadow: var(--shadow-lg); 8358 + padding: var(--space-lg); 8359 + z-index: 200; 8360 + max-width: 480px; 8361 + width: 90vw; 8362 + max-height: 80vh; 8363 + overflow-y: auto; 8364 + } 8365 + .diagrams-shortcuts-dialog h2 { 8366 + font-size: 1rem; 8367 + margin-bottom: var(--space-md); 8368 + } 8369 + .diagrams-shortcuts-dialog .shortcut-row { 8370 + display: flex; 8371 + justify-content: space-between; 8372 + padding: 4px 0; 8373 + font-size: 0.8rem; 8374 + border-bottom: 1px solid var(--color-border-subtle, var(--color-border)); 8375 + } 8376 + .diagrams-shortcuts-dialog kbd { 8377 + background: var(--color-bg); 8378 + border: 1px solid var(--color-border); 8379 + border-radius: var(--radius-sm); 8380 + padding: 1px 6px; 8381 + font-family: var(--font-mono); 8382 + font-size: 0.7rem; 8383 + } 8384 + .diagrams-shortcuts-overlay { 8385 + position: fixed; 8386 + inset: 0; 8387 + background: rgba(0,0,0,0.3); 8388 + z-index: 199; 8205 8389 } 8206 8390 8207 8391 /* ── Comments Sidebar ───────────────────────────────────────────────── */
+448
src/diagrams/export.ts
··· 1 + /** 2 + * Diagram Export — SVG and PNG export for the whiteboard. 3 + * 4 + * Pure SVG generation (exportToSVG) works in any environment. 5 + * PNG export and download helpers require a browser (DOM + Canvas). 6 + */ 7 + 8 + import { pointsToCatmullRomPath } from './whiteboard'; 9 + import type { WhiteboardState, Shape, Arrow, ArrowEndpoint } from './whiteboard'; 10 + 11 + // --------------------------------------------------------------------------- 12 + // Constants 13 + // --------------------------------------------------------------------------- 14 + 15 + const SVG_NS = 'http://www.w3.org/2000/svg'; 16 + const DEFAULT_PADDING = 20; 17 + const DEFAULT_FILL = '#ffffff'; 18 + const DEFAULT_STROKE = '#000000'; 19 + const DEFAULT_STROKE_WIDTH = '2'; 20 + const DEFAULT_FONT_FAMILY = 'system-ui, -apple-system, sans-serif'; 21 + const DEFAULT_FONT_SIZE = '14'; 22 + const ARROWHEAD_ID = 'arrowhead'; 23 + 24 + // --------------------------------------------------------------------------- 25 + // Helpers 26 + // --------------------------------------------------------------------------- 27 + 28 + /** Escape text for inclusion in SVG markup. */ 29 + function escapeXml(text: string): string { 30 + return text 31 + .replace(/&/g, '&amp;') 32 + .replace(/</g, '&lt;') 33 + .replace(/>/g, '&gt;') 34 + .replace(/"/g, '&quot;') 35 + .replace(/'/g, '&apos;'); 36 + } 37 + 38 + /** Resolve an ArrowEndpoint to an {x, y} coordinate. */ 39 + function resolveEndpoint( 40 + endpoint: ArrowEndpoint, 41 + shapes: Map<string, Shape>, 42 + ): { x: number; y: number } | null { 43 + if ('x' in endpoint && 'y' in endpoint) { 44 + return { x: endpoint.x, y: endpoint.y }; 45 + } 46 + 47 + const shape = shapes.get(endpoint.shapeId); 48 + if (!shape) return null; 49 + 50 + const cx = shape.x + shape.width / 2; 51 + const cy = shape.y + shape.height / 2; 52 + 53 + switch (endpoint.anchor) { 54 + case 'top': return { x: cx, y: shape.y }; 55 + case 'bottom': return { x: cx, y: shape.y + shape.height }; 56 + case 'left': return { x: shape.x, y: cy }; 57 + case 'right': return { x: shape.x + shape.width, y: cy }; 58 + case 'center': return { x: cx, y: cy }; 59 + } 60 + } 61 + 62 + /** Check if an arrow is connected to any shape in a set. */ 63 + function arrowConnectsSelected( 64 + arrow: Arrow, 65 + selectedIds: Set<string>, 66 + ): boolean { 67 + const fromSelected = 'shapeId' in arrow.from && selectedIds.has(arrow.from.shapeId); 68 + const toSelected = 'shapeId' in arrow.to && selectedIds.has(arrow.to.shapeId); 69 + return fromSelected && toSelected; 70 + } 71 + 72 + /** Check if an arrow has only free (x,y) endpoints (no shape binding). */ 73 + function arrowIsFreestanding(arrow: Arrow): boolean { 74 + return !('shapeId' in arrow.from) && !('shapeId' in arrow.to); 75 + } 76 + 77 + /** Compute bounding box for a subset of shapes. */ 78 + function computeBounds( 79 + shapes: Iterable<Shape>, 80 + ): { minX: number; minY: number; maxX: number; maxY: number } | null { 81 + let minX = Infinity; 82 + let minY = Infinity; 83 + let maxX = -Infinity; 84 + let maxY = -Infinity; 85 + let any = false; 86 + 87 + for (const shape of shapes) { 88 + any = true; 89 + minX = Math.min(minX, shape.x); 90 + minY = Math.min(minY, shape.y); 91 + maxX = Math.max(maxX, shape.x + shape.width); 92 + maxY = Math.max(maxY, shape.y + shape.height); 93 + 94 + // Freehand points may extend beyond the bounding box 95 + if (shape.points) { 96 + for (const pt of shape.points) { 97 + minX = Math.min(minX, pt.x); 98 + minY = Math.min(minY, pt.y); 99 + maxX = Math.max(maxX, pt.x); 100 + maxY = Math.max(maxY, pt.y); 101 + } 102 + } 103 + } 104 + 105 + return any ? { minX, minY, maxX, maxY } : null; 106 + } 107 + 108 + // --------------------------------------------------------------------------- 109 + // SVG element renderers 110 + // --------------------------------------------------------------------------- 111 + 112 + function renderShapeSvg(shape: Shape): string { 113 + const fill = shape.style.fill ?? DEFAULT_FILL; 114 + const stroke = shape.style.stroke ?? DEFAULT_STROKE; 115 + const strokeWidth = shape.style.strokeWidth ?? DEFAULT_STROKE_WIDTH; 116 + 117 + const cx = shape.x + shape.width / 2; 118 + const cy = shape.y + shape.height / 2; 119 + 120 + // Build rotation transform if needed 121 + const rotation = shape.rotation !== 0 122 + ? ` transform="rotate(${shape.rotation}, ${cx}, ${cy})"` 123 + : ''; 124 + 125 + let element = ''; 126 + 127 + switch (shape.kind) { 128 + case 'rectangle': 129 + element = `<rect x="${shape.x}" y="${shape.y}" width="${shape.width}" height="${shape.height}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${rotation}/>`; 130 + break; 131 + 132 + case 'ellipse': 133 + element = `<ellipse cx="${cx}" cy="${cy}" rx="${shape.width / 2}" ry="${shape.height / 2}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${rotation}/>`; 134 + break; 135 + 136 + case 'diamond': { 137 + const top = `${cx},${shape.y}`; 138 + const right = `${shape.x + shape.width},${cy}`; 139 + const bottom = `${cx},${shape.y + shape.height}`; 140 + const left = `${shape.x},${cy}`; 141 + element = `<polygon points="${top} ${right} ${bottom} ${left}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${rotation}/>`; 142 + break; 143 + } 144 + 145 + case 'text': 146 + // Text shapes render as a <text> element positioned at the center 147 + element = `<text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central" font-family="${DEFAULT_FONT_FAMILY}" font-size="${DEFAULT_FONT_SIZE}" fill="${stroke}"${rotation}>${escapeXml(shape.label)}</text>`; 148 + break; 149 + 150 + case 'triangle': { 151 + const top = `${cx},${shape.y}`; 152 + const right = `${shape.x + shape.width},${shape.y + shape.height}`; 153 + const left = `${shape.x},${shape.y + shape.height}`; 154 + element = `<polygon points="${top} ${right} ${left}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${rotation}/>`; 155 + break; 156 + } 157 + 158 + case 'star': { 159 + const outerR = Math.min(shape.width, shape.height) / 2; 160 + const innerR = outerR * 0.38; 161 + const pts: string[] = []; 162 + for (let i = 0; i < 5; i++) { 163 + const outerAngle = (Math.PI / 2 + (i * 2 * Math.PI) / 5) * -1; 164 + const innerAngle = outerAngle - Math.PI / 5; 165 + pts.push(`${cx + outerR * Math.cos(outerAngle)},${cy + outerR * Math.sin(outerAngle)}`); 166 + pts.push(`${cx + innerR * Math.cos(innerAngle)},${cy + innerR * Math.sin(innerAngle)}`); 167 + } 168 + element = `<polygon points="${pts.join(' ')}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${rotation}/>`; 169 + break; 170 + } 171 + 172 + case 'hexagon': { 173 + const rx = shape.width / 2, ry = shape.height / 2; 174 + const pts: string[] = []; 175 + for (let i = 0; i < 6; i++) { 176 + const angle = (Math.PI / 3) * i - Math.PI / 6; 177 + pts.push(`${cx + rx * Math.cos(angle)},${cy + ry * Math.sin(angle)}`); 178 + } 179 + element = `<polygon points="${pts.join(' ')}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${rotation}/>`; 180 + break; 181 + } 182 + 183 + case 'cloud': { 184 + const w = shape.width, h = shape.height; 185 + const d = `M${shape.x + w*0.25},${shape.y + h*0.8} C${shape.x + w*0.05},${shape.y + h*0.8} ${shape.x},${shape.y + h*0.55} ${shape.x + w*0.15},${shape.y + h*0.45} C${shape.x + w*0.05},${shape.y + h*0.3} ${shape.x + w*0.15},${shape.y + h*0.1} ${shape.x + w*0.35},${shape.y + h*0.2} C${shape.x + w*0.4},${shape.y + h*0.05} ${shape.x + w*0.6},${shape.y + h*0.05} ${shape.x + w*0.65},${shape.y + h*0.2} C${shape.x + w*0.85},${shape.y + h*0.1} ${shape.x + w*0.95},${shape.y + h*0.3} ${shape.x + w*0.85},${shape.y + h*0.45} C${shape.x + w},${shape.y + h*0.55} ${shape.x + w*0.95},${shape.y + h*0.8} ${shape.x + w*0.75},${shape.y + h*0.8} Z`; 186 + element = `<path d="${d}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${rotation}/>`; 187 + break; 188 + } 189 + 190 + case 'cylinder': { 191 + const ry = shape.height * 0.12; 192 + const bodyY = shape.y + ry; 193 + const bodyH = shape.height - 2 * ry; 194 + element = `<rect x="${shape.x}" y="${bodyY}" width="${shape.width}" height="${bodyH}" fill="${fill}" stroke="none"${rotation}/>`; 195 + element += `\n <line x1="${shape.x}" y1="${bodyY}" x2="${shape.x}" y2="${shape.y + shape.height - ry}" stroke="${stroke}" stroke-width="${strokeWidth}"/>`; 196 + element += `\n <line x1="${shape.x + shape.width}" y1="${bodyY}" x2="${shape.x + shape.width}" y2="${shape.y + shape.height - ry}" stroke="${stroke}" stroke-width="${strokeWidth}"/>`; 197 + element += `\n <ellipse cx="${cx}" cy="${bodyY}" rx="${shape.width / 2}" ry="${ry}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"/>`; 198 + element += `\n <path d="M${shape.x},${shape.y + shape.height - ry} A${shape.width / 2},${ry} 0 0,0 ${shape.x + shape.width},${shape.y + shape.height - ry}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"/>`; 199 + break; 200 + } 201 + 202 + case 'parallelogram': { 203 + const skew = shape.width * 0.2; 204 + const tl = `${shape.x + skew},${shape.y}`; 205 + const tr = `${shape.x + shape.width},${shape.y}`; 206 + const br = `${shape.x + shape.width - skew},${shape.y + shape.height}`; 207 + const bl = `${shape.x},${shape.y + shape.height}`; 208 + element = `<polygon points="${tl} ${tr} ${br} ${bl}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${rotation}/>`; 209 + break; 210 + } 211 + 212 + case 'note': { 213 + const fold = Math.min(shape.width, shape.height) * 0.15; 214 + const noteFill = fill === DEFAULT_FILL ? '#fef08a' : fill; 215 + element = `<path d="M${shape.x},${shape.y} H${shape.x + shape.width - fold} L${shape.x + shape.width},${shape.y + fold} V${shape.y + shape.height} H${shape.x} Z" fill="${noteFill}" stroke="${stroke}" stroke-width="${strokeWidth}"${rotation}/>`; 216 + element += `\n <path d="M${shape.x + shape.width - fold},${shape.y} V${shape.y + fold} H${shape.x + shape.width}" fill="none" stroke="${stroke}" stroke-width="1"/>`; 217 + break; 218 + } 219 + 220 + case 'line': { 221 + const pts = shape.points ?? []; 222 + if (pts.length >= 2) { 223 + const pointsStr = pts.map(p => `${shape.x + p.x},${shape.y + p.y}`).join(' '); 224 + element = `<polyline points="${pointsStr}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round"${rotation}/>`; 225 + } 226 + break; 227 + } 228 + 229 + case 'freehand': { 230 + const d = pointsToCatmullRomPath(shape.points ?? []); 231 + if (d) { 232 + element = `<path d="${d}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round"${rotation}/>`; 233 + } 234 + break; 235 + } 236 + } 237 + 238 + // Add centered label for non-text shapes that have one 239 + if (shape.kind !== 'text' && shape.label) { 240 + element += `\n <text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central" font-family="${DEFAULT_FONT_FAMILY}" font-size="${DEFAULT_FONT_SIZE}" fill="${stroke}">${escapeXml(shape.label)}</text>`; 241 + } 242 + 243 + return element; 244 + } 245 + 246 + function renderArrowSvg( 247 + arrow: Arrow, 248 + shapes: Map<string, Shape>, 249 + ): string { 250 + const from = resolveEndpoint(arrow.from, shapes); 251 + const to = resolveEndpoint(arrow.to, shapes); 252 + if (!from || !to) return ''; 253 + 254 + const stroke = arrow.style.stroke ?? DEFAULT_STROKE; 255 + const strokeWidth = arrow.style.strokeWidth ?? DEFAULT_STROKE_WIDTH; 256 + 257 + let svg = `<line x1="${from.x}" y1="${from.y}" x2="${to.x}" y2="${to.y}" stroke="${stroke}" stroke-width="${strokeWidth}" marker-end="url(#${ARROWHEAD_ID})"/>`; 258 + 259 + if (arrow.label) { 260 + const mx = (from.x + to.x) / 2; 261 + const my = (from.y + to.y) / 2; 262 + svg += `\n <text x="${mx}" y="${my - 8}" text-anchor="middle" font-family="${DEFAULT_FONT_FAMILY}" font-size="${DEFAULT_FONT_SIZE}" fill="${stroke}">${escapeXml(arrow.label)}</text>`; 263 + } 264 + 265 + return svg; 266 + } 267 + 268 + function arrowMarkerDef(): string { 269 + return `<defs> 270 + <marker id="${ARROWHEAD_ID}" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto" markerUnits="strokeWidth"> 271 + <polygon points="0 0, 10 3.5, 0 7" fill="${DEFAULT_STROKE}"/> 272 + </marker> 273 + </defs>`; 274 + } 275 + 276 + // --------------------------------------------------------------------------- 277 + // Public API 278 + // --------------------------------------------------------------------------- 279 + 280 + /** 281 + * Generate a standalone SVG string from whiteboard state. 282 + * 283 + * If `selectedIds` is provided, only those shapes and arrows connecting 284 + * them are included. Otherwise all elements are exported. 285 + */ 286 + export function exportToSVG( 287 + state: WhiteboardState, 288 + selectedIds?: Set<string>, 289 + ): string { 290 + // Determine which shapes to include 291 + const exportShapes: Shape[] = []; 292 + for (const shape of state.shapes.values()) { 293 + if (!selectedIds || selectedIds.has(shape.id)) { 294 + exportShapes.push(shape); 295 + } 296 + } 297 + 298 + // Determine which arrows to include 299 + const exportArrows: Arrow[] = []; 300 + for (const arrow of state.arrows.values()) { 301 + if (selectedIds) { 302 + // Include only arrows where both endpoints are selected shapes 303 + if (arrowConnectsSelected(arrow, selectedIds)) { 304 + exportArrows.push(arrow); 305 + } 306 + } else { 307 + // Include all arrows 308 + exportArrows.push(arrow); 309 + } 310 + } 311 + 312 + // Compute viewBox 313 + const bounds = computeBounds(exportShapes); 314 + let vx: number, vy: number, vw: number, vh: number; 315 + if (bounds) { 316 + vx = bounds.minX - DEFAULT_PADDING; 317 + vy = bounds.minY - DEFAULT_PADDING; 318 + vw = (bounds.maxX - bounds.minX) + DEFAULT_PADDING * 2; 319 + vh = (bounds.maxY - bounds.minY) + DEFAULT_PADDING * 2; 320 + } else { 321 + // Default viewBox for empty diagrams 322 + vx = 0; 323 + vy = 0; 324 + vw = 400; 325 + vh = 300; 326 + } 327 + 328 + // Build SVG 329 + const parts: string[] = []; 330 + parts.push(`<svg xmlns="${SVG_NS}" viewBox="${vx} ${vy} ${vw} ${vh}" width="${vw}" height="${vh}">`); 331 + 332 + // Include arrowhead marker def if there are arrows 333 + if (exportArrows.length > 0) { 334 + parts.push(arrowMarkerDef()); 335 + } 336 + 337 + // Render shapes 338 + for (const shape of exportShapes) { 339 + parts.push(renderShapeSvg(shape)); 340 + } 341 + 342 + // Render arrows (after shapes so they draw on top) 343 + const shapeMap = new Map(exportShapes.map(s => [s.id, s])); 344 + // Use the full shapes map for resolving endpoints (arrow targets may reference 345 + // shapes outside the export set for coordinate resolution) 346 + for (const arrow of exportArrows) { 347 + const rendered = renderArrowSvg(arrow, state.shapes); 348 + if (rendered) parts.push(rendered); 349 + } 350 + 351 + parts.push('</svg>'); 352 + return parts.join('\n'); 353 + } 354 + 355 + /** 356 + * Convert an SVG string to a PNG Blob at the given scale. 357 + * 358 + * Browser-only: requires HTMLCanvasElement and Image. 359 + */ 360 + export function exportToPNG(svgString: string, scale = 2): Promise<Blob> { 361 + return new Promise((resolve, reject) => { 362 + const match = svgString.match(/width="(\d+(?:\.\d+)?)" height="(\d+(?:\.\d+)?)"/); 363 + if (!match) { 364 + reject(new Error('Cannot determine SVG dimensions from width/height attributes')); 365 + return; 366 + } 367 + 368 + const baseWidth = parseFloat(match[1] ?? '0'); 369 + const baseHeight = parseFloat(match[2] ?? '0'); 370 + const width = baseWidth * scale; 371 + const height = baseHeight * scale; 372 + 373 + const canvas = document.createElement('canvas'); 374 + canvas.width = width; 375 + canvas.height = height; 376 + const ctx = canvas.getContext('2d'); 377 + if (!ctx) { 378 + reject(new Error('Failed to get canvas 2d context')); 379 + return; 380 + } 381 + 382 + const img = new Image(); 383 + const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' }); 384 + const url = URL.createObjectURL(blob); 385 + 386 + img.onload = () => { 387 + ctx.drawImage(img, 0, 0, width, height); 388 + URL.revokeObjectURL(url); 389 + canvas.toBlob((pngBlob) => { 390 + if (pngBlob) { 391 + resolve(pngBlob); 392 + } else { 393 + reject(new Error('Canvas toBlob returned null')); 394 + } 395 + }, 'image/png'); 396 + }; 397 + 398 + img.onerror = () => { 399 + URL.revokeObjectURL(url); 400 + reject(new Error('Failed to load SVG as image')); 401 + }; 402 + 403 + img.src = url; 404 + }); 405 + } 406 + 407 + /** 408 + * Trigger a browser file download. 409 + * 410 + * Browser-only: creates a temporary anchor element. 411 + */ 412 + export function downloadFile(blob: Blob, filename: string): void { 413 + const url = URL.createObjectURL(blob); 414 + const a = document.createElement('a'); 415 + a.href = url; 416 + a.download = filename; 417 + document.body.appendChild(a); 418 + a.click(); 419 + document.body.removeChild(a); 420 + URL.revokeObjectURL(url); 421 + } 422 + 423 + /** 424 + * Export the whiteboard to SVG and download it. 425 + */ 426 + export function exportAndDownloadSVG( 427 + state: WhiteboardState, 428 + filename = 'diagram.svg', 429 + selectedIds?: Set<string>, 430 + ): void { 431 + const svg = exportToSVG(state, selectedIds); 432 + const blob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }); 433 + downloadFile(blob, filename); 434 + } 435 + 436 + /** 437 + * Export the whiteboard to PNG and download it. 438 + */ 439 + export async function exportAndDownloadPNG( 440 + state: WhiteboardState, 441 + filename = 'diagram.png', 442 + selectedIds?: Set<string>, 443 + scale = 2, 444 + ): Promise<void> { 445 + const svg = exportToSVG(state, selectedIds); 446 + const blob = await exportToPNG(svg, scale); 447 + downloadFile(blob, filename); 448 + }
+91
src/diagrams/history.ts
··· 1 + /** 2 + * Undo/redo history for the diagrams whiteboard. 3 + * 4 + * Stores deep-cloned snapshots of WhiteboardState via JSON serialization. 5 + * Maps are serialized as [key, value] entry arrays and restored on deserialize. 6 + */ 7 + 8 + import type { WhiteboardState } from './whiteboard'; 9 + 10 + const MAX_DEPTH = 100; 11 + 12 + /** JSON-safe representation of WhiteboardState (Maps replaced with entry arrays). */ 13 + type Serialized = Omit<WhiteboardState, 'shapes' | 'arrows'> & { 14 + shapes: [string, WhiteboardState['shapes'] extends Map<string, infer V> ? V : never][]; 15 + arrows: [string, WhiteboardState['arrows'] extends Map<string, infer V> ? V : never][]; 16 + }; 17 + 18 + function serialize(state: WhiteboardState): string { 19 + const plain: Serialized = { 20 + ...state, 21 + shapes: [...state.shapes.entries()], 22 + arrows: [...state.arrows.entries()], 23 + }; 24 + return JSON.stringify(plain); 25 + } 26 + 27 + function deserialize(json: string): WhiteboardState { 28 + const plain: Serialized = JSON.parse(json); 29 + return { 30 + ...plain, 31 + shapes: new Map(plain.shapes), 32 + arrows: new Map(plain.arrows), 33 + }; 34 + } 35 + 36 + /** 37 + * Linear undo/redo history with max depth and branch-on-push semantics. 38 + * 39 + * The internal stack stores serialized JSON strings. A cursor index tracks 40 + * the current position. Undo moves the cursor back; redo moves it forward. 41 + * Pushing after an undo truncates the forward (redo) entries. 42 + */ 43 + class History { 44 + private stack: string[] = []; 45 + private cursor = -1; 46 + 47 + /** Record a new state snapshot. Discards any redo entries (branching). */ 48 + push(state: WhiteboardState): void { 49 + // Discard everything after the cursor (branch) 50 + this.stack.length = this.cursor + 1; 51 + this.stack.push(serialize(state)); 52 + this.cursor = this.stack.length - 1; 53 + 54 + // Enforce max depth — drop oldest entries from the front 55 + if (this.stack.length > MAX_DEPTH) { 56 + const excess = this.stack.length - MAX_DEPTH; 57 + this.stack.splice(0, excess); 58 + this.cursor -= excess; 59 + } 60 + } 61 + 62 + /** Move back one step. Returns the previous state, or undefined if at the start. */ 63 + undo(): WhiteboardState | undefined { 64 + if (!this.canUndo()) return undefined; 65 + this.cursor--; 66 + return deserialize(this.stack[this.cursor]!); 67 + } 68 + 69 + /** Move forward one step. Returns the next state, or undefined if at the end. */ 70 + redo(): WhiteboardState | undefined { 71 + if (!this.canRedo()) return undefined; 72 + this.cursor++; 73 + return deserialize(this.stack[this.cursor]!); 74 + } 75 + 76 + canUndo(): boolean { 77 + return this.cursor > 0; 78 + } 79 + 80 + canRedo(): boolean { 81 + return this.cursor < this.stack.length - 1; 82 + } 83 + 84 + /** Drop all history. */ 85 + clear(): void { 86 + this.stack.length = 0; 87 + this.cursor = -1; 88 + } 89 + } 90 + 91 + export default History;
+97 -1
src/diagrams/index.html
··· 26 26 <a class="app-logo" href="/">Tools</a> 27 27 <input class="doc-title-input" id="diagram-title" type="text" value="Untitled Diagram" spellcheck="false"> 28 28 <span class="topbar-spacer"></span> 29 + <!-- Export buttons --> 30 + <button class="btn-icon btn-sm" id="btn-export-svg" title="Export SVG">SVG</button> 31 + <button class="btn-icon btn-sm" id="btn-export-png" title="Export PNG">PNG</button> 29 32 <span class="save-status" id="save-status"></span> 30 33 </div> 31 34 32 35 <main class="diagrams-main" id="main-content"> 33 36 <!-- Toolbar --> 34 37 <div class="diagrams-toolbar" id="diagrams-toolbar"> 38 + <!-- Primary tools --> 35 39 <button class="btn-icon diagrams-tool active" id="tool-select" title="Select (V)" data-tool="select">&#9995;</button> 40 + <button class="btn-icon diagrams-tool" id="tool-hand" title="Hand (H)" data-tool="hand">&#9997;</button> 41 + <span class="toolbar-divider"></span> 42 + 43 + <!-- Shape tools --> 36 44 <button class="btn-icon diagrams-tool" id="tool-rectangle" title="Rectangle (R)" data-tool="rectangle">&#9634;</button> 37 45 <button class="btn-icon diagrams-tool" id="tool-ellipse" title="Ellipse (E)" data-tool="ellipse">&#9711;</button> 38 46 <button class="btn-icon diagrams-tool" id="tool-diamond" title="Diamond (D)" data-tool="diamond">&#9670;</button> 47 + <button class="btn-icon diagrams-tool" id="tool-triangle" title="Triangle" data-tool="triangle">&#9651;</button> 48 + <button class="btn-icon diagrams-tool" id="tool-star" title="Star" data-tool="star">&#9733;</button> 49 + <button class="btn-icon diagrams-tool" id="tool-hexagon" title="Hexagon" data-tool="hexagon">&#11043;</button> 50 + <button class="btn-icon diagrams-tool" id="tool-cylinder" title="Cylinder" data-tool="cylinder">&#9778;</button> 51 + <button class="btn-icon diagrams-tool" id="tool-parallelogram" title="Parallelogram" data-tool="parallelogram">&#9645;</button> 52 + <button class="btn-icon diagrams-tool" id="tool-cloud" title="Cloud" data-tool="cloud">&#9729;</button> 53 + <button class="btn-icon diagrams-tool" id="tool-note" title="Sticky Note (N)" data-tool="note">&#128466;</button> 54 + <span class="toolbar-divider"></span> 55 + 56 + <!-- Drawing tools --> 39 57 <button class="btn-icon diagrams-tool" id="tool-text" title="Text (T)" data-tool="text">T</button> 40 58 <button class="btn-icon diagrams-tool" id="tool-freehand" title="Freehand (P)" data-tool="freehand">&#9997;</button> 59 + <button class="btn-icon diagrams-tool" id="tool-highlighter" title="Highlighter" data-tool="highlighter">&#128396;</button> 60 + <button class="btn-icon diagrams-tool" id="tool-line" title="Line (L)" data-tool="line">&#9585;</button> 41 61 <button class="btn-icon diagrams-tool" id="tool-arrow" title="Arrow (A)" data-tool="arrow">&#8594;</button> 62 + <button class="btn-icon diagrams-tool" id="tool-eraser" title="Eraser (X)" data-tool="eraser">&#128465;</button> 42 63 <span class="toolbar-divider"></span> 64 + 65 + <!-- Canvas controls --> 43 66 <button class="btn-icon" id="btn-snap-grid" title="Toggle grid snap">&#9638;</button> 44 67 <button class="btn-icon" id="btn-zoom-in" title="Zoom in">+</button> 45 68 <span class="diagrams-zoom-label" id="zoom-label">100%</span> 46 69 <button class="btn-icon" id="btn-zoom-out" title="Zoom out">&minus;</button> 47 70 <button class="btn-icon" id="btn-zoom-fit" title="Zoom to fit">&#8690;</button> 48 71 <span class="toolbar-divider"></span> 72 + 73 + <!-- Z-order --> 74 + <button class="btn-icon" id="btn-bring-front" title="Bring to front (Cmd+])">&#8679;</button> 75 + <button class="btn-icon" id="btn-send-back" title="Send to back (Cmd+[)">&#8681;</button> 76 + <span class="toolbar-divider"></span> 77 + 78 + <!-- Group --> 79 + <button class="btn-icon" id="btn-group" title="Group (Cmd+G)">&#9744;</button> 80 + <button class="btn-icon" id="btn-ungroup" title="Ungroup (Cmd+Shift+G)">&#9744;</button> 81 + <span class="toolbar-divider"></span> 82 + 83 + <!-- Flip --> 84 + <button class="btn-icon" id="btn-flip-h" title="Flip horizontal">&#8646;</button> 85 + <button class="btn-icon" id="btn-flip-v" title="Flip vertical">&#8693;</button> 86 + <span class="toolbar-divider"></span> 87 + 88 + <!-- Alignment (shown when multi-select) --> 89 + <button class="btn-icon btn-sm" data-align="left" title="Align left">&#8676;</button> 90 + <button class="btn-icon btn-sm" data-align="center-h" title="Align center">&#8596;</button> 91 + <button class="btn-icon btn-sm" data-align="right" title="Align right">&#8677;</button> 92 + <button class="btn-icon btn-sm" data-align="top" title="Align top">&#8673;</button> 93 + <button class="btn-icon btn-sm" data-align="center-v" title="Align middle">&#8597;</button> 94 + <button class="btn-icon btn-sm" data-align="bottom" title="Align bottom">&#8675;</button> 95 + <button class="btn-icon btn-sm" data-distribute="horizontal" title="Distribute horizontally">&#9776;</button> 96 + <button class="btn-icon btn-sm" data-distribute="vertical" title="Distribute vertically">&#9776;</button> 97 + <span class="toolbar-divider"></span> 98 + 99 + <!-- Actions --> 49 100 <button class="btn-icon" id="btn-delete" title="Delete selected">&#128465;</button> 50 101 </div> 51 102 ··· 62 113 </svg> 63 114 </div> 64 115 65 - <!-- Properties panel (right sidebar, shown when element selected) --> 116 + <!-- Style panel (right sidebar, shown when element selected) --> 117 + <div class="diagrams-style-panel" id="style-panel" style="display:none"> 118 + <h3 class="diagrams-props-title">Style</h3> 119 + <div class="diagrams-style-row"> 120 + <label>Fill <input type="color" id="style-fill" value="#ffffff"></label> 121 + <label>Stroke <input type="color" id="style-stroke" value="#000000"></label> 122 + </div> 123 + <div class="diagrams-style-row"> 124 + <label>Width 125 + <select id="style-stroke-width"> 126 + <option value="1">Thin</option> 127 + <option value="2" selected>Normal</option> 128 + <option value="4">Thick</option> 129 + <option value="6">Heavy</option> 130 + </select> 131 + </label> 132 + <label>Style 133 + <select id="style-stroke-style"> 134 + <option value="solid">Solid</option> 135 + <option value="dashed">Dashed</option> 136 + <option value="dotted">Dotted</option> 137 + </select> 138 + </label> 139 + </div> 140 + <div class="diagrams-style-row"> 141 + <label>Opacity 142 + <input type="range" id="style-opacity" min="0" max="100" value="100"> 143 + <span id="style-opacity-value">100%</span> 144 + </label> 145 + </div> 146 + <div class="diagrams-style-row"> 147 + <label>Font 148 + <select id="style-font-family"> 149 + <option value="system-ui">Sans-serif</option> 150 + <option value="serif">Serif</option> 151 + <option value="monospace">Monospace</option> 152 + <option value="cursive">Handwriting</option> 153 + </select> 154 + </label> 155 + <label>Size 156 + <input type="number" id="style-font-size" value="14" min="8" max="72" step="2"> 157 + </label> 158 + </div> 159 + </div> 160 + 161 + <!-- Properties panel (right sidebar, shown when single element selected) --> 66 162 <div class="diagrams-props" id="props-panel" style="display:none"> 67 163 <h3 class="diagrams-props-title">Properties</h3> 68 164 <label class="diagrams-prop-label">Label
+1119 -45
src/diagrams/main.ts
··· 2 2 /** 3 3 * Tools Diagrams — E2EE collaborative whiteboard/diagrams. 4 4 * Backed by Yjs for real-time collaboration. 5 + * Full tldraw-parity feature set. 5 6 */ 6 7 7 8 import * as Y from 'yjs'; ··· 13 14 hitTestShape, shapeAtPoint, shapesInRect, arrowsForShape, getBoundingBox, 14 15 getResizeHandles, hitTestResizeHandle, applyResize, pointsToCatmullRomPath, 15 16 nearestEdgeAnchor, snapPoint, 17 + bringToFront, sendToBack, bringForward, sendBackward, 18 + alignShapes, distributeShapes, flipShapes, 19 + groupShapes, ungroupShapes, getGroupMembers, 20 + rotateShape, setShapeRotation, 21 + setShapeStyle, setShapeOpacity, setShapeFontFamily, setShapeFontSize, 22 + duplicateShapes, 16 23 } from './whiteboard.js'; 17 24 import type { 18 25 WhiteboardState, Shape, Arrow, ShapeKind, ArrowEndpoint, Point, ResizeHandle, 19 26 } from './whiteboard.js'; 27 + import History from './history.js'; 28 + import { exportToSVG, exportAndDownloadSVG, exportAndDownloadPNG } from './export.js'; 20 29 21 30 // --- DOM refs --- 22 31 const $ = (id: string) => document.getElementById(id)!; ··· 29 38 const propWidth = $('prop-width') as HTMLInputElement; 30 39 const propHeight = $('prop-height') as HTMLInputElement; 31 40 41 + // Style panel refs 42 + const stylePanel = $('style-panel'); 43 + const styleFill = $('style-fill') as HTMLInputElement; 44 + const styleStroke = $('style-stroke') as HTMLInputElement; 45 + const styleStrokeWidth = $('style-stroke-width') as HTMLSelectElement; 46 + const styleStrokeStyle = $('style-stroke-style') as HTMLSelectElement; 47 + const styleOpacity = $('style-opacity') as HTMLInputElement; 48 + const styleOpacityValue = $('style-opacity-value'); 49 + const styleFontFamily = $('style-font-family') as HTMLSelectElement; 50 + const styleFontSize = $('style-font-size') as HTMLInputElement; 51 + 32 52 // --- State --- 33 53 let wb: WhiteboardState = createWhiteboard(); 34 54 let activeTool: string = 'select'; 55 + const history = new History(); 35 56 36 57 // Selection (multi-select) 37 58 let selectedShapeIds: Set<string> = new Set(); 59 + 60 + // Clipboard for copy/paste 61 + let clipboard: { shapes: Shape[]; arrows: Arrow[] } | null = null; 38 62 39 63 // Shape dragging 40 64 let isDragging = false; ··· 64 88 let resizeStart: Point = { x: 0, y: 0 }; 65 89 let resizeShapeOriginal: { x: number; y: number; width: number; height: number } | null = null; 66 90 91 + // Rotation handle 92 + let isRotating = false; 93 + let rotateShapeId: string | null = null; 94 + let rotateStartAngle = 0; 95 + let rotateShapeStartRotation = 0; 96 + 97 + // Alt+drag duplicate 98 + let altDragDuplicated = false; 99 + 67 100 // Arrow drawing 68 101 let isDrawingArrow = false; 69 102 let arrowFromShape: string | null = null; ··· 76 109 // Arrow hover target for visual feedback 77 110 let arrowHoverTargetId: string | null = null; 78 111 112 + // Line drawing (multi-point) 113 + let isDrawingLine = false; 114 + let linePoints: Point[] = []; 115 + 116 + // Eraser mode 117 + let isErasing = false; 118 + 119 + // Inline text editing 120 + let editingShapeId: string | null = null; 121 + 122 + // --- Snap guides --- 123 + const SNAP_THRESHOLD = 6; 124 + 125 + function computeSnapGuides(draggedIds: Set<string>): Array<{ axis: 'h' | 'v'; pos: number; from: number; to: number }> { 126 + const guides: Array<{ axis: 'h' | 'v'; pos: number; from: number; to: number }> = []; 127 + if (draggedIds.size === 0) return guides; 128 + 129 + // Get bounding box of dragged shapes 130 + let dMinX = Infinity, dMinY = Infinity, dMaxX = -Infinity, dMaxY = -Infinity; 131 + for (const id of draggedIds) { 132 + const s = wb.shapes.get(id); 133 + if (!s) continue; 134 + dMinX = Math.min(dMinX, s.x); 135 + dMinY = Math.min(dMinY, s.y); 136 + dMaxX = Math.max(dMaxX, s.x + s.width); 137 + dMaxY = Math.max(dMaxY, s.y + s.height); 138 + } 139 + const dCx = (dMinX + dMaxX) / 2; 140 + const dCy = (dMinY + dMaxY) / 2; 141 + 142 + // Edges/centers to check 143 + const dragEdgesH = [dMinX, dCx, dMaxX]; 144 + const dragEdgesV = [dMinY, dCy, dMaxY]; 145 + 146 + for (const [id, shape] of wb.shapes) { 147 + if (draggedIds.has(id)) continue; 148 + const sEdgesH = [shape.x, shape.x + shape.width / 2, shape.x + shape.width]; 149 + const sEdgesV = [shape.y, shape.y + shape.height / 2, shape.y + shape.height]; 150 + 151 + for (const de of dragEdgesH) { 152 + for (const se of sEdgesH) { 153 + if (Math.abs(de - se) < SNAP_THRESHOLD) { 154 + guides.push({ axis: 'v', pos: se, from: Math.min(dMinY, shape.y), to: Math.max(dMaxY, shape.y + shape.height) }); 155 + } 156 + } 157 + } 158 + for (const de of dragEdgesV) { 159 + for (const se of sEdgesV) { 160 + if (Math.abs(de - se) < SNAP_THRESHOLD) { 161 + guides.push({ axis: 'h', pos: se, from: Math.min(dMinX, shape.x), to: Math.max(dMaxX, shape.x + shape.width) }); 162 + } 163 + } 164 + } 165 + } 166 + return guides; 167 + } 168 + 169 + function renderSnapGuides(guides: Array<{ axis: 'h' | 'v'; pos: number; from: number; to: number }>) { 170 + // Remove existing guides 171 + layer.querySelectorAll('.snap-guide').forEach(el => el.remove()); 172 + for (const g of guides) { 173 + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); 174 + if (g.axis === 'v') { 175 + line.setAttribute('x1', String(g.pos)); 176 + line.setAttribute('y1', String(g.from)); 177 + line.setAttribute('x2', String(g.pos)); 178 + line.setAttribute('y2', String(g.to)); 179 + } else { 180 + line.setAttribute('x1', String(g.from)); 181 + line.setAttribute('y1', String(g.pos)); 182 + line.setAttribute('x2', String(g.to)); 183 + line.setAttribute('y2', String(g.pos)); 184 + } 185 + line.classList.add('snap-guide'); 186 + layer.appendChild(line); 187 + } 188 + } 189 + 190 + function clearSnapGuides() { 191 + layer.querySelectorAll('.snap-guide').forEach(el => el.remove()); 192 + } 193 + 194 + // --- History helpers --- 195 + function pushHistory() { 196 + history.push(wb); 197 + } 198 + 199 + function doUndo() { 200 + const prev = history.undo(); 201 + if (prev) { 202 + wb = prev; 203 + selectedShapeIds = new Set(); 204 + syncToYjs(); 205 + render(); 206 + } 207 + } 208 + 209 + function doRedo() { 210 + const next = history.redo(); 211 + if (next) { 212 + wb = next; 213 + selectedShapeIds = new Set(); 214 + syncToYjs(); 215 + render(); 216 + } 217 + } 218 + 79 219 // --- Yjs setup --- 80 220 const docId = window.location.pathname.split('/').pop() || ''; 81 221 const keyFragment = window.location.hash.slice(1); ··· 130 270 wb.shapes.forEach((shape) => { 131 271 const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); 132 272 g.setAttribute('data-shape-id', shape.id); 133 - g.setAttribute('transform', `translate(${shape.x}, ${shape.y})${shape.rotation ? ` rotate(${shape.rotation})` : ''}`); 273 + g.setAttribute('transform', `translate(${shape.x}, ${shape.y})${shape.rotation ? ` rotate(${shape.rotation}, ${shape.width / 2}, ${shape.height / 2})` : ''}`); 134 274 g.classList.add('diagram-shape'); 135 275 if (selectedShapeIds.has(shape.id)) g.classList.add('selected'); 136 276 if (shape.id === arrowHoverTargetId) g.classList.add('arrow-hover-target'); 277 + if (shape.opacity !== undefined && shape.opacity < 1) { 278 + g.setAttribute('opacity', String(shape.opacity)); 279 + } 137 280 138 281 const fill = shape.style?.fill || 'var(--color-surface)'; 139 282 const stroke = shape.style?.stroke || 'var(--color-text)'; 283 + const strokeWidth = shape.style?.strokeWidth || '2'; 284 + const strokeDasharray = shape.style?.strokeDasharray || ''; 140 285 141 286 switch (shape.kind) { 142 287 case 'rectangle': 143 - appendRect(g, shape.width, shape.height, fill, stroke); 288 + appendRect(g, shape.width, shape.height, fill, stroke, strokeWidth, strokeDasharray); 144 289 break; 145 290 case 'ellipse': 146 - appendEllipse(g, shape.width, shape.height, fill, stroke); 291 + appendEllipse(g, shape.width, shape.height, fill, stroke, strokeWidth, strokeDasharray); 147 292 break; 148 293 case 'diamond': 149 - appendDiamond(g, shape.width, shape.height, fill, stroke); 294 + appendDiamond(g, shape.width, shape.height, fill, stroke, strokeWidth, strokeDasharray); 295 + break; 296 + case 'triangle': 297 + appendTriangle(g, shape.width, shape.height, fill, stroke, strokeWidth, strokeDasharray); 298 + break; 299 + case 'star': 300 + appendStar(g, shape.width, shape.height, fill, stroke, strokeWidth, strokeDasharray); 301 + break; 302 + case 'hexagon': 303 + appendHexagon(g, shape.width, shape.height, fill, stroke, strokeWidth, strokeDasharray); 304 + break; 305 + case 'cloud': 306 + appendCloud(g, shape.width, shape.height, fill, stroke, strokeWidth, strokeDasharray); 307 + break; 308 + case 'cylinder': 309 + appendCylinder(g, shape.width, shape.height, fill, stroke, strokeWidth, strokeDasharray); 310 + break; 311 + case 'parallelogram': 312 + appendParallelogram(g, shape.width, shape.height, fill, stroke, strokeWidth, strokeDasharray); 313 + break; 314 + case 'note': 315 + appendNote(g, shape.width, shape.height, fill, stroke, strokeWidth, strokeDasharray); 150 316 break; 151 317 case 'text': 152 - appendRect(g, shape.width, shape.height, 'transparent', 'transparent'); 318 + appendRect(g, shape.width, shape.height, 'transparent', 'transparent', '0', ''); 319 + break; 320 + case 'line': 321 + if (shape.points && shape.points.length >= 2) { 322 + const polyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); 323 + polyline.setAttribute('points', shape.points.map(p => `${p.x},${p.y}`).join(' ')); 324 + polyline.setAttribute('fill', 'none'); 325 + polyline.setAttribute('stroke', stroke); 326 + polyline.setAttribute('stroke-width', strokeWidth); 327 + polyline.setAttribute('stroke-linecap', 'round'); 328 + polyline.setAttribute('stroke-linejoin', 'round'); 329 + if (strokeDasharray) polyline.setAttribute('stroke-dasharray', strokeDasharray); 330 + g.appendChild(polyline); 331 + } 153 332 break; 154 333 case 'freehand': 155 334 if (shape.points && shape.points.length > 1) { ··· 157 336 path.setAttribute('d', pointsToCatmullRomPath(shape.points)); 158 337 path.setAttribute('fill', 'none'); 159 338 path.setAttribute('stroke', stroke); 160 - path.setAttribute('stroke-width', '2'); 339 + path.setAttribute('stroke-width', strokeWidth); 161 340 path.setAttribute('stroke-linecap', 'round'); 341 + if (strokeDasharray) path.setAttribute('stroke-dasharray', strokeDasharray); 162 342 g.appendChild(path); 163 343 } 164 344 break; 165 345 } 166 346 167 347 // Label 168 - if (shape.label) { 348 + if (shape.label && editingShapeId !== shape.id) { 169 349 const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); 170 350 text.setAttribute('x', String(shape.width / 2)); 171 351 text.setAttribute('y', String(shape.height / 2)); 172 352 text.setAttribute('text-anchor', 'middle'); 173 353 text.setAttribute('dominant-baseline', 'central'); 174 354 text.setAttribute('fill', 'var(--color-text)'); 175 - text.setAttribute('font-size', '14'); 176 - text.setAttribute('font-family', 'system-ui'); 355 + text.setAttribute('font-size', String(shape.fontSize || 14)); 356 + text.setAttribute('font-family', shape.fontFamily || 'system-ui'); 177 357 text.textContent = shape.label; 178 358 g.appendChild(text); 179 359 } ··· 181 361 layer.appendChild(g); 182 362 }); 183 363 184 - // Render resize handles for single selection 185 - if (selectedShapeIds.size === 1) { 364 + // Render resize handles + rotation handle for single selection 365 + if (selectedShapeIds.size === 1 && !editingShapeId) { 186 366 const selId = [...selectedShapeIds][0]; 187 367 const selShape = wb.shapes.get(selId); 188 368 if (selShape) { ··· 196 376 circle.setAttribute('data-handle', h.handle); 197 377 layer.appendChild(circle); 198 378 } 379 + // Rotation handle — circle above the shape 380 + const rotY = selShape.y - 25; 381 + const rotX = selShape.x + selShape.width / 2; 382 + const rotLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); 383 + rotLine.setAttribute('x1', String(rotX)); 384 + rotLine.setAttribute('y1', String(selShape.y)); 385 + rotLine.setAttribute('x2', String(rotX)); 386 + rotLine.setAttribute('y2', String(rotY)); 387 + rotLine.setAttribute('stroke', 'var(--color-primary)'); 388 + rotLine.setAttribute('stroke-width', '1'); 389 + rotLine.classList.add('rotation-line'); 390 + layer.appendChild(rotLine); 391 + 392 + const rotCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); 393 + rotCircle.setAttribute('cx', String(rotX)); 394 + rotCircle.setAttribute('cy', String(rotY)); 395 + rotCircle.setAttribute('r', '5'); 396 + rotCircle.classList.add('rotation-handle'); 397 + layer.appendChild(rotCircle); 199 398 } 200 399 } 201 400 ··· 211 410 line.setAttribute('x2', String(to.x)); 212 411 line.setAttribute('y2', String(to.y)); 213 412 line.setAttribute('stroke', arrow.style?.stroke || 'var(--color-text)'); 214 - line.setAttribute('stroke-width', '2'); 413 + line.setAttribute('stroke-width', arrow.style?.strokeWidth || '2'); 414 + if (arrow.style?.strokeDasharray) line.setAttribute('stroke-dasharray', arrow.style.strokeDasharray); 215 415 line.setAttribute('marker-end', 'url(#arrowhead)'); 216 416 line.classList.add('diagram-arrow'); 217 417 line.setAttribute('data-arrow-id', arrow.id); ··· 264 464 zoomLabel.textContent = `${Math.round(wb.zoom * 100)}%`; 265 465 updateToolbar(); 266 466 updateProps(); 467 + updateStylePanel(); 267 468 } 268 469 269 470 function resolveEndpoint(ep: ArrowEndpoint): Point | null { ··· 281 482 } 282 483 } 283 484 284 - function appendRect(g: SVGGElement, w: number, h: number, fill: string, stroke: string) { 485 + // --- Shape renderers --- 486 + function appendRect(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2', dasharray = '') { 285 487 const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); 286 488 rect.setAttribute('width', String(w)); 287 489 rect.setAttribute('height', String(h)); 288 490 rect.setAttribute('rx', '4'); 289 491 rect.setAttribute('fill', fill); 290 492 rect.setAttribute('stroke', stroke); 291 - rect.setAttribute('stroke-width', '2'); 493 + rect.setAttribute('stroke-width', strokeWidth); 494 + if (dasharray) rect.setAttribute('stroke-dasharray', dasharray); 292 495 g.appendChild(rect); 293 496 } 294 497 295 - function appendEllipse(g: SVGGElement, w: number, h: number, fill: string, stroke: string) { 498 + function appendEllipse(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2', dasharray = '') { 296 499 const el = document.createElementNS('http://www.w3.org/2000/svg', 'ellipse'); 297 500 el.setAttribute('cx', String(w / 2)); 298 501 el.setAttribute('cy', String(h / 2)); ··· 300 503 el.setAttribute('ry', String(h / 2)); 301 504 el.setAttribute('fill', fill); 302 505 el.setAttribute('stroke', stroke); 303 - el.setAttribute('stroke-width', '2'); 506 + el.setAttribute('stroke-width', strokeWidth); 507 + if (dasharray) el.setAttribute('stroke-dasharray', dasharray); 304 508 g.appendChild(el); 305 509 } 306 510 307 - function appendDiamond(g: SVGGElement, w: number, h: number, fill: string, stroke: string) { 511 + function appendDiamond(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2', dasharray = '') { 308 512 const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); 309 513 poly.setAttribute('points', `${w/2},0 ${w},${h/2} ${w/2},${h} 0,${h/2}`); 310 514 poly.setAttribute('fill', fill); 311 515 poly.setAttribute('stroke', stroke); 312 - poly.setAttribute('stroke-width', '2'); 516 + poly.setAttribute('stroke-width', strokeWidth); 517 + if (dasharray) poly.setAttribute('stroke-dasharray', dasharray); 313 518 g.appendChild(poly); 314 519 } 315 520 521 + function appendTriangle(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2', dasharray = '') { 522 + const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); 523 + poly.setAttribute('points', `${w/2},0 ${w},${h} 0,${h}`); 524 + poly.setAttribute('fill', fill); 525 + poly.setAttribute('stroke', stroke); 526 + poly.setAttribute('stroke-width', strokeWidth); 527 + if (dasharray) poly.setAttribute('stroke-dasharray', dasharray); 528 + g.appendChild(poly); 529 + } 530 + 531 + function appendStar(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2', dasharray = '') { 532 + const cx = w / 2, cy = h / 2; 533 + const outerR = Math.min(w, h) / 2; 534 + const innerR = outerR * 0.38; 535 + const pts: string[] = []; 536 + for (let i = 0; i < 5; i++) { 537 + const outerAngle = (Math.PI / 2 + (i * 2 * Math.PI) / 5) * -1; 538 + const innerAngle = outerAngle - Math.PI / 5; 539 + pts.push(`${cx + outerR * Math.cos(outerAngle)},${cy + outerR * Math.sin(outerAngle)}`); 540 + pts.push(`${cx + innerR * Math.cos(innerAngle)},${cy + innerR * Math.sin(innerAngle)}`); 541 + } 542 + const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); 543 + poly.setAttribute('points', pts.join(' ')); 544 + poly.setAttribute('fill', fill); 545 + poly.setAttribute('stroke', stroke); 546 + poly.setAttribute('stroke-width', strokeWidth); 547 + if (dasharray) poly.setAttribute('stroke-dasharray', dasharray); 548 + g.appendChild(poly); 549 + } 550 + 551 + function appendHexagon(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2', dasharray = '') { 552 + const cx = w / 2, cy = h / 2; 553 + const rx = w / 2, ry = h / 2; 554 + const pts: string[] = []; 555 + for (let i = 0; i < 6; i++) { 556 + const angle = (Math.PI / 3) * i - Math.PI / 6; 557 + pts.push(`${cx + rx * Math.cos(angle)},${cy + ry * Math.sin(angle)}`); 558 + } 559 + const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); 560 + poly.setAttribute('points', pts.join(' ')); 561 + poly.setAttribute('fill', fill); 562 + poly.setAttribute('stroke', stroke); 563 + poly.setAttribute('stroke-width', strokeWidth); 564 + if (dasharray) poly.setAttribute('stroke-dasharray', dasharray); 565 + g.appendChild(poly); 566 + } 567 + 568 + function appendCloud(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2', dasharray = '') { 569 + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 570 + const d = `M${w*0.25},${h*0.8} C${w*0.05},${h*0.8} ${w*0.0},${h*0.55} ${w*0.15},${h*0.45} C${w*0.05},${h*0.3} ${w*0.15},${h*0.1} ${w*0.35},${h*0.2} C${w*0.4},${h*0.05} ${w*0.6},${h*0.05} ${w*0.65},${h*0.2} C${w*0.85},${h*0.1} ${w*0.95},${h*0.3} ${w*0.85},${h*0.45} C${w*1.0},${h*0.55} ${w*0.95},${h*0.8} ${w*0.75},${h*0.8} Z`; 571 + path.setAttribute('d', d); 572 + path.setAttribute('fill', fill); 573 + path.setAttribute('stroke', stroke); 574 + path.setAttribute('stroke-width', strokeWidth); 575 + if (dasharray) path.setAttribute('stroke-dasharray', dasharray); 576 + g.appendChild(path); 577 + } 578 + 579 + function appendCylinder(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2', dasharray = '') { 580 + const ry = h * 0.12; 581 + // Body 582 + const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); 583 + rect.setAttribute('x', '0'); 584 + rect.setAttribute('y', String(ry)); 585 + rect.setAttribute('width', String(w)); 586 + rect.setAttribute('height', String(h - 2 * ry)); 587 + rect.setAttribute('fill', fill); 588 + rect.setAttribute('stroke', 'none'); 589 + g.appendChild(rect); 590 + // Side lines 591 + const leftLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); 592 + leftLine.setAttribute('x1', '0'); leftLine.setAttribute('y1', String(ry)); 593 + leftLine.setAttribute('x2', '0'); leftLine.setAttribute('y2', String(h - ry)); 594 + leftLine.setAttribute('stroke', stroke); leftLine.setAttribute('stroke-width', strokeWidth); 595 + if (dasharray) leftLine.setAttribute('stroke-dasharray', dasharray); 596 + g.appendChild(leftLine); 597 + const rightLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); 598 + rightLine.setAttribute('x1', String(w)); rightLine.setAttribute('y1', String(ry)); 599 + rightLine.setAttribute('x2', String(w)); rightLine.setAttribute('y2', String(h - ry)); 600 + rightLine.setAttribute('stroke', stroke); rightLine.setAttribute('stroke-width', strokeWidth); 601 + if (dasharray) rightLine.setAttribute('stroke-dasharray', dasharray); 602 + g.appendChild(rightLine); 603 + // Top ellipse 604 + const topEllipse = document.createElementNS('http://www.w3.org/2000/svg', 'ellipse'); 605 + topEllipse.setAttribute('cx', String(w / 2)); topEllipse.setAttribute('cy', String(ry)); 606 + topEllipse.setAttribute('rx', String(w / 2)); topEllipse.setAttribute('ry', String(ry)); 607 + topEllipse.setAttribute('fill', fill); topEllipse.setAttribute('stroke', stroke); 608 + topEllipse.setAttribute('stroke-width', strokeWidth); 609 + if (dasharray) topEllipse.setAttribute('stroke-dasharray', dasharray); 610 + g.appendChild(topEllipse); 611 + // Bottom ellipse (half, front arc) 612 + const botPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 613 + botPath.setAttribute('d', `M0,${h - ry} A${w/2},${ry} 0 0,0 ${w},${h - ry}`); 614 + botPath.setAttribute('fill', 'none'); botPath.setAttribute('stroke', stroke); 615 + botPath.setAttribute('stroke-width', strokeWidth); 616 + if (dasharray) botPath.setAttribute('stroke-dasharray', dasharray); 617 + g.appendChild(botPath); 618 + } 619 + 620 + function appendParallelogram(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2', dasharray = '') { 621 + const skew = w * 0.2; 622 + const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); 623 + poly.setAttribute('points', `${skew},0 ${w},0 ${w - skew},${h} 0,${h}`); 624 + poly.setAttribute('fill', fill); 625 + poly.setAttribute('stroke', stroke); 626 + poly.setAttribute('stroke-width', strokeWidth); 627 + if (dasharray) poly.setAttribute('stroke-dasharray', dasharray); 628 + g.appendChild(poly); 629 + } 630 + 631 + function appendNote(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2', dasharray = '') { 632 + // Sticky note with folded corner 633 + const fold = Math.min(w, h) * 0.15; 634 + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 635 + path.setAttribute('d', `M0,0 H${w - fold} L${w},${fold} V${h} H0 Z`); 636 + path.setAttribute('fill', fill || '#fef08a'); 637 + path.setAttribute('stroke', stroke); 638 + path.setAttribute('stroke-width', strokeWidth); 639 + if (dasharray) path.setAttribute('stroke-dasharray', dasharray); 640 + g.appendChild(path); 641 + // Fold triangle 642 + const foldPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 643 + foldPath.setAttribute('d', `M${w - fold},0 V${fold} H${w}`); 644 + foldPath.setAttribute('fill', 'none'); 645 + foldPath.setAttribute('stroke', stroke); 646 + foldPath.setAttribute('stroke-width', '1'); 647 + g.appendChild(foldPath); 648 + } 649 + 316 650 function updateToolbar() { 317 651 document.querySelectorAll('.diagrams-tool').forEach(btn => { 318 652 btn.classList.toggle('active', (btn as HTMLElement).dataset.tool === activeTool); ··· 333 667 propHeight.value = String(shape.height); 334 668 } 335 669 670 + function updateStylePanel() { 671 + if (selectedShapeIds.size === 0) { 672 + stylePanel.style.display = 'none'; 673 + return; 674 + } 675 + stylePanel.style.display = ''; 676 + // Show first selected shape's styles 677 + const shape = wb.shapes.get([...selectedShapeIds][0]); 678 + if (!shape) return; 679 + styleFill.value = shape.style?.fill || '#ffffff'; 680 + styleStroke.value = shape.style?.stroke || '#000000'; 681 + styleStrokeWidth.value = shape.style?.strokeWidth || '2'; 682 + styleStrokeStyle.value = shape.style?.strokeDasharray ? (shape.style.strokeDasharray === '8 4' ? 'dashed' : 'dotted') : 'solid'; 683 + styleOpacity.value = String(Math.round((shape.opacity ?? 1) * 100)); 684 + styleOpacityValue.textContent = `${Math.round((shape.opacity ?? 1) * 100)}%`; 685 + styleFontFamily.value = shape.fontFamily || 'system-ui'; 686 + styleFontSize.value = String(shape.fontSize || 14); 687 + } 688 + 336 689 // --- Canvas event handling --- 337 690 function screenToCanvas(sx: number, sy: number): Point { 338 691 const rect = canvas.getBoundingClientRect(); ··· 356 709 g.setAttribute('transform', `translate(${x}, ${y})`); 357 710 358 711 switch (kind) { 359 - case 'rectangle': case 'text': { 712 + case 'rectangle': case 'text': case 'note': { 360 713 const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); 361 714 rect.setAttribute('width', String(w)); 362 715 rect.setAttribute('height', String(h)); ··· 379 732 g.appendChild(poly); 380 733 break; 381 734 } 735 + case 'triangle': { 736 + const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); 737 + poly.setAttribute('points', `${w/2},0 ${w},${h} 0,${h}`); 738 + g.appendChild(poly); 739 + break; 740 + } 741 + default: { 742 + const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); 743 + rect.setAttribute('width', String(w)); 744 + rect.setAttribute('height', String(h)); 745 + rect.setAttribute('rx', '4'); 746 + g.appendChild(rect); 747 + break; 748 + } 382 749 } 383 750 layer.appendChild(g); 384 751 } ··· 404 771 layer.querySelector('.arrow-preview')?.remove(); 405 772 } 406 773 774 + // --- Line preview --- 775 + function renderLinePreview() { 776 + layer.querySelector('.line-preview')?.remove(); 777 + if (linePoints.length < 1) return; 778 + const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); 779 + g.classList.add('line-preview'); 780 + if (linePoints.length >= 2) { 781 + const polyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); 782 + polyline.setAttribute('points', linePoints.map(p => `${p.x},${p.y}`).join(' ')); 783 + polyline.setAttribute('fill', 'none'); 784 + polyline.setAttribute('stroke', 'var(--color-text)'); 785 + polyline.setAttribute('stroke-width', '2'); 786 + g.appendChild(polyline); 787 + } 788 + // Draw dots at each point 789 + for (const p of linePoints) { 790 + const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); 791 + c.setAttribute('cx', String(p.x)); 792 + c.setAttribute('cy', String(p.y)); 793 + c.setAttribute('r', '3'); 794 + c.setAttribute('fill', 'var(--color-primary)'); 795 + g.appendChild(c); 796 + } 797 + layer.appendChild(g); 798 + } 799 + 800 + function finishLine() { 801 + if (linePoints.length < 2) { isDrawingLine = false; linePoints = []; layer.querySelector('.line-preview')?.remove(); return; } 802 + pushHistory(); 803 + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; 804 + linePoints.forEach(p => { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); }); 805 + const normalized = linePoints.map(p => ({ x: p.x - minX, y: p.y - minY })); 806 + wb = addShape(wb, 'line', minX, minY, maxX - minX || 10, maxY - minY || 10); 807 + const shapes = [...wb.shapes.values()]; 808 + const lastShape = shapes[shapes.length - 1]; 809 + if (lastShape) { 810 + wb.shapes.set(lastShape.id, { ...lastShape, points: normalized }); 811 + } 812 + syncToYjs(); 813 + isDrawingLine = false; 814 + linePoints = []; 815 + layer.querySelector('.line-preview')?.remove(); 816 + render(); 817 + } 818 + 819 + // --- Inline text editing --- 820 + function startTextEditing(shapeId: string) { 821 + const shape = wb.shapes.get(shapeId); 822 + if (!shape) return; 823 + editingShapeId = shapeId; 824 + render(); 825 + 826 + // Create a foreignObject with a textarea overlay 827 + const fo = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject'); 828 + fo.setAttribute('x', String(shape.x)); 829 + fo.setAttribute('y', String(shape.y)); 830 + fo.setAttribute('width', String(shape.width)); 831 + fo.setAttribute('height', String(shape.height)); 832 + fo.classList.add('inline-text-edit'); 833 + 834 + const textarea = document.createElement('textarea'); 835 + textarea.value = shape.label || ''; 836 + textarea.style.cssText = `width:100%;height:100%;border:none;background:transparent;resize:none;outline:none;text-align:center;font-size:${shape.fontSize || 14}px;font-family:${shape.fontFamily || 'system-ui'};color:var(--color-text);padding:4px;box-sizing:border-box;`; 837 + textarea.addEventListener('blur', () => finishTextEditing()); 838 + textarea.addEventListener('keydown', (e) => { 839 + if (e.key === 'Escape') { finishTextEditing(); e.preventDefault(); } 840 + e.stopPropagation(); // Prevent shortcut keys 841 + }); 842 + fo.appendChild(textarea); 843 + layer.appendChild(fo); 844 + textarea.focus(); 845 + textarea.select(); 846 + } 847 + 848 + function finishTextEditing() { 849 + if (!editingShapeId) return; 850 + const fo = layer.querySelector('.inline-text-edit'); 851 + if (fo) { 852 + const textarea = fo.querySelector('textarea'); 853 + if (textarea) { 854 + pushHistory(); 855 + wb = setShapeLabel(wb, editingShapeId, textarea.value); 856 + syncToYjs(); 857 + } 858 + fo.remove(); 859 + } 860 + editingShapeId = null; 861 + render(); 862 + } 863 + 407 864 // --- Cursor management --- 408 865 const HANDLE_CURSORS: Record<ResizeHandle, string> = { 409 866 nw: 'nwse-resize', se: 'nwse-resize', ··· 414 871 415 872 function updateCursor(e: MouseEvent) { 416 873 const canvasArea = $('canvas-area'); 874 + if (activeTool === 'eraser') { 875 + canvasArea.style.cursor = 'crosshair'; 876 + return; 877 + } 417 878 if (activeTool !== 'select' || selectedShapeIds.size !== 1) { 418 879 canvasArea.style.cursor = activeTool === 'select' ? '' : 'crosshair'; 419 880 return; ··· 421 882 const pt = screenToCanvas(e.clientX, e.clientY); 422 883 const selShape = wb.shapes.get([...selectedShapeIds][0]); 423 884 if (selShape) { 885 + // Check rotation handle 886 + const rotX = selShape.x + selShape.width / 2; 887 + const rotY = selShape.y - 25; 888 + if (Math.abs(pt.x - rotX) <= 8 && Math.abs(pt.y - rotY) <= 8) { 889 + canvasArea.style.cursor = 'grab'; 890 + return; 891 + } 424 892 const handle = hitTestResizeHandle(selShape, pt.x, pt.y); 425 893 if (handle) { 426 894 canvasArea.style.cursor = HANDLE_CURSORS[handle]; ··· 434 902 canvas.addEventListener('mousedown', (e) => { 435 903 const pt = screenToCanvas(e.clientX, e.clientY); 436 904 437 - // Middle-click or Space+drag = pan 438 - if (e.button === 1 || spaceHeld) { 905 + // Middle-click or Space+drag or Hand tool = pan 906 + if (e.button === 1 || spaceHeld || activeTool === 'hand') { 439 907 isPanning = true; 440 908 panStart = { x: e.clientX, y: e.clientY }; 441 909 panWbStart = { x: wb.panX, y: wb.panY }; ··· 443 911 return; 444 912 } 445 913 914 + if (activeTool === 'eraser') { 915 + isErasing = true; 916 + const hit = shapeAtPoint(wb, pt.x, pt.y); 917 + if (hit) { 918 + pushHistory(); 919 + wb = removeShape(wb, hit.id); 920 + selectedShapeIds.delete(hit.id); 921 + syncToYjs(); 922 + render(); 923 + } 924 + return; 925 + } 926 + 446 927 if (activeTool === 'select') { 447 - // Check resize handles first (single selection) 928 + // Check rotation handle first (single selection) 448 929 if (selectedShapeIds.size === 1) { 449 930 const selId = [...selectedShapeIds][0]; 450 931 const selShape = wb.shapes.get(selId); 451 932 if (selShape) { 933 + const rotX = selShape.x + selShape.width / 2; 934 + const rotY = selShape.y - 25; 935 + if (Math.abs(pt.x - rotX) <= 8 && Math.abs(pt.y - rotY) <= 8) { 936 + isRotating = true; 937 + rotateShapeId = selId; 938 + rotateShapeStartRotation = selShape.rotation; 939 + const cx = selShape.x + selShape.width / 2; 940 + const cy = selShape.y + selShape.height / 2; 941 + rotateStartAngle = Math.atan2(pt.y - cy, pt.x - cx) * 180 / Math.PI; 942 + return; 943 + } 944 + 945 + // Check resize handles 452 946 const handle = hitTestResizeHandle(selShape, pt.x, pt.y); 453 947 if (handle) { 454 948 isResizing = true; ··· 463 957 464 958 const hit = shapeAtPoint(wb, pt.x, pt.y); 465 959 if (hit) { 466 - // Shift+click toggles selection 467 - if (e.shiftKey) { 960 + // If hit shape is in a group, select the whole group 961 + if (hit.groupId && !e.shiftKey) { 962 + const members = getGroupMembers(wb, hit.groupId); 963 + selectedShapeIds = new Set(members); 964 + } else if (e.shiftKey) { 468 965 const newSet = new Set(selectedShapeIds); 469 966 if (newSet.has(hit.id)) newSet.delete(hit.id); 470 967 else newSet.add(hit.id); ··· 474 971 } 475 972 // Start dragging all selected shapes 476 973 isDragging = true; 974 + altDragDuplicated = false; 477 975 dragStart = { x: e.clientX, y: e.clientY }; 478 976 dragShapesStart = new Map(); 977 + // Alt+drag = duplicate first, then drag the copies 978 + if (e.altKey && selectedShapeIds.size > 0) { 979 + pushHistory(); 980 + const result = duplicateShapes(wb, selectedShapeIds); 981 + wb = result.state; 982 + selectedShapeIds = new Set(result.idMap.values()); 983 + altDragDuplicated = true; 984 + syncToYjs(); 985 + } 479 986 for (const id of selectedShapeIds) { 480 987 const s = wb.shapes.get(id); 481 988 if (s) dragShapesStart.set(id, { x: s.x, y: s.y }); ··· 489 996 } 490 997 render(); 491 998 999 + } else if (activeTool === 'line') { 1000 + // Click to add points to line; double-click handled separately to finish 1001 + if (!isDrawingLine) { 1002 + isDrawingLine = true; 1003 + linePoints = [pt]; 1004 + } else { 1005 + linePoints.push(pt); 1006 + } 1007 + // Render line preview 1008 + renderLinePreview(); 1009 + return; 1010 + 492 1011 } else if (activeTool === 'arrow') { 493 1012 const hit = shapeAtPoint(wb, pt.x, pt.y); 494 1013 if (hit) { ··· 497 1016 arrowFromAnchor = nearestEdgeAnchor(hit, pt.x, pt.y); 498 1017 } 499 1018 500 - } else if (activeTool === 'freehand') { 1019 + } else if (activeTool === 'freehand' || activeTool === 'highlighter') { 501 1020 isDrawingFreehand = true; 502 1021 freehandPoints = [pt]; 503 1022 504 1023 } else { 505 1024 // Shape creation tools — start drag-to-create 506 1025 const kind = activeTool as ShapeKind; 507 - if (['rectangle', 'ellipse', 'diamond', 'text'].includes(kind)) { 1026 + const creatableShapes: ShapeKind[] = ['rectangle', 'ellipse', 'diamond', 'text', 'triangle', 'star', 'hexagon', 'cloud', 'cylinder', 'parallelogram', 'note']; 1027 + if (creatableShapes.includes(kind)) { 508 1028 isCreatingShape = true; 509 1029 createShapeKind = kind; 510 1030 createStart = pt; ··· 515 1035 canvas.addEventListener('mousemove', (e) => { 516 1036 updateCursor(e); 517 1037 1038 + if (isErasing && activeTool === 'eraser') { 1039 + const pt = screenToCanvas(e.clientX, e.clientY); 1040 + const hit = shapeAtPoint(wb, pt.x, pt.y); 1041 + if (hit) { 1042 + wb = removeShape(wb, hit.id); 1043 + selectedShapeIds.delete(hit.id); 1044 + syncToYjs(); 1045 + render(); 1046 + } 1047 + return; 1048 + } 1049 + 1050 + if (isRotating && rotateShapeId) { 1051 + const shape = wb.shapes.get(rotateShapeId); 1052 + if (shape) { 1053 + const pt = screenToCanvas(e.clientX, e.clientY); 1054 + const cx = shape.x + shape.width / 2; 1055 + const cy = shape.y + shape.height / 2; 1056 + const currentAngle = Math.atan2(pt.y - cy, pt.x - cx) * 180 / Math.PI; 1057 + let newRotation = rotateShapeStartRotation + (currentAngle - rotateStartAngle); 1058 + // Snap to 15-degree increments if shift held 1059 + if (e.shiftKey) newRotation = Math.round(newRotation / 15) * 15; 1060 + wb = setShapeRotation(wb, rotateShapeId, newRotation); 1061 + render(); 1062 + } 1063 + return; 1064 + } 1065 + 518 1066 if (isDragging && selectedShapeIds.size > 0) { 519 1067 const dx = (e.clientX - dragStart.x) / wb.zoom; 520 1068 const dy = (e.clientY - dragStart.y) / wb.zoom; 521 - // Move all selected shapes from their original positions 522 1069 const shapes = new Map(wb.shapes); 523 1070 for (const [id, startPos] of dragShapesStart) { 524 1071 const shape = shapes.get(id); ··· 530 1077 } 531 1078 wb = { ...wb, shapes }; 532 1079 render(); 1080 + // Show snap guides while dragging 1081 + const guides = computeSnapGuides(selectedShapeIds); 1082 + renderSnapGuides(guides); 1083 + // Edge scrolling while dragging near edges 1084 + startEdgeScroll(e.clientX, e.clientY); 533 1085 534 1086 } else if (isPanning) { 535 1087 const dx = e.clientX - panStart.x; ··· 539 1091 540 1092 } else if (isMarqueeSelecting) { 541 1093 marqueeEnd = screenToCanvas(e.clientX, e.clientY); 542 - // Live preview: select shapes intersecting marquee 543 1094 const mx = Math.min(marqueeStart.x, marqueeEnd.x); 544 1095 const my = Math.min(marqueeStart.y, marqueeEnd.y); 545 1096 const mw = Math.abs(marqueeEnd.x - marqueeStart.x); ··· 571 1122 } else if (isDrawingArrow && arrowFromAnchor) { 572 1123 const pt = screenToCanvas(e.clientX, e.clientY); 573 1124 renderArrowPreview(arrowFromAnchor, pt); 574 - // Highlight hover target 575 1125 const hover = shapeAtPoint(wb, pt.x, pt.y); 576 1126 const newTarget = hover && hover.id !== arrowFromShape ? hover.id : null; 577 1127 if (newTarget !== arrowHoverTargetId) { 578 1128 arrowHoverTargetId = newTarget; 579 1129 render(); 580 - // Re-render arrow preview since render() clears it 581 1130 renderArrowPreview(arrowFromAnchor, pt); 582 1131 } 583 1132 584 1133 } else if (isDrawingFreehand) { 585 1134 const pt = screenToCanvas(e.clientX, e.clientY); 586 1135 freehandPoints.push(pt); 587 - // Live preview with smooth curves 588 1136 let tempPath = layer.querySelector('.freehand-preview') as SVGPathElement | null; 589 1137 if (!tempPath) { 590 1138 tempPath = document.createElementNS('http://www.w3.org/2000/svg', 'path') as SVGPathElement; 591 1139 tempPath.classList.add('freehand-preview'); 592 1140 tempPath.setAttribute('fill', 'none'); 593 - tempPath.setAttribute('stroke', 'var(--color-text)'); 594 - tempPath.setAttribute('stroke-width', '2'); 1141 + tempPath.setAttribute('stroke', activeTool === 'highlighter' ? 'rgba(255,255,0,0.4)' : 'var(--color-text)'); 1142 + tempPath.setAttribute('stroke-width', activeTool === 'highlighter' ? '12' : '2'); 595 1143 tempPath.setAttribute('stroke-linecap', 'round'); 596 1144 layer.appendChild(tempPath); 597 1145 } ··· 600 1148 }); 601 1149 602 1150 canvas.addEventListener('mouseup', (e) => { 1151 + if (isErasing) { 1152 + isErasing = false; 1153 + return; 1154 + } 1155 + 1156 + if (isRotating) { 1157 + pushHistory(); 1158 + isRotating = false; 1159 + rotateShapeId = null; 1160 + syncToYjs(); 1161 + return; 1162 + } 1163 + 603 1164 if (isDragging) { 1165 + if (!altDragDuplicated) pushHistory(); 604 1166 isDragging = false; 605 1167 dragShapesStart.clear(); 1168 + clearSnapGuides(); 1169 + stopEdgeScroll(); 606 1170 syncToYjs(); 607 1171 } 608 1172 ··· 612 1176 613 1177 if (isMarqueeSelecting) { 614 1178 isMarqueeSelecting = false; 615 - // If marquee was tiny (< 5px), treat as click-to-deselect 616 1179 const dx = Math.abs(marqueeEnd.x - marqueeStart.x); 617 1180 const dy = Math.abs(marqueeEnd.y - marqueeStart.y); 618 1181 if (dx < 5 && dy < 5) { ··· 626 1189 const dx = Math.abs(pt.x - createStart.x); 627 1190 const dy = Math.abs(pt.y - createStart.y); 628 1191 1192 + pushHistory(); 629 1193 if (Math.sqrt(dx * dx + dy * dy) < 5) { 630 - // Click (no meaningful drag) — use default size 631 - wb = addShape(wb, createShapeKind, createStart.x, createStart.y, 120, 80, 632 - createShapeKind === 'text' ? 'Text' : ''); 1194 + const defaultLabel = createShapeKind === 'text' ? 'Text' : createShapeKind === 'note' ? 'Note' : ''; 1195 + wb = addShape(wb, createShapeKind, createStart.x, createStart.y, 120, 80, defaultLabel); 633 1196 } else { 634 - // Drag — use dragged bounds 635 1197 const x = Math.min(createStart.x, pt.x); 636 1198 const y = Math.min(createStart.y, pt.y); 637 - wb = addShape(wb, createShapeKind, x, y, Math.max(10, dx), Math.max(10, dy), 638 - createShapeKind === 'text' ? 'Text' : ''); 1199 + const defaultLabel = createShapeKind === 'text' ? 'Text' : createShapeKind === 'note' ? 'Note' : ''; 1200 + wb = addShape(wb, createShapeKind, x, y, Math.max(10, dx), Math.max(10, dy), defaultLabel); 1201 + } 1202 + // Set default note fill 1203 + if (createShapeKind === 'note') { 1204 + const shapes = [...wb.shapes.values()]; 1205 + const lastShape = shapes[shapes.length - 1]; 1206 + if (lastShape) { 1207 + wb = setShapeStyle(wb, [lastShape.id], { fill: '#fef08a' }); 1208 + } 639 1209 } 640 1210 syncToYjs(); 641 1211 removeCreationPreview(); 642 1212 isCreatingShape = false; 643 1213 createShapeKind = null; 644 - // Tool stays active (persistent tool mode) 645 1214 render(); 646 1215 } 647 1216 648 1217 if (isResizing) { 1218 + pushHistory(); 649 1219 isResizing = false; 650 1220 resizeHandle = null; 651 1221 resizeShapeId = null; ··· 657 1227 const pt = screenToCanvas(e.clientX, e.clientY); 658 1228 const hit = shapeAtPoint(wb, pt.x, pt.y); 659 1229 if (hit && hit.id !== arrowFromShape) { 1230 + pushHistory(); 660 1231 const fromAnchor = arrowFromAnchor.anchor as 'top' | 'bottom' | 'left' | 'right'; 661 1232 const toAnchorInfo = nearestEdgeAnchor(hit, arrowFromAnchor.x, arrowFromAnchor.y); 662 1233 wb = addArrow(wb, ··· 674 1245 } 675 1246 676 1247 if (isDrawingFreehand && freehandPoints.length > 2) { 677 - // Calculate bounding box for freehand 1248 + pushHistory(); 678 1249 let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; 679 1250 freehandPoints.forEach(p => { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); }); 680 1251 const normalized = freehandPoints.map(p => ({ x: p.x - minX, y: p.y - minY })); 681 1252 wb = addShape(wb, 'freehand', minX, minY, maxX - minX || 10, maxY - minY || 10); 682 - // Attach points to the last added shape 683 1253 const shapes = [...wb.shapes.values()]; 684 1254 const lastShape = shapes[shapes.length - 1]; 685 1255 if (lastShape) { 686 1256 wb.shapes.set(lastShape.id, { ...lastShape, points: normalized }); 1257 + // Highlighter gets semi-transparent yellow stroke 1258 + if (activeTool === 'highlighter') { 1259 + wb = setShapeStyle(wb, [lastShape.id], { stroke: 'rgba(255,255,0,0.4)', strokeWidth: '12' }); 1260 + } 687 1261 } 688 1262 syncToYjs(); 689 1263 render(); ··· 691 1265 if (preview) preview.remove(); 692 1266 isDrawingFreehand = false; 693 1267 freehandPoints = []; 694 - // Tool stays active (persistent tool mode) 695 1268 } else if (isDrawingFreehand) { 696 1269 isDrawingFreehand = false; 697 1270 freehandPoints = []; ··· 700 1273 } 701 1274 }); 702 1275 1276 + // Double-click for inline text editing or finish line 1277 + canvas.addEventListener('dblclick', (e) => { 1278 + if (activeTool === 'line' && isDrawingLine) { 1279 + finishLine(); 1280 + return; 1281 + } 1282 + if (activeTool !== 'select') return; 1283 + const pt = screenToCanvas(e.clientX, e.clientY); 1284 + const hit = shapeAtPoint(wb, pt.x, pt.y); 1285 + if (hit) { 1286 + startTextEditing(hit.id); 1287 + } 1288 + }); 1289 + 703 1290 // Zoom with wheel 704 1291 canvas.addEventListener('wheel', (e) => { 705 1292 e.preventDefault(); ··· 708 1295 render(); 709 1296 }, { passive: false }); 710 1297 1298 + // Touch support: pinch-to-zoom 1299 + let lastTouchDist = 0; 1300 + let lastTouchCenter: Point = { x: 0, y: 0 }; 1301 + 1302 + canvas.addEventListener('touchstart', (e) => { 1303 + if (e.touches.length === 2) { 1304 + e.preventDefault(); 1305 + const dx = e.touches[1].clientX - e.touches[0].clientX; 1306 + const dy = e.touches[1].clientY - e.touches[0].clientY; 1307 + lastTouchDist = Math.sqrt(dx * dx + dy * dy); 1308 + lastTouchCenter = { 1309 + x: (e.touches[0].clientX + e.touches[1].clientX) / 2, 1310 + y: (e.touches[0].clientY + e.touches[1].clientY) / 2, 1311 + }; 1312 + } 1313 + }, { passive: false }); 1314 + 1315 + canvas.addEventListener('touchmove', (e) => { 1316 + if (e.touches.length === 2) { 1317 + e.preventDefault(); 1318 + const dx = e.touches[1].clientX - e.touches[0].clientX; 1319 + const dy = e.touches[1].clientY - e.touches[0].clientY; 1320 + const dist = Math.sqrt(dx * dx + dy * dy); 1321 + const scale = dist / lastTouchDist; 1322 + wb = setZoom(wb, wb.zoom * scale); 1323 + lastTouchDist = dist; 1324 + 1325 + const center = { 1326 + x: (e.touches[0].clientX + e.touches[1].clientX) / 2, 1327 + y: (e.touches[0].clientY + e.touches[1].clientY) / 2, 1328 + }; 1329 + wb = { ...wb, panX: wb.panX + (center.x - lastTouchCenter.x), panY: wb.panY + (center.y - lastTouchCenter.y) }; 1330 + lastTouchCenter = center; 1331 + render(); 1332 + } 1333 + }, { passive: false }); 1334 + 711 1335 // --- Tool buttons --- 712 1336 document.querySelectorAll('.diagrams-tool').forEach(btn => { 713 1337 btn.addEventListener('click', () => { ··· 733 1357 734 1358 $('btn-delete').addEventListener('click', () => { 735 1359 if (selectedShapeIds.size > 0) { 1360 + pushHistory(); 736 1361 wb = removeShapes(wb, selectedShapeIds); 737 1362 selectedShapeIds = new Set(); 738 1363 syncToYjs(); ··· 740 1365 } 741 1366 }); 742 1367 1368 + // Z-order buttons 1369 + $('btn-bring-front')?.addEventListener('click', () => { 1370 + if (selectedShapeIds.size > 0) { 1371 + pushHistory(); 1372 + wb = bringToFront(wb, selectedShapeIds); 1373 + syncToYjs(); 1374 + render(); 1375 + } 1376 + }); 1377 + $('btn-send-back')?.addEventListener('click', () => { 1378 + if (selectedShapeIds.size > 0) { 1379 + pushHistory(); 1380 + wb = sendToBack(wb, selectedShapeIds); 1381 + syncToYjs(); 1382 + render(); 1383 + } 1384 + }); 1385 + 1386 + // Alignment buttons 1387 + document.querySelectorAll('[data-align]').forEach(btn => { 1388 + btn.addEventListener('click', () => { 1389 + if (selectedShapeIds.size < 2) return; 1390 + pushHistory(); 1391 + const alignment = (btn as HTMLElement).dataset.align as any; 1392 + wb = alignShapes(wb, [...selectedShapeIds], alignment); 1393 + syncToYjs(); 1394 + render(); 1395 + }); 1396 + }); 1397 + 1398 + document.querySelectorAll('[data-distribute]').forEach(btn => { 1399 + btn.addEventListener('click', () => { 1400 + if (selectedShapeIds.size < 3) return; 1401 + pushHistory(); 1402 + const axis = (btn as HTMLElement).dataset.distribute as 'horizontal' | 'vertical'; 1403 + wb = distributeShapes(wb, [...selectedShapeIds], axis); 1404 + syncToYjs(); 1405 + render(); 1406 + }); 1407 + }); 1408 + 1409 + // Group/ungroup 1410 + $('btn-group')?.addEventListener('click', () => { 1411 + if (selectedShapeIds.size < 2) return; 1412 + pushHistory(); 1413 + const result = groupShapes(wb, [...selectedShapeIds]); 1414 + wb = result.state; 1415 + syncToYjs(); 1416 + render(); 1417 + }); 1418 + $('btn-ungroup')?.addEventListener('click', () => { 1419 + if (selectedShapeIds.size === 0) return; 1420 + const shape = wb.shapes.get([...selectedShapeIds][0]); 1421 + if (!shape?.groupId) return; 1422 + pushHistory(); 1423 + wb = ungroupShapes(wb, shape.groupId); 1424 + syncToYjs(); 1425 + render(); 1426 + }); 1427 + 1428 + // Export buttons 1429 + $('btn-export-svg')?.addEventListener('click', () => { 1430 + const ids = selectedShapeIds.size > 0 ? selectedShapeIds : undefined; 1431 + exportAndDownloadSVG(wb, `${diagramTitle.value || 'diagram'}.svg`, ids); 1432 + }); 1433 + $('btn-export-png')?.addEventListener('click', async () => { 1434 + const ids = selectedShapeIds.size > 0 ? selectedShapeIds : undefined; 1435 + await exportAndDownloadPNG(wb, `${diagramTitle.value || 'diagram'}.png`, ids); 1436 + }); 1437 + 1438 + // Style panel events 1439 + styleFill?.addEventListener('input', () => { 1440 + if (selectedShapeIds.size === 0) return; 1441 + pushHistory(); 1442 + wb = setShapeStyle(wb, selectedShapeIds, { fill: styleFill.value }); 1443 + syncToYjs(); 1444 + render(); 1445 + }); 1446 + styleStroke?.addEventListener('input', () => { 1447 + if (selectedShapeIds.size === 0) return; 1448 + pushHistory(); 1449 + wb = setShapeStyle(wb, selectedShapeIds, { stroke: styleStroke.value }); 1450 + syncToYjs(); 1451 + render(); 1452 + }); 1453 + styleStrokeWidth?.addEventListener('change', () => { 1454 + if (selectedShapeIds.size === 0) return; 1455 + pushHistory(); 1456 + wb = setShapeStyle(wb, selectedShapeIds, { strokeWidth: styleStrokeWidth.value }); 1457 + syncToYjs(); 1458 + render(); 1459 + }); 1460 + styleStrokeStyle?.addEventListener('change', () => { 1461 + if (selectedShapeIds.size === 0) return; 1462 + pushHistory(); 1463 + const dashMap: Record<string, string> = { solid: '', dashed: '8 4', dotted: '2 2' }; 1464 + wb = setShapeStyle(wb, selectedShapeIds, { strokeDasharray: dashMap[styleStrokeStyle.value] || '' }); 1465 + syncToYjs(); 1466 + render(); 1467 + }); 1468 + styleOpacity?.addEventListener('input', () => { 1469 + if (selectedShapeIds.size === 0) return; 1470 + const val = Number(styleOpacity.value) / 100; 1471 + wb = setShapeOpacity(wb, selectedShapeIds, val); 1472 + styleOpacityValue.textContent = `${styleOpacity.value}%`; 1473 + syncToYjs(); 1474 + render(); 1475 + }); 1476 + styleFontFamily?.addEventListener('change', () => { 1477 + if (selectedShapeIds.size === 0) return; 1478 + pushHistory(); 1479 + wb = setShapeFontFamily(wb, selectedShapeIds, styleFontFamily.value); 1480 + syncToYjs(); 1481 + render(); 1482 + }); 1483 + styleFontSize?.addEventListener('change', () => { 1484 + if (selectedShapeIds.size === 0) return; 1485 + pushHistory(); 1486 + wb = setShapeFontSize(wb, selectedShapeIds, Number(styleFontSize.value)); 1487 + syncToYjs(); 1488 + render(); 1489 + }); 1490 + 743 1491 // Properties panel 744 1492 propLabel.addEventListener('change', () => { 745 1493 if (selectedShapeIds.size === 1) { 1494 + pushHistory(); 746 1495 const id = [...selectedShapeIds][0]; 747 1496 wb = setShapeLabel(wb, id, propLabel.value); syncToYjs(); render(); 748 1497 } 749 1498 }); 750 1499 propWidth.addEventListener('change', () => { 751 1500 if (selectedShapeIds.size === 1) { 1501 + pushHistory(); 752 1502 const id = [...selectedShapeIds][0]; 753 1503 wb = resizeShape(wb, id, Number(propWidth.value), Number(propHeight.value)); syncToYjs(); render(); 754 1504 } 755 1505 }); 756 1506 propHeight.addEventListener('change', () => { 757 1507 if (selectedShapeIds.size === 1) { 1508 + pushHistory(); 758 1509 const id = [...selectedShapeIds][0]; 759 1510 wb = resizeShape(wb, id, Number(propWidth.value), Number(propHeight.value)); syncToYjs(); render(); 760 1511 } 761 1512 }); 762 1513 1514 + // --- Copy / Paste --- 1515 + function doCopy() { 1516 + if (selectedShapeIds.size === 0) return; 1517 + const shapes: Shape[] = []; 1518 + const arrows: Arrow[] = []; 1519 + for (const id of selectedShapeIds) { 1520 + const s = wb.shapes.get(id); 1521 + if (s) shapes.push({ ...s }); 1522 + } 1523 + for (const arrow of wb.arrows.values()) { 1524 + const fromId = 'shapeId' in arrow.from ? arrow.from.shapeId : null; 1525 + const toId = 'shapeId' in arrow.to ? arrow.to.shapeId : null; 1526 + if (fromId && toId && selectedShapeIds.has(fromId) && selectedShapeIds.has(toId)) { 1527 + arrows.push({ ...arrow }); 1528 + } 1529 + } 1530 + clipboard = { shapes, arrows }; 1531 + } 1532 + 1533 + function doPaste() { 1534 + if (!clipboard || clipboard.shapes.length === 0) return; 1535 + pushHistory(); 1536 + const result = duplicateShapes(wb, clipboard.shapes.map(s => s.id)); 1537 + // If duplicateShapes couldn't find original IDs (pasted from different state), manually add 1538 + if (result.idMap.size === 0) { 1539 + // Re-add shapes with new IDs and offset 1540 + let newState = wb; 1541 + const newIds: string[] = []; 1542 + for (const shape of clipboard.shapes) { 1543 + newState = addShape(newState, shape.kind, shape.x + 20, shape.y + 20, shape.width, shape.height, shape.label); 1544 + const lastId = [...newState.shapes.keys()].pop()!; 1545 + newIds.push(lastId); 1546 + const last = newState.shapes.get(lastId)!; 1547 + newState.shapes.set(lastId, { ...last, style: { ...shape.style }, opacity: shape.opacity, rotation: shape.rotation, fontFamily: shape.fontFamily, fontSize: shape.fontSize, points: shape.points }); 1548 + } 1549 + wb = newState; 1550 + selectedShapeIds = new Set(newIds); 1551 + } else { 1552 + wb = result.state; 1553 + selectedShapeIds = new Set(result.idMap.values()); 1554 + } 1555 + syncToYjs(); 1556 + render(); 1557 + } 1558 + 1559 + function doDuplicate() { 1560 + if (selectedShapeIds.size === 0) return; 1561 + pushHistory(); 1562 + const result = duplicateShapes(wb, selectedShapeIds); 1563 + wb = result.state; 1564 + selectedShapeIds = new Set(result.idMap.values()); 1565 + syncToYjs(); 1566 + render(); 1567 + } 1568 + 1569 + // Flip buttons 1570 + $('btn-flip-h')?.addEventListener('click', () => { 1571 + if (selectedShapeIds.size > 0) { 1572 + pushHistory(); 1573 + wb = flipShapes(wb, [...selectedShapeIds], 'horizontal'); 1574 + syncToYjs(); 1575 + render(); 1576 + } 1577 + }); 1578 + $('btn-flip-v')?.addEventListener('click', () => { 1579 + if (selectedShapeIds.size > 0) { 1580 + pushHistory(); 1581 + wb = flipShapes(wb, [...selectedShapeIds], 'vertical'); 1582 + syncToYjs(); 1583 + render(); 1584 + } 1585 + }); 1586 + 1587 + // --- Context Menu --- 1588 + let contextMenuEl: HTMLElement | null = null; 1589 + 1590 + function showContextMenu(x: number, y: number) { 1591 + hideContextMenu(); 1592 + const menu = document.createElement('div'); 1593 + menu.className = 'diagrams-context-menu'; 1594 + menu.style.left = `${x}px`; 1595 + menu.style.top = `${y}px`; 1596 + 1597 + const items: Array<{ label: string; shortcut?: string; action: () => void; divider?: boolean }> = []; 1598 + 1599 + if (selectedShapeIds.size > 0) { 1600 + items.push({ label: 'Copy', shortcut: '\u2318C', action: doCopy }); 1601 + items.push({ label: 'Paste', shortcut: '\u2318V', action: doPaste }); 1602 + items.push({ label: 'Duplicate', shortcut: '\u2318D', action: doDuplicate }); 1603 + items.push({ label: '', shortcut: '', action: () => {}, divider: true }); 1604 + items.push({ label: 'Bring to Front', shortcut: '\u2318]', action: () => { pushHistory(); wb = bringToFront(wb, selectedShapeIds); syncToYjs(); render(); } }); 1605 + items.push({ label: 'Send to Back', shortcut: '\u2318[', action: () => { pushHistory(); wb = sendToBack(wb, selectedShapeIds); syncToYjs(); render(); } }); 1606 + items.push({ label: '', shortcut: '', action: () => {}, divider: true }); 1607 + if (selectedShapeIds.size >= 2) { 1608 + items.push({ label: 'Group', shortcut: '\u2318G', action: () => { pushHistory(); const r = groupShapes(wb, [...selectedShapeIds]); wb = r.state; syncToYjs(); render(); } }); 1609 + } 1610 + const firstShape = wb.shapes.get([...selectedShapeIds][0]); 1611 + if (firstShape?.groupId) { 1612 + items.push({ label: 'Ungroup', shortcut: '\u21e7\u2318G', action: () => { pushHistory(); wb = ungroupShapes(wb, firstShape.groupId!); syncToYjs(); render(); } }); 1613 + } 1614 + if (selectedShapeIds.size >= 2) { 1615 + items.push({ label: 'Flip Horizontal', action: () => { pushHistory(); wb = flipShapes(wb, [...selectedShapeIds], 'horizontal'); syncToYjs(); render(); } }); 1616 + items.push({ label: 'Flip Vertical', action: () => { pushHistory(); wb = flipShapes(wb, [...selectedShapeIds], 'vertical'); syncToYjs(); render(); } }); 1617 + } 1618 + items.push({ label: '', shortcut: '', action: () => {}, divider: true }); 1619 + items.push({ label: 'Delete', shortcut: 'Del', action: () => { pushHistory(); wb = removeShapes(wb, selectedShapeIds); selectedShapeIds = new Set(); syncToYjs(); render(); } }); 1620 + } else { 1621 + items.push({ label: 'Paste', shortcut: '\u2318V', action: doPaste }); 1622 + items.push({ label: 'Select All', shortcut: '\u2318A', action: () => { selectedShapeIds = new Set(wb.shapes.keys()); render(); } }); 1623 + } 1624 + 1625 + for (const item of items) { 1626 + if (item.divider) { 1627 + const div = document.createElement('div'); 1628 + div.className = 'menu-divider'; 1629 + menu.appendChild(div); 1630 + continue; 1631 + } 1632 + const btn = document.createElement('button'); 1633 + btn.textContent = item.label; 1634 + if (item.shortcut) { 1635 + const kbd = document.createElement('span'); 1636 + kbd.className = 'shortcut'; 1637 + kbd.textContent = item.shortcut; 1638 + btn.appendChild(kbd); 1639 + } 1640 + btn.addEventListener('click', () => { hideContextMenu(); item.action(); }); 1641 + menu.appendChild(btn); 1642 + } 1643 + 1644 + document.body.appendChild(menu); 1645 + contextMenuEl = menu; 1646 + 1647 + // Adjust position if off-screen 1648 + const rect = menu.getBoundingClientRect(); 1649 + if (rect.right > window.innerWidth) menu.style.left = `${window.innerWidth - rect.width - 4}px`; 1650 + if (rect.bottom > window.innerHeight) menu.style.top = `${window.innerHeight - rect.height - 4}px`; 1651 + } 1652 + 1653 + function hideContextMenu() { 1654 + if (contextMenuEl) { 1655 + contextMenuEl.remove(); 1656 + contextMenuEl = null; 1657 + } 1658 + } 1659 + 1660 + canvas.addEventListener('contextmenu', (e) => { 1661 + e.preventDefault(); 1662 + const pt = screenToCanvas(e.clientX, e.clientY); 1663 + const hit = shapeAtPoint(wb, pt.x, pt.y); 1664 + if (hit && !selectedShapeIds.has(hit.id)) { 1665 + selectedShapeIds = new Set([hit.id]); 1666 + render(); 1667 + } 1668 + showContextMenu(e.clientX, e.clientY); 1669 + }); 1670 + 1671 + document.addEventListener('click', (e) => { 1672 + if (contextMenuEl && !contextMenuEl.contains(e.target as Node)) hideContextMenu(); 1673 + }); 1674 + 1675 + // --- Focus Mode --- 1676 + let focusModeActive = false; 1677 + 1678 + function toggleFocusMode() { 1679 + focusModeActive = !focusModeActive; 1680 + const toolbar = document.getElementById('diagrams-toolbar'); 1681 + const topbar = document.querySelector('.app-topbar') as HTMLElement; 1682 + if (toolbar) toolbar.style.display = focusModeActive ? 'none' : ''; 1683 + if (topbar) topbar.style.display = focusModeActive ? 'none' : ''; 1684 + // Also hide any panels 1685 + stylePanel.style.display = 'none'; 1686 + propsPanel.style.display = 'none'; 1687 + } 1688 + 1689 + // --- Edge Scrolling --- 1690 + const EDGE_SCROLL_ZONE = 40; 1691 + const EDGE_SCROLL_SPEED = 8; 1692 + let edgeScrollInterval: ReturnType<typeof setInterval> | null = null; 1693 + 1694 + function startEdgeScroll(clientX: number, clientY: number) { 1695 + stopEdgeScroll(); 1696 + const rect = canvas.getBoundingClientRect(); 1697 + let dx = 0, dy = 0; 1698 + if (clientX < rect.left + EDGE_SCROLL_ZONE) dx = EDGE_SCROLL_SPEED; 1699 + else if (clientX > rect.right - EDGE_SCROLL_ZONE) dx = -EDGE_SCROLL_SPEED; 1700 + if (clientY < rect.top + EDGE_SCROLL_ZONE) dy = EDGE_SCROLL_SPEED; 1701 + else if (clientY > rect.bottom - EDGE_SCROLL_ZONE) dy = -EDGE_SCROLL_SPEED; 1702 + 1703 + if (dx === 0 && dy === 0) { stopEdgeScroll(); return; } 1704 + edgeScrollInterval = setInterval(() => { 1705 + wb = { ...wb, panX: wb.panX + dx, panY: wb.panY + dy }; 1706 + render(); 1707 + }, 16); 1708 + } 1709 + 1710 + function stopEdgeScroll() { 1711 + if (edgeScrollInterval) { clearInterval(edgeScrollInterval); edgeScrollInterval = null; } 1712 + } 1713 + 1714 + // --- Keyboard Shortcuts Dialog --- 1715 + let shortcutsDialogOpen = false; 1716 + 1717 + function toggleShortcutsDialog() { 1718 + if (shortcutsDialogOpen) { closeShortcutsDialog(); return; } 1719 + shortcutsDialogOpen = true; 1720 + const overlay = document.createElement('div'); 1721 + overlay.className = 'diagrams-shortcuts-overlay'; 1722 + overlay.addEventListener('click', closeShortcutsDialog); 1723 + document.body.appendChild(overlay); 1724 + 1725 + const dialog = document.createElement('div'); 1726 + dialog.className = 'diagrams-shortcuts-dialog'; 1727 + dialog.innerHTML = `<h2>Keyboard Shortcuts</h2>`; 1728 + 1729 + const shortcuts: Array<[string, string]> = [ 1730 + ['V', 'Select tool'], 1731 + ['H', 'Hand / pan tool'], 1732 + ['R', 'Rectangle'], 1733 + ['E', 'Ellipse'], 1734 + ['D', 'Diamond'], 1735 + ['T', 'Text'], 1736 + ['P', 'Freehand / pen'], 1737 + ['A', 'Arrow'], 1738 + ['X', 'Eraser'], 1739 + ['N', 'Sticky note'], 1740 + ['Space + drag', 'Pan canvas'], 1741 + ['Scroll wheel', 'Zoom'], 1742 + ['\u2318Z', 'Undo'], 1743 + ['\u21e7\u2318Z', 'Redo'], 1744 + ['\u2318C', 'Copy'], 1745 + ['\u2318V', 'Paste'], 1746 + ['\u2318D', 'Duplicate'], 1747 + ['\u2318A', 'Select all'], 1748 + ['\u2318G', 'Group'], 1749 + ['\u21e7\u2318G', 'Ungroup'], 1750 + ['\u2318]', 'Bring to front'], 1751 + ['\u2318[', 'Send to back'], 1752 + ['Delete', 'Delete selected'], 1753 + ['Escape', 'Deselect / cancel'], 1754 + ['Double-click', 'Edit text'], 1755 + ['Shift + click', 'Multi-select'], 1756 + ['Shift + rotate', 'Snap to 15\u00b0'], 1757 + ['Alt + drag', 'Duplicate while dragging'], 1758 + ['\u2318.', 'Toggle focus mode'], 1759 + ['\u2318\u2325/', 'Keyboard shortcuts'], 1760 + ]; 1761 + 1762 + for (const [key, desc] of shortcuts) { 1763 + const row = document.createElement('div'); 1764 + row.className = 'shortcut-row'; 1765 + row.innerHTML = `<span>${desc}</span><kbd>${key}</kbd>`; 1766 + dialog.appendChild(row); 1767 + } 1768 + 1769 + document.body.appendChild(dialog); 1770 + } 1771 + 1772 + function closeShortcutsDialog() { 1773 + shortcutsDialogOpen = false; 1774 + document.querySelector('.diagrams-shortcuts-dialog')?.remove(); 1775 + document.querySelector('.diagrams-shortcuts-overlay')?.remove(); 1776 + } 1777 + 763 1778 // Keyboard shortcuts 764 1779 document.addEventListener('keydown', (e) => { 1780 + // Don't handle shortcuts when editing text 765 1781 if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; 1782 + if (editingShapeId) return; 766 1783 767 1784 if (e.key === ' ') { 768 1785 spaceHeld = true; ··· 770 1787 return; 771 1788 } 772 1789 1790 + // Cmd/Ctrl shortcuts 1791 + const mod = e.metaKey || e.ctrlKey; 1792 + 1793 + // Focus mode: Cmd+. 1794 + if (mod && e.key === '.') { e.preventDefault(); toggleFocusMode(); return; } 1795 + 1796 + // Shortcuts dialog: Cmd+Alt+/ or ? 1797 + if (mod && e.altKey && e.key === '/') { e.preventDefault(); toggleShortcutsDialog(); return; } 1798 + if (e.key === '?' && !mod) { toggleShortcutsDialog(); return; } 1799 + 1800 + if (mod && e.key === 'z' && !e.shiftKey) { e.preventDefault(); doUndo(); return; } 1801 + if (mod && e.key === 'z' && e.shiftKey) { e.preventDefault(); doRedo(); return; } 1802 + if (mod && e.key === 'y') { e.preventDefault(); doRedo(); return; } 1803 + if (mod && e.key === 'c') { e.preventDefault(); doCopy(); return; } 1804 + if (mod && e.key === 'v') { e.preventDefault(); doPaste(); return; } 1805 + if (mod && e.key === 'd') { e.preventDefault(); doDuplicate(); return; } 1806 + if (mod && e.key === 'a') { e.preventDefault(); selectedShapeIds = new Set(wb.shapes.keys()); render(); return; } 1807 + if (mod && e.key === 'g' && !e.shiftKey) { 1808 + e.preventDefault(); 1809 + if (selectedShapeIds.size >= 2) { 1810 + pushHistory(); 1811 + const result = groupShapes(wb, [...selectedShapeIds]); 1812 + wb = result.state; 1813 + syncToYjs(); 1814 + render(); 1815 + } 1816 + return; 1817 + } 1818 + if (mod && e.key === 'g' && e.shiftKey) { 1819 + e.preventDefault(); 1820 + if (selectedShapeIds.size > 0) { 1821 + const shape = wb.shapes.get([...selectedShapeIds][0]); 1822 + if (shape?.groupId) { 1823 + pushHistory(); 1824 + wb = ungroupShapes(wb, shape.groupId); 1825 + syncToYjs(); 1826 + render(); 1827 + } 1828 + } 1829 + return; 1830 + } 1831 + // Z-order shortcuts 1832 + if (mod && e.key === ']') { e.preventDefault(); if (selectedShapeIds.size > 0) { pushHistory(); wb = bringToFront(wb, selectedShapeIds); syncToYjs(); render(); } return; } 1833 + if (mod && e.key === '[') { e.preventDefault(); if (selectedShapeIds.size > 0) { pushHistory(); wb = sendToBack(wb, selectedShapeIds); syncToYjs(); render(); } return; } 1834 + 773 1835 switch (e.key) { 774 1836 case 'Escape': 1837 + if (isDrawingLine && linePoints.length >= 2) { finishLine(); break; } 775 1838 activeTool = 'select'; 776 1839 selectedShapeIds = new Set(); 777 - // Cancel any in-progress operations 778 1840 isDrawingArrow = false; 779 1841 arrowFromShape = null; 780 1842 arrowFromAnchor = null; 781 1843 isDrawingFreehand = false; 782 1844 freehandPoints = []; 1845 + isDrawingLine = false; 1846 + linePoints = []; 783 1847 isCreatingShape = false; 784 1848 createShapeKind = null; 785 1849 removeCreationPreview(); 786 1850 removeArrowPreview(); 787 1851 layer.querySelector('.freehand-preview')?.remove(); 1852 + layer.querySelector('.line-preview')?.remove(); 788 1853 arrowHoverTargetId = null; 789 1854 updateToolbar(); 790 1855 render(); ··· 794 1859 case 'e': case 'E': activeTool = 'ellipse'; updateToolbar(); break; 795 1860 case 'd': case 'D': activeTool = 'diamond'; updateToolbar(); break; 796 1861 case 't': case 'T': activeTool = 'text'; updateToolbar(); break; 1862 + case 'l': case 'L': activeTool = 'line'; updateToolbar(); break; 797 1863 case 'p': case 'P': activeTool = 'freehand'; updateToolbar(); break; 798 1864 case 'a': case 'A': activeTool = 'arrow'; updateToolbar(); break; 1865 + case 'h': case 'H': activeTool = 'hand'; updateToolbar(); break; 1866 + case 'x': case 'X': activeTool = 'eraser'; updateToolbar(); break; 1867 + case 'n': case 'N': activeTool = 'note'; updateToolbar(); break; 799 1868 case 'Delete': case 'Backspace': 800 1869 if (selectedShapeIds.size > 0) { 1870 + pushHistory(); 801 1871 wb = removeShapes(wb, selectedShapeIds); 802 1872 selectedShapeIds = new Set(); 803 1873 syncToYjs(); ··· 829 1899 async function init() { 830 1900 await initCrypto(); 831 1901 1902 + // Push initial state to history 1903 + pushHistory(); 1904 + 832 1905 if (cryptoKey) { 833 1906 const provider = new EncryptedProvider(ydoc, docId, cryptoKey); 834 1907 provider.on('sync', () => { 835 1908 loadFromYjs(); 1909 + pushHistory(); 836 1910 render(); 837 1911 }); 838 1912 }
+390 -1
src/diagrams/whiteboard.ts
··· 5 5 * Canvas/SVG rendering handled by the diagrams UI layer. 6 6 */ 7 7 8 - export type ShapeKind = 'rectangle' | 'ellipse' | 'diamond' | 'text' | 'freehand'; 8 + export type ShapeKind = 'rectangle' | 'ellipse' | 'diamond' | 'text' | 'freehand' | 'line' | 'triangle' | 'star' | 'hexagon' | 'cloud' | 'cylinder' | 'parallelogram' | 'note'; 9 9 export type ArrowEndpoint = { shapeId: string; anchor: 'top' | 'bottom' | 'left' | 'right' | 'center' } | { x: number; y: number }; 10 10 11 11 export interface Point { ··· 23 23 rotation: number; 24 24 label: string; 25 25 style: Record<string, string>; 26 + opacity: number; 26 27 /** Freehand path points (only for freehand shapes) */ 27 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; 28 35 } 29 36 30 37 export interface Arrow { ··· 86 93 rotation: 0, 87 94 label, 88 95 style: {}, 96 + opacity: 1, 89 97 }; 90 98 const shapes = new Map(state.shapes); 91 99 shapes.set(shape.id, shape); ··· 442 450 return { anchor: 'bottom', x: clamp(cx, shape.x, shape.x + shape.width), y: shape.y + shape.height }; 443 451 } 444 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 + } 569 + 570 + /** 571 + * Evenly distribute shapes along an axis. 572 + * Shapes are sorted by position, then spaced evenly between the first and last. 573 + */ 574 + export function distributeShapes( 575 + state: WhiteboardState, 576 + 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 } { 656 + if (shapeIds.length < 2) return { state, groupId: '' }; 657 + const groupId = `group-${Date.now()}-${++_counter}`; 658 + const shapes = new Map(state.shapes); 659 + for (const id of shapeIds) { 660 + const shape = shapes.get(id); 661 + if (shape) shapes.set(id, { ...shape, groupId }); 662 + } 663 + return { state: { ...state, shapes }, groupId }; 664 + } 665 + 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 }); 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 }); 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 + // --- Copy / Duplicate --- 784 + 785 + /** 786 + * Duplicate shapes with an offset. Returns new state + mapping of old→new IDs. 787 + */ 788 + export function duplicateShapes( 789 + state: WhiteboardState, 790 + shapeIds: Iterable<string>, 791 + offsetX = 20, 792 + offsetY = 20, 793 + ): { state: WhiteboardState; idMap: Map<string, string> } { 794 + const shapes = new Map(state.shapes); 795 + const arrows = new Map(state.arrows); 796 + const idMap = new Map<string, string>(); 797 + const idsSet = new Set(shapeIds); 798 + 799 + for (const id of idsSet) { 800 + const shape = shapes.get(id); 801 + if (!shape) continue; 802 + const newId = `shape-${Date.now()}-${++_counter}`; 803 + idMap.set(id, newId); 804 + shapes.set(newId, { 805 + ...shape, 806 + id: newId, 807 + x: shape.x + offsetX, 808 + y: shape.y + offsetY, 809 + groupId: undefined, 810 + }); 811 + } 812 + 813 + // Duplicate arrows between duplicated shapes 814 + for (const arrow of state.arrows.values()) { 815 + const fromIsShape = 'shapeId' in arrow.from; 816 + const toIsShape = 'shapeId' in arrow.to; 817 + if (fromIsShape && toIsShape) { 818 + const fromId = (arrow.from as { shapeId: string }).shapeId; 819 + const toId = (arrow.to as { shapeId: string }).shapeId; 820 + if (idMap.has(fromId) && idMap.has(toId)) { 821 + const newArrowId = `arrow-${Date.now()}-${++_counter}`; 822 + arrows.set(newArrowId, { 823 + ...arrow, 824 + id: newArrowId, 825 + from: { ...(arrow.from as any), shapeId: idMap.get(fromId)! }, 826 + to: { ...(arrow.to as any), shapeId: idMap.get(toId)! }, 827 + }); 828 + } 829 + } 830 + } 831 + 832 + return { state: { ...state, shapes, arrows }, idMap }; 833 + }
+316
tests/export.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createWhiteboard, 4 + pointsToCatmullRomPath, 5 + } from '../src/diagrams/whiteboard'; 6 + import type { WhiteboardState, Shape, Arrow } from '../src/diagrams/whiteboard'; 7 + import { exportToSVG } from '../src/diagrams/export'; 8 + 9 + // --------------------------------------------------------------------------- 10 + // Helpers 11 + // --------------------------------------------------------------------------- 12 + 13 + function stateWith(shapes: Shape[], arrows: Arrow[] = []): WhiteboardState { 14 + const state = createWhiteboard(); 15 + for (const s of shapes) state.shapes.set(s.id, s); 16 + for (const a of arrows) state.arrows.set(a.id, a); 17 + return state; 18 + } 19 + 20 + function makeShape(overrides: Partial<Shape> & { id: string; kind: Shape['kind'] }): Shape { 21 + return { 22 + x: 0, 23 + y: 0, 24 + width: 120, 25 + height: 80, 26 + rotation: 0, 27 + label: '', 28 + style: {}, 29 + ...overrides, 30 + }; 31 + } 32 + 33 + function makeArrow(overrides: Partial<Arrow> & { id: string }): Arrow { 34 + return { 35 + from: { x: 0, y: 0 }, 36 + to: { x: 100, y: 100 }, 37 + label: '', 38 + style: {}, 39 + ...overrides, 40 + }; 41 + } 42 + 43 + // --------------------------------------------------------------------------- 44 + // Tests 45 + // --------------------------------------------------------------------------- 46 + 47 + describe('exportToSVG', () => { 48 + it('returns a minimal SVG for empty state', () => { 49 + const state = createWhiteboard(); 50 + const svg = exportToSVG(state); 51 + 52 + expect(svg).toContain('xmlns="http://www.w3.org/2000/svg"'); 53 + expect(svg).toContain('<svg'); 54 + expect(svg).toContain('</svg>'); 55 + expect(svg).toMatch(/viewBox="[^"]*"/); 56 + }); 57 + 58 + it('includes proper SVG namespace', () => { 59 + const state = stateWith([makeShape({ id: 's1', kind: 'rectangle', x: 10, y: 20 })]); 60 + const svg = exportToSVG(state); 61 + 62 + expect(svg).toContain('xmlns="http://www.w3.org/2000/svg"'); 63 + }); 64 + 65 + it('renders a rectangle shape as <rect>', () => { 66 + const state = stateWith([ 67 + makeShape({ id: 's1', kind: 'rectangle', x: 50, y: 60, width: 100, height: 40 }), 68 + ]); 69 + const svg = exportToSVG(state); 70 + 71 + expect(svg).toContain('<rect'); 72 + expect(svg).toMatch(/x="50"/); 73 + expect(svg).toMatch(/y="60"/); 74 + expect(svg).toMatch(/width="100"/); 75 + expect(svg).toMatch(/height="40"/); 76 + }); 77 + 78 + it('renders an ellipse shape as <ellipse>', () => { 79 + const state = stateWith([ 80 + makeShape({ id: 's1', kind: 'ellipse', x: 100, y: 200, width: 80, height: 60 }), 81 + ]); 82 + const svg = exportToSVG(state); 83 + 84 + expect(svg).toContain('<ellipse'); 85 + expect(svg).toMatch(/cx="140"/); 86 + expect(svg).toMatch(/cy="230"/); 87 + expect(svg).toMatch(/rx="40"/); 88 + expect(svg).toMatch(/ry="30"/); 89 + }); 90 + 91 + it('renders a diamond shape as <polygon>', () => { 92 + const state = stateWith([ 93 + makeShape({ id: 's1', kind: 'diamond', x: 0, y: 0, width: 100, height: 80 }), 94 + ]); 95 + const svg = exportToSVG(state); 96 + 97 + expect(svg).toContain('<polygon'); 98 + expect(svg).toContain('points='); 99 + expect(svg).toContain('50,0'); 100 + expect(svg).toContain('100,40'); 101 + expect(svg).toContain('50,80'); 102 + expect(svg).toContain('0,40'); 103 + }); 104 + 105 + it('renders a text shape as <text>', () => { 106 + const state = stateWith([ 107 + makeShape({ id: 's1', kind: 'text', x: 10, y: 20, width: 200, height: 30, label: 'Hello' }), 108 + ]); 109 + const svg = exportToSVG(state); 110 + 111 + expect(svg).toContain('<text'); 112 + expect(svg).toContain('Hello'); 113 + }); 114 + 115 + it('renders a freehand shape as <path> using Catmull-Rom', () => { 116 + const points = [ 117 + { x: 10, y: 10 }, 118 + { x: 20, y: 30 }, 119 + { x: 40, y: 20 }, 120 + { x: 60, y: 50 }, 121 + ]; 122 + const state = stateWith([ 123 + makeShape({ id: 's1', kind: 'freehand', x: 0, y: 0, width: 60, height: 50, points }), 124 + ]); 125 + const svg = exportToSVG(state); 126 + 127 + expect(svg).toContain('<path'); 128 + const expectedD = pointsToCatmullRomPath(points); 129 + expect(svg).toContain(expectedD); 130 + }); 131 + 132 + it('renders shape labels as centered <text> elements', () => { 133 + const state = stateWith([ 134 + makeShape({ 135 + id: 's1', 136 + kind: 'rectangle', 137 + x: 0, 138 + y: 0, 139 + width: 100, 140 + height: 80, 141 + label: 'My Box', 142 + }), 143 + ]); 144 + const svg = exportToSVG(state); 145 + 146 + expect(svg).toContain('My Box'); 147 + expect(svg).toContain('text-anchor="middle"'); 148 + }); 149 + 150 + it('renders all shape types in one export', () => { 151 + const state = stateWith([ 152 + makeShape({ id: 's1', kind: 'rectangle', x: 0, y: 0 }), 153 + makeShape({ id: 's2', kind: 'ellipse', x: 200, y: 0 }), 154 + makeShape({ id: 's3', kind: 'diamond', x: 400, y: 0 }), 155 + ]); 156 + const svg = exportToSVG(state); 157 + 158 + expect(svg).toContain('<rect'); 159 + expect(svg).toContain('<ellipse'); 160 + expect(svg).toContain('<polygon'); 161 + }); 162 + 163 + it('renders arrows with arrowhead marker', () => { 164 + const state = stateWith( 165 + [makeShape({ id: 's1', kind: 'rectangle', x: 0, y: 0, width: 100, height: 80 })], 166 + [makeArrow({ id: 'a1', from: { x: 100, y: 40 }, to: { x: 200, y: 40 } })], 167 + ); 168 + const svg = exportToSVG(state); 169 + 170 + expect(svg).toContain('<defs>'); 171 + expect(svg).toContain('<marker'); 172 + expect(svg).toContain('marker-end='); 173 + expect(svg).toContain('<line'); 174 + }); 175 + 176 + it('renders arrows connected to shapes using shape anchor positions', () => { 177 + const s1 = makeShape({ id: 's1', kind: 'rectangle', x: 0, y: 0, width: 100, height: 80 }); 178 + const s2 = makeShape({ id: 's2', kind: 'rectangle', x: 200, y: 0, width: 100, height: 80 }); 179 + const arrow = makeArrow({ 180 + id: 'a1', 181 + from: { shapeId: 's1', anchor: 'right' }, 182 + to: { shapeId: 's2', anchor: 'left' }, 183 + }); 184 + const state = stateWith([s1, s2], [arrow]); 185 + const svg = exportToSVG(state); 186 + 187 + expect(svg).toContain('<line'); 188 + expect(svg).toContain('x1="100"'); 189 + expect(svg).toContain('y1="40"'); 190 + expect(svg).toContain('x2="200"'); 191 + expect(svg).toContain('y2="40"'); 192 + }); 193 + 194 + it('computes viewBox from shape bounding box with padding', () => { 195 + const state = stateWith([ 196 + makeShape({ id: 's1', kind: 'rectangle', x: 100, y: 200, width: 300, height: 150 }), 197 + ]); 198 + const svg = exportToSVG(state); 199 + 200 + const match = svg.match(/viewBox="([^"]*)"/); 201 + expect(match).not.toBeNull(); 202 + const [vx, vy, vw, vh] = match![1].split(' ').map(Number); 203 + expect(vx).toBeLessThanOrEqual(100); 204 + expect(vy).toBeLessThanOrEqual(200); 205 + expect(vw).toBeGreaterThanOrEqual(300); 206 + expect(vh).toBeGreaterThanOrEqual(150); 207 + }); 208 + 209 + it('uses a default viewBox for empty state', () => { 210 + const state = createWhiteboard(); 211 + const svg = exportToSVG(state); 212 + 213 + const match = svg.match(/viewBox="([^"]*)"/); 214 + expect(match).not.toBeNull(); 215 + const [, , vw, vh] = match![1].split(' ').map(Number); 216 + expect(vw).toBeGreaterThan(0); 217 + expect(vh).toBeGreaterThan(0); 218 + }); 219 + 220 + it('exports only selected shapes when selectedIds is provided', () => { 221 + const state = stateWith([ 222 + makeShape({ id: 's1', kind: 'rectangle', x: 0, y: 0, label: 'Keep' }), 223 + makeShape({ id: 's2', kind: 'ellipse', x: 200, y: 0, label: 'Drop' }), 224 + ]); 225 + const svg = exportToSVG(state, new Set(['s1'])); 226 + 227 + expect(svg).toContain('<rect'); 228 + expect(svg).toContain('Keep'); 229 + expect(svg).not.toContain('<ellipse'); 230 + expect(svg).not.toContain('Drop'); 231 + }); 232 + 233 + it('includes arrows connected to selected shapes', () => { 234 + const s1 = makeShape({ id: 's1', kind: 'rectangle', x: 0, y: 0, width: 100, height: 80 }); 235 + const s2 = makeShape({ id: 's2', kind: 'rectangle', x: 200, y: 0, width: 100, height: 80 }); 236 + const s3 = makeShape({ id: 's3', kind: 'rectangle', x: 400, y: 0, width: 100, height: 80 }); 237 + const a1 = makeArrow({ 238 + id: 'a1', 239 + from: { shapeId: 's1', anchor: 'right' }, 240 + to: { shapeId: 's2', anchor: 'left' }, 241 + }); 242 + const a2 = makeArrow({ 243 + id: 'a2', 244 + from: { shapeId: 's2', anchor: 'right' }, 245 + to: { shapeId: 's3', anchor: 'left' }, 246 + }); 247 + const state = stateWith([s1, s2, s3], [a1, a2]); 248 + 249 + const svg = exportToSVG(state, new Set(['s1', 's2'])); 250 + 251 + const lineMatches = svg.match(/<line /g); 252 + expect(lineMatches).toHaveLength(1); 253 + }); 254 + 255 + it('excludes arrows where neither endpoint is a selected shape', () => { 256 + const s1 = makeShape({ id: 's1', kind: 'rectangle', x: 0, y: 0 }); 257 + const s2 = makeShape({ id: 's2', kind: 'ellipse', x: 200, y: 0 }); 258 + const a1 = makeArrow({ 259 + id: 'a1', 260 + from: { shapeId: 's1', anchor: 'right' }, 261 + to: { shapeId: 's2', anchor: 'left' }, 262 + }); 263 + const state = stateWith([s1, s2], [a1]); 264 + 265 + const svg = exportToSVG(state, new Set(['s2'])); 266 + 267 + expect(svg).not.toContain('<line'); 268 + }); 269 + 270 + it('includes free-endpoint arrows when selectedIds is not provided', () => { 271 + const state = stateWith( 272 + [], 273 + [makeArrow({ id: 'a1', from: { x: 0, y: 0 }, to: { x: 100, y: 100 } })], 274 + ); 275 + const svg = exportToSVG(state); 276 + 277 + expect(svg).toContain('<line'); 278 + }); 279 + 280 + it('uses inline styles with actual colors (not CSS vars)', () => { 281 + const state = stateWith([ 282 + makeShape({ id: 's1', kind: 'rectangle', x: 0, y: 0 }), 283 + ]); 284 + const svg = exportToSVG(state); 285 + 286 + expect(svg).not.toContain('var('); 287 + expect(svg).toMatch(/fill="[^"]+"/); 288 + expect(svg).toMatch(/stroke="[^"]+"/); 289 + }); 290 + 291 + it('applies rotation transform to rotated shapes', () => { 292 + const state = stateWith([ 293 + makeShape({ 294 + id: 's1', 295 + kind: 'rectangle', 296 + x: 0, 297 + y: 0, 298 + width: 100, 299 + height: 80, 300 + rotation: 45, 301 + }), 302 + ]); 303 + const svg = exportToSVG(state); 304 + 305 + expect(svg).toContain('transform='); 306 + expect(svg).toContain('rotate(45'); 307 + }); 308 + 309 + it('handles freehand shape with no points gracefully', () => { 310 + const state = stateWith([ 311 + makeShape({ id: 's1', kind: 'freehand', x: 0, y: 0, width: 0, height: 0 }), 312 + ]); 313 + const svg = exportToSVG(state); 314 + expect(svg).toContain('<svg'); 315 + }); 316 + });
+268
tests/history.test.ts
··· 1 + import { describe, it, expect, beforeEach } from 'vitest'; 2 + import History from '../src/diagrams/history'; 3 + import { createWhiteboard, addShape, addArrow } from '../src/diagrams/whiteboard'; 4 + import type { WhiteboardState } from '../src/diagrams/whiteboard'; 5 + 6 + describe('History', () => { 7 + let history: History; 8 + let base: WhiteboardState; 9 + 10 + beforeEach(() => { 11 + history = new History(); 12 + base = createWhiteboard(); 13 + }); 14 + 15 + describe('push and undo', () => { 16 + it('returns undefined when undoing with no history', () => { 17 + expect(history.undo()).toBeUndefined(); 18 + }); 19 + 20 + it('returns the previous state on undo', () => { 21 + const s1 = addShape(base, 'rectangle', 0, 0); 22 + const s2 = addShape(s1, 'ellipse', 100, 100); 23 + history.push(s1); 24 + history.push(s2); 25 + 26 + const undone = history.undo(); 27 + expect(undone).toBeDefined(); 28 + expect(undone!.shapes.size).toBe(1); 29 + }); 30 + 31 + it('can undo multiple times back to the first state', () => { 32 + const s1 = addShape(base, 'rectangle', 0, 0); 33 + const s2 = addShape(s1, 'ellipse', 100, 100); 34 + const s3 = addShape(s2, 'diamond', 200, 200); 35 + history.push(s1); 36 + history.push(s2); 37 + history.push(s3); 38 + 39 + history.undo(); 40 + const result = history.undo(); 41 + expect(result).toBeDefined(); 42 + expect(result!.shapes.size).toBe(1); 43 + }); 44 + 45 + it('returns undefined when undo is exhausted', () => { 46 + const s1 = addShape(base, 'rectangle', 0, 0); 47 + history.push(s1); 48 + expect(history.undo()).toBeUndefined(); 49 + }); 50 + }); 51 + 52 + describe('push, undo, redo', () => { 53 + it('redo returns the state that was undone', () => { 54 + const s1 = addShape(base, 'rectangle', 0, 0); 55 + const s2 = addShape(s1, 'ellipse', 100, 100); 56 + history.push(s1); 57 + history.push(s2); 58 + 59 + history.undo(); 60 + const redone = history.redo(); 61 + expect(redone).toBeDefined(); 62 + expect(redone!.shapes.size).toBe(2); 63 + }); 64 + 65 + it('returns undefined when redo stack is empty', () => { 66 + const s1 = addShape(base, 'rectangle', 0, 0); 67 + history.push(s1); 68 + expect(history.redo()).toBeUndefined(); 69 + }); 70 + 71 + it('supports multiple undo then redo', () => { 72 + const s1 = addShape(base, 'rectangle', 0, 0); 73 + const s2 = addShape(s1, 'ellipse', 100, 100); 74 + const s3 = addShape(s2, 'diamond', 200, 200); 75 + history.push(s1); 76 + history.push(s2); 77 + history.push(s3); 78 + 79 + history.undo(); 80 + history.undo(); 81 + history.redo(); 82 + const result = history.redo(); 83 + expect(result).toBeDefined(); 84 + expect(result!.shapes.size).toBe(3); 85 + }); 86 + }); 87 + 88 + describe('canUndo / canRedo', () => { 89 + it('both false on empty history', () => { 90 + expect(history.canUndo()).toBe(false); 91 + expect(history.canRedo()).toBe(false); 92 + }); 93 + 94 + it('canUndo false with single entry, canRedo false', () => { 95 + history.push(base); 96 + expect(history.canUndo()).toBe(false); 97 + expect(history.canRedo()).toBe(false); 98 + }); 99 + 100 + it('canUndo true with two entries', () => { 101 + history.push(base); 102 + history.push(addShape(base, 'rectangle', 0, 0)); 103 + expect(history.canUndo()).toBe(true); 104 + expect(history.canRedo()).toBe(false); 105 + }); 106 + 107 + it('canRedo true after undo', () => { 108 + history.push(base); 109 + history.push(addShape(base, 'rectangle', 0, 0)); 110 + history.undo(); 111 + expect(history.canUndo()).toBe(false); 112 + expect(history.canRedo()).toBe(true); 113 + }); 114 + 115 + it('canUndo and canRedo both true in the middle of history', () => { 116 + const s1 = addShape(base, 'rectangle', 0, 0); 117 + const s2 = addShape(s1, 'ellipse', 100, 100); 118 + const s3 = addShape(s2, 'diamond', 200, 200); 119 + history.push(s1); 120 + history.push(s2); 121 + history.push(s3); 122 + history.undo(); 123 + expect(history.canUndo()).toBe(true); 124 + expect(history.canRedo()).toBe(true); 125 + }); 126 + }); 127 + 128 + describe('max depth truncation', () => { 129 + it('enforces the 100-entry limit', () => { 130 + for (let i = 0; i < 120; i++) { 131 + history.push(addShape(base, 'rectangle', i * 10, 0)); 132 + } 133 + let undoCount = 0; 134 + while (history.canUndo()) { 135 + history.undo(); 136 + undoCount++; 137 + } 138 + expect(undoCount).toBe(99); 139 + }); 140 + }); 141 + 142 + describe('branching (push after undo discards redo stack)', () => { 143 + it('discards redo stack when pushing after undo', () => { 144 + const s1 = addShape(base, 'rectangle', 0, 0); 145 + const s2 = addShape(s1, 'ellipse', 100, 100); 146 + const s3 = addShape(s2, 'diamond', 200, 200); 147 + history.push(s1); 148 + history.push(s2); 149 + history.push(s3); 150 + 151 + history.undo(); 152 + expect(history.canRedo()).toBe(true); 153 + 154 + const s4 = addShape(s1, 'text', 300, 300); 155 + history.push(s4); 156 + 157 + expect(history.canRedo()).toBe(false); 158 + expect(history.redo()).toBeUndefined(); 159 + }); 160 + 161 + it('the new branch is navigable', () => { 162 + const s1 = addShape(base, 'rectangle', 0, 0); 163 + const s2 = addShape(s1, 'ellipse', 100, 100); 164 + history.push(s1); 165 + history.push(s2); 166 + 167 + history.undo(); 168 + const branch = addShape(s1, 'text', 50, 50); 169 + history.push(branch); 170 + 171 + const undone = history.undo(); 172 + expect(undone).toBeDefined(); 173 + expect(undone!.shapes.size).toBe(1); 174 + }); 175 + }); 176 + 177 + describe('clear', () => { 178 + it('resets everything', () => { 179 + const s1 = addShape(base, 'rectangle', 0, 0); 180 + const s2 = addShape(s1, 'ellipse', 100, 100); 181 + history.push(s1); 182 + history.push(s2); 183 + history.undo(); 184 + 185 + history.clear(); 186 + expect(history.canUndo()).toBe(false); 187 + expect(history.canRedo()).toBe(false); 188 + expect(history.undo()).toBeUndefined(); 189 + expect(history.redo()).toBeUndefined(); 190 + }); 191 + }); 192 + 193 + describe('Map serialization round-trips correctly', () => { 194 + it('shapes Map survives push and undo', () => { 195 + const s1 = addShape(base, 'rectangle', 0, 0); 196 + history.push(s1); 197 + const s2 = addShape(s1, 'ellipse', 100, 100); 198 + history.push(s2); 199 + 200 + const undone = history.undo()!; 201 + expect(undone.shapes).toBeInstanceOf(Map); 202 + expect(undone.shapes.size).toBe(1); 203 + const shape = [...undone.shapes.values()][0]!; 204 + expect(shape.kind).toBe('rectangle'); 205 + }); 206 + 207 + it('arrows Map survives push and undo', () => { 208 + let state = addShape(base, 'rectangle', 0, 0); 209 + const shapeId = [...state.shapes.keys()][0]!; 210 + state = addArrow(state, { shapeId, anchor: 'right' }, { x: 300, y: 100 }, 'link'); 211 + history.push(state); 212 + 213 + const s2 = addShape(state, 'ellipse', 200, 200); 214 + history.push(s2); 215 + 216 + const undone = history.undo()!; 217 + expect(undone.arrows).toBeInstanceOf(Map); 218 + expect(undone.arrows.size).toBe(1); 219 + const arrow = [...undone.arrows.values()][0]!; 220 + expect(arrow.label).toBe('link'); 221 + }); 222 + 223 + it('stored snapshots are independent copies (no reference sharing)', () => { 224 + const s1 = addShape(base, 'rectangle', 0, 0); 225 + history.push(s1); 226 + 227 + s1.panX = 9999; 228 + s1.shapes.clear(); 229 + 230 + const s2 = addShape(base, 'ellipse', 100, 100); 231 + history.push(s2); 232 + 233 + const undone = history.undo()!; 234 + expect(undone.panX).toBe(0); 235 + expect(undone.shapes.size).toBe(1); 236 + }); 237 + 238 + it('redo returns proper Map instances', () => { 239 + const s1 = addShape(base, 'rectangle', 0, 0); 240 + const s2 = addShape(s1, 'ellipse', 100, 100); 241 + history.push(s1); 242 + history.push(s2); 243 + 244 + history.undo(); 245 + const redone = history.redo()!; 246 + expect(redone.shapes).toBeInstanceOf(Map); 247 + expect(redone.arrows).toBeInstanceOf(Map); 248 + expect(redone.shapes.size).toBe(2); 249 + }); 250 + 251 + it('shape style records survive serialization', () => { 252 + let state = addShape(base, 'rectangle', 0, 0); 253 + const id = [...state.shapes.keys()][0]!; 254 + const shape = state.shapes.get(id)!; 255 + const shapes = new Map(state.shapes); 256 + shapes.set(id, { ...shape, style: { fill: '#ff0000', stroke: '#000' } }); 257 + state = { ...state, shapes }; 258 + 259 + history.push(state); 260 + const s2 = addShape(state, 'ellipse', 100, 100); 261 + history.push(s2); 262 + 263 + const undone = history.undo()!; 264 + const restored = undone.shapes.get(id)!; 265 + expect(restored.style).toEqual({ fill: '#ff0000', stroke: '#000' }); 266 + }); 267 + }); 268 + });
+291
tests/whiteboard.test.ts
··· 25 25 applyResize, 26 26 pointsToCatmullRomPath, 27 27 nearestEdgeAnchor, 28 + bringToFront, 29 + sendToBack, 30 + bringForward, 31 + sendBackward, 32 + alignShapes, 33 + distributeShapes, 34 + flipShapes, 28 35 } from '../src/diagrams/whiteboard'; 29 36 30 37 describe('whiteboard', () => { ··· 535 542 expect(result.anchor).toBe('right'); 536 543 expect(result.y).toBeGreaterThanOrEqual(100); 537 544 expect(result.y).toBeLessThanOrEqual(200); 545 + }); 546 + }); 547 + 548 + // Helper: create a whiteboard with snap disabled and N shapes at known positions. 549 + // Returns [state, [...ids]]. 550 + function setupShapes(positions: Array<{ x: number; y: number; w?: number; h?: number }>) { 551 + let wb = createWhiteboard(); 552 + wb = { ...wb, snapToGrid: false }; 553 + for (const p of positions) { 554 + wb = addShape(wb, 'rectangle', p.x, p.y, p.w ?? 50, p.h ?? 50); 555 + } 556 + return { state: wb, ids: [...wb.shapes.keys()] }; 557 + } 558 + 559 + describe('bringToFront', () => { 560 + it('moves shapes to end of Map (topmost)', () => { 561 + const { state, ids } = setupShapes([{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 200, y: 0 }]); 562 + const updated = bringToFront(state, [ids[0]]); 563 + const order = [...updated.shapes.keys()]; 564 + expect(order[0]).toBe(ids[1]); 565 + expect(order[1]).toBe(ids[2]); 566 + expect(order[2]).toBe(ids[0]); 567 + }); 568 + 569 + it('moves multiple shapes to front preserving their relative order', () => { 570 + const { state, ids } = setupShapes([{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 200, y: 0 }]); 571 + const updated = bringToFront(state, [ids[0], ids[1]]); 572 + const order = [...updated.shapes.keys()]; 573 + expect(order[0]).toBe(ids[2]); 574 + expect(order[1]).toBe(ids[0]); 575 + expect(order[2]).toBe(ids[1]); 576 + }); 577 + 578 + it('returns same state for empty iterable', () => { 579 + const { state } = setupShapes([{ x: 0, y: 0 }]); 580 + expect(bringToFront(state, [])).toBe(state); 581 + }); 582 + 583 + it('ignores non-existent IDs', () => { 584 + const { state, ids } = setupShapes([{ x: 0, y: 0 }, { x: 100, y: 0 }]); 585 + const updated = bringToFront(state, ['fake-id']); 586 + const order = [...updated.shapes.keys()]; 587 + expect(order).toEqual(ids); 588 + }); 589 + }); 590 + 591 + describe('sendToBack', () => { 592 + it('moves shapes to start of Map (bottommost)', () => { 593 + const { state, ids } = setupShapes([{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 200, y: 0 }]); 594 + const updated = sendToBack(state, [ids[2]]); 595 + const order = [...updated.shapes.keys()]; 596 + expect(order[0]).toBe(ids[2]); 597 + expect(order[1]).toBe(ids[0]); 598 + expect(order[2]).toBe(ids[1]); 599 + }); 600 + 601 + it('moves multiple shapes to back preserving their relative order', () => { 602 + const { state, ids } = setupShapes([{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 200, y: 0 }]); 603 + const updated = sendToBack(state, [ids[1], ids[2]]); 604 + const order = [...updated.shapes.keys()]; 605 + expect(order[0]).toBe(ids[1]); 606 + expect(order[1]).toBe(ids[2]); 607 + expect(order[2]).toBe(ids[0]); 608 + }); 609 + 610 + it('returns same state for empty iterable', () => { 611 + const { state } = setupShapes([{ x: 0, y: 0 }]); 612 + expect(sendToBack(state, [])).toBe(state); 613 + }); 614 + }); 615 + 616 + describe('bringForward', () => { 617 + it('swaps shape one position forward', () => { 618 + const { state, ids } = setupShapes([{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 200, y: 0 }]); 619 + const updated = bringForward(state, ids[0]); 620 + const order = [...updated.shapes.keys()]; 621 + expect(order[0]).toBe(ids[1]); 622 + expect(order[1]).toBe(ids[0]); 623 + expect(order[2]).toBe(ids[2]); 624 + }); 625 + 626 + it('returns same state when already at front', () => { 627 + const { state, ids } = setupShapes([{ x: 0, y: 0 }, { x: 100, y: 0 }]); 628 + const updated = bringForward(state, ids[1]); 629 + expect([...updated.shapes.keys()]).toEqual(ids); 630 + }); 631 + 632 + it('returns same state for non-existent ID', () => { 633 + const { state } = setupShapes([{ x: 0, y: 0 }]); 634 + expect(bringForward(state, 'fake-id')).toBe(state); 635 + }); 636 + }); 637 + 638 + describe('sendBackward', () => { 639 + it('swaps shape one position backward', () => { 640 + const { state, ids } = setupShapes([{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 200, y: 0 }]); 641 + const updated = sendBackward(state, ids[2]); 642 + const order = [...updated.shapes.keys()]; 643 + expect(order[0]).toBe(ids[0]); 644 + expect(order[1]).toBe(ids[2]); 645 + expect(order[2]).toBe(ids[1]); 646 + }); 647 + 648 + it('returns same state when already at back', () => { 649 + const { state, ids } = setupShapes([{ x: 0, y: 0 }, { x: 100, y: 0 }]); 650 + const updated = sendBackward(state, ids[0]); 651 + expect([...updated.shapes.keys()]).toEqual(ids); 652 + }); 653 + 654 + it('returns same state for non-existent ID', () => { 655 + const { state } = setupShapes([{ x: 0, y: 0 }]); 656 + expect(sendBackward(state, 'fake-id')).toBe(state); 657 + }); 658 + }); 659 + 660 + describe('alignShapes', () => { 661 + it('aligns left to leftmost x', () => { 662 + const { state, ids } = setupShapes([{ x: 10, y: 0 }, { x: 50, y: 0 }, { x: 100, y: 0 }]); 663 + const updated = alignShapes(state, ids, 'left'); 664 + for (const id of ids) { 665 + expect(updated.shapes.get(id)!.x).toBe(10); 666 + } 667 + }); 668 + 669 + it('aligns right to rightmost right edge', () => { 670 + const { state, ids } = setupShapes([ 671 + { x: 0, y: 0, w: 50 }, 672 + { x: 100, y: 0, w: 80 }, 673 + { x: 200, y: 0, w: 30 }, 674 + ]); 675 + // Rightmost edge: 200 + 30 = 230 676 + const updated = alignShapes(state, ids, 'right'); 677 + expect(updated.shapes.get(ids[0])!.x).toBe(230 - 50); // 180 678 + expect(updated.shapes.get(ids[1])!.x).toBe(230 - 80); // 150 679 + expect(updated.shapes.get(ids[2])!.x).toBe(230 - 30); // 200 680 + }); 681 + 682 + it('aligns top to topmost y', () => { 683 + const { state, ids } = setupShapes([{ x: 0, y: 20 }, { x: 0, y: 80 }, { x: 0, y: 5 }]); 684 + const updated = alignShapes(state, ids, 'top'); 685 + for (const id of ids) { 686 + expect(updated.shapes.get(id)!.y).toBe(5); 687 + } 688 + }); 689 + 690 + it('aligns bottom to bottommost bottom edge', () => { 691 + const { state, ids } = setupShapes([ 692 + { x: 0, y: 0, h: 40 }, 693 + { x: 0, y: 50, h: 60 }, 694 + { x: 0, y: 100, h: 20 }, 695 + ]); 696 + // Bottommost edge: 100 + 20 = 120 697 + const updated = alignShapes(state, ids, 'bottom'); 698 + expect(updated.shapes.get(ids[0])!.y).toBe(120 - 40); // 80 699 + expect(updated.shapes.get(ids[1])!.y).toBe(120 - 60); // 60 700 + expect(updated.shapes.get(ids[2])!.y).toBe(120 - 20); // 100 701 + }); 702 + 703 + it('aligns center-h to average horizontal center', () => { 704 + const { state, ids } = setupShapes([ 705 + { x: 0, y: 0, w: 40 }, 706 + { x: 100, y: 0, w: 60 }, 707 + ]); 708 + // Centers: 0+20=20, 100+30=130. Average = 75. 709 + const updated = alignShapes(state, ids, 'center-h'); 710 + expect(updated.shapes.get(ids[0])!.x).toBe(75 - 20); // 55 711 + expect(updated.shapes.get(ids[1])!.x).toBe(75 - 30); // 45 712 + }); 713 + 714 + it('aligns center-v to average vertical center', () => { 715 + const { state, ids } = setupShapes([ 716 + { x: 0, y: 0, h: 40 }, 717 + { x: 0, y: 100, h: 60 }, 718 + ]); 719 + // Centers: 0+20=20, 100+30=130. Average = 75. 720 + const updated = alignShapes(state, ids, 'center-v'); 721 + expect(updated.shapes.get(ids[0])!.y).toBe(75 - 20); // 55 722 + expect(updated.shapes.get(ids[1])!.y).toBe(75 - 30); // 45 723 + }); 724 + 725 + it('returns same state for single shape', () => { 726 + const { state, ids } = setupShapes([{ x: 50, y: 50 }]); 727 + expect(alignShapes(state, ids, 'left')).toBe(state); 728 + }); 729 + 730 + it('ignores non-existent IDs', () => { 731 + const { state } = setupShapes([{ x: 0, y: 0 }]); 732 + const updated = alignShapes(state, ['fake-1', 'fake-2'], 'left'); 733 + expect(updated).toBe(state); 734 + }); 735 + }); 736 + 737 + describe('distributeShapes', () => { 738 + it('distributes horizontally with even spacing', () => { 739 + const { state, ids } = setupShapes([ 740 + { x: 0, y: 0, w: 20 }, 741 + { x: 30, y: 0, w: 20 }, 742 + { x: 200, y: 0, w: 20 }, 743 + ]); 744 + // Total span: 200+20 - 0 = 220. Total shape width: 60. Gaps: (220-60)/2 = 80. 745 + const updated = distributeShapes(state, ids, 'horizontal'); 746 + expect(updated.shapes.get(ids[0])!.x).toBe(0); 747 + expect(updated.shapes.get(ids[1])!.x).toBe(100); // 0 + 20 + 80 748 + expect(updated.shapes.get(ids[2])!.x).toBe(200); // 100 + 20 + 80 749 + }); 750 + 751 + it('distributes vertically with even spacing', () => { 752 + const { state, ids } = setupShapes([ 753 + { x: 0, y: 0, h: 10 }, 754 + { x: 0, y: 20, h: 10 }, 755 + { x: 0, y: 100, h: 10 }, 756 + ]); 757 + // Total span: 100+10 - 0 = 110. Total shape height: 30. Gaps: (110-30)/2 = 40. 758 + const updated = distributeShapes(state, ids, 'vertical'); 759 + expect(updated.shapes.get(ids[0])!.y).toBe(0); 760 + expect(updated.shapes.get(ids[1])!.y).toBe(50); // 0 + 10 + 40 761 + expect(updated.shapes.get(ids[2])!.y).toBe(100); // 50 + 10 + 40 762 + }); 763 + 764 + it('returns same state for fewer than 3 shapes', () => { 765 + const { state, ids } = setupShapes([{ x: 0, y: 0 }, { x: 100, y: 0 }]); 766 + expect(distributeShapes(state, ids, 'horizontal')).toBe(state); 767 + }); 768 + 769 + it('handles shapes of different sizes', () => { 770 + const { state, ids } = setupShapes([ 771 + { x: 0, y: 0, w: 40 }, 772 + { x: 60, y: 0, w: 20 }, 773 + { x: 300, y: 0, w: 60 }, 774 + ]); 775 + // Total span: 300+60 - 0 = 360. Total widths: 40+20+60=120. Gaps: (360-120)/2 = 120. 776 + const updated = distributeShapes(state, ids, 'horizontal'); 777 + expect(updated.shapes.get(ids[0])!.x).toBe(0); 778 + expect(updated.shapes.get(ids[1])!.x).toBe(160); // 0 + 40 + 120 779 + expect(updated.shapes.get(ids[2])!.x).toBe(300); // 160 + 20 + 120 780 + }); 781 + }); 782 + 783 + describe('flipShapes', () => { 784 + it('flips positions horizontally', () => { 785 + const { state, ids } = setupShapes([ 786 + { x: 0, y: 0, w: 20 }, 787 + { x: 80, y: 0, w: 20 }, 788 + ]); 789 + // Bounding box: 0 to 100. Shape A at x=0 w=20 -> newX = 0 + (100 - 20) = 80. 790 + // Shape B at x=80 w=20 -> newX = 0 + (100 - 100) = 0. 791 + const updated = flipShapes(state, ids, 'horizontal'); 792 + expect(updated.shapes.get(ids[0])!.x).toBe(80); 793 + expect(updated.shapes.get(ids[1])!.x).toBe(0); 794 + }); 795 + 796 + it('flips positions vertically', () => { 797 + const { state, ids } = setupShapes([ 798 + { x: 0, y: 0, h: 30 }, 799 + { x: 0, y: 70, h: 30 }, 800 + ]); 801 + // Bounding box: 0 to 100. Shape A at y=0 h=30 -> newY = 0 + (100 - 30) = 70. 802 + // Shape B at y=70 h=30 -> newY = 0 + (100 - 100) = 0. 803 + const updated = flipShapes(state, ids, 'vertical'); 804 + expect(updated.shapes.get(ids[0])!.y).toBe(70); 805 + expect(updated.shapes.get(ids[1])!.y).toBe(0); 806 + }); 807 + 808 + it('flips three shapes horizontally', () => { 809 + const { state, ids } = setupShapes([ 810 + { x: 0, y: 0, w: 20 }, 811 + { x: 40, y: 0, w: 20 }, 812 + { x: 80, y: 0, w: 20 }, 813 + ]); 814 + // BBox: 0 to 100. A(0,20)->80, B(40,60)->40, C(80,100)->0 815 + const updated = flipShapes(state, ids, 'horizontal'); 816 + expect(updated.shapes.get(ids[0])!.x).toBe(80); 817 + expect(updated.shapes.get(ids[1])!.x).toBe(40); 818 + expect(updated.shapes.get(ids[2])!.x).toBe(0); 819 + }); 820 + 821 + it('returns same state for single shape', () => { 822 + const { state, ids } = setupShapes([{ x: 50, y: 50 }]); 823 + expect(flipShapes(state, ids, 'horizontal')).toBe(state); 824 + }); 825 + 826 + it('returns same state for non-existent IDs', () => { 827 + const { state } = setupShapes([{ x: 0, y: 0 }]); 828 + expect(flipShapes(state, ['fake-1', 'fake-2'], 'horizontal')).toBe(state); 538 829 }); 539 830 }); 540 831 });