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

Configure Feed

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

fix(diagrams): 8 QA bugs — text editing, rotation, export, eraser undo

- Inline text editing: mousedown guard prevents canvas clicks from
destroying active textarea (BUG-1)
- Eraser drag: adds pushHistory() before removing shapes (BUG-2)
- Opacity slider: adds pushHistory() before changing opacity (BUG-3)
- Line tool: switching tools now finishes or cleans up line state (BUG-4)
- Rotation: negative angles normalized with ((a%360)+360)%360
- Export: includes shape opacity via <g opacity="..."> wrapper
- Export: uses per-shape fontFamily/fontSize instead of defaults
- Export: line/freehand point bounds offset by shape.x/y

Also adds 66 new QA tests covering untested code paths.

Fixes #350

+1034 -18
+16
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.2] — 2026-04-03 9 + 10 + ### Fixed 11 + - Inline text editing no longer destroyed by mousedown on canvas (#350) 12 + - Eraser drag-to-erase now records undo history (#350) 13 + - Opacity slider now records undo history (#350) 14 + - Negative rotation angles normalized to 0-359 range (#350) 15 + - SVG/PNG export now includes shape opacity (#350) 16 + - SVG/PNG export uses per-shape font family and size instead of defaults (#350) 17 + - Export bounding box correctly offsets line/freehand points by shape position (#350) 18 + - Tool switch while drawing line now finishes or cleans up line state (#350) 19 + 20 + ### Added 21 + - 66 new QA tests covering groups, rotation, styles, duplication, export edge cases 22 + 8 23 ## [0.22.1] — 2026-04-03 9 24 10 25 ### Fixed 26 + - Fix line and arrow tools not working in diagrams (#348) 11 27 - Arrow tool now works from empty canvas space, not just shape-to-shape (#348) 12 28 - Arrow endpoints support mixed types (shape anchor or free-standing point) 13 29
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.22.1", 3 + "version": "0.22.2", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+15 -6
src/diagrams/export.ts
··· 94 94 // Freehand points may extend beyond the bounding box 95 95 if (shape.points) { 96 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); 97 + minX = Math.min(minX, shape.x + pt.x); 98 + minY = Math.min(minY, shape.y + pt.y); 99 + maxX = Math.max(maxX, shape.x + pt.x); 100 + maxY = Math.max(maxY, shape.y + pt.y); 101 101 } 102 102 } 103 103 } ··· 113 113 const fill = shape.style.fill ?? DEFAULT_FILL; 114 114 const stroke = shape.style.stroke ?? DEFAULT_STROKE; 115 115 const strokeWidth = shape.style.strokeWidth ?? DEFAULT_STROKE_WIDTH; 116 + const opacity = shape.opacity !== undefined && shape.opacity !== 1 ? shape.opacity : null; 116 117 117 118 const cx = shape.x + shape.width / 2; 118 119 const cy = shape.y + shape.height / 2; ··· 122 123 ? ` transform="rotate(${shape.rotation}, ${cx}, ${cy})"` 123 124 : ''; 124 125 126 + // Build opacity attribute if not fully opaque 127 + const opacityAttr = opacity !== null ? ` opacity="${opacity}"` : ''; 128 + 125 129 let element = ''; 126 130 127 131 switch (shape.kind) { ··· 144 148 145 149 case 'text': 146 150 // 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>`; 151 + element = `<text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central" font-family="${shape.fontFamily || DEFAULT_FONT_FAMILY}" font-size="${shape.fontSize || DEFAULT_FONT_SIZE}" fill="${stroke}"${rotation}>${escapeXml(shape.label)}</text>`; 148 152 break; 149 153 150 154 case 'triangle': { ··· 237 241 238 242 // Add centered label for non-text shapes that have one 239 243 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>`; 244 + element += `\n <text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central" font-family="${shape.fontFamily || DEFAULT_FONT_FAMILY}" font-size="${shape.fontSize || DEFAULT_FONT_SIZE}" fill="${stroke}">${escapeXml(shape.label)}</text>`; 245 + } 246 + 247 + // Wrap in group with opacity if not fully opaque 248 + if (opacityAttr) { 249 + element = `<g${opacityAttr}>${element}</g>`; 241 250 } 242 251 243 252 return element;
+15 -10
src/diagrams/main.ts
··· 900 900 901 901 // --- Mouse events --- 902 902 canvas.addEventListener('mousedown', (e) => { 903 + // Don't disrupt active text editing 904 + if (editingShapeId) return; 905 + 903 906 const pt = screenToCanvas(e.clientX, e.clientY); 904 907 905 908 // Middle-click or Space+drag or Hand tool = pan ··· 1043 1046 const pt = screenToCanvas(e.clientX, e.clientY); 1044 1047 const hit = shapeAtPoint(wb, pt.x, pt.y); 1045 1048 if (hit) { 1049 + pushHistory(); 1046 1050 wb = removeShape(wb, hit.id); 1047 1051 selectedShapeIds.delete(hit.id); 1048 1052 syncToYjs(); ··· 1488 1492 }); 1489 1493 styleOpacity?.addEventListener('input', () => { 1490 1494 if (selectedShapeIds.size === 0) return; 1495 + pushHistory(); 1491 1496 const val = Number(styleOpacity.value) / 100; 1492 1497 wb = setShapeOpacity(wb, selectedShapeIds, val); 1493 1498 styleOpacityValue.textContent = `${styleOpacity.value}%`; ··· 1875 1880 updateToolbar(); 1876 1881 render(); 1877 1882 break; 1878 - case 'v': case 'V': activeTool = 'select'; updateToolbar(); break; 1879 - case 'r': case 'R': activeTool = 'rectangle'; updateToolbar(); break; 1880 - case 'e': case 'E': activeTool = 'ellipse'; updateToolbar(); break; 1881 - case 'd': case 'D': activeTool = 'diamond'; updateToolbar(); break; 1882 - case 't': case 'T': activeTool = 'text'; updateToolbar(); break; 1883 + case 'v': case 'V': if (isDrawingLine && linePoints.length >= 2) { finishLine(); } isDrawingLine = false; linePoints = []; activeTool = 'select'; updateToolbar(); break; 1884 + case 'r': case 'R': if (isDrawingLine && linePoints.length >= 2) { finishLine(); } isDrawingLine = false; linePoints = []; activeTool = 'rectangle'; updateToolbar(); break; 1885 + case 'e': case 'E': if (isDrawingLine && linePoints.length >= 2) { finishLine(); } isDrawingLine = false; linePoints = []; activeTool = 'ellipse'; updateToolbar(); break; 1886 + case 'd': case 'D': if (isDrawingLine && linePoints.length >= 2) { finishLine(); } isDrawingLine = false; linePoints = []; activeTool = 'diamond'; updateToolbar(); break; 1887 + case 't': case 'T': if (isDrawingLine && linePoints.length >= 2) { finishLine(); } isDrawingLine = false; linePoints = []; activeTool = 'text'; updateToolbar(); break; 1883 1888 case 'l': case 'L': activeTool = 'line'; updateToolbar(); break; 1884 - case 'p': case 'P': activeTool = 'freehand'; updateToolbar(); break; 1885 - case 'a': case 'A': activeTool = 'arrow'; updateToolbar(); break; 1886 - case 'h': case 'H': activeTool = 'hand'; updateToolbar(); break; 1887 - case 'x': case 'X': activeTool = 'eraser'; updateToolbar(); break; 1888 - case 'n': case 'N': activeTool = 'note'; updateToolbar(); break; 1889 + case 'p': case 'P': if (isDrawingLine && linePoints.length >= 2) { finishLine(); } isDrawingLine = false; linePoints = []; activeTool = 'freehand'; updateToolbar(); break; 1890 + case 'a': case 'A': if (isDrawingLine && linePoints.length >= 2) { finishLine(); } isDrawingLine = false; linePoints = []; activeTool = 'arrow'; updateToolbar(); break; 1891 + case 'h': case 'H': if (isDrawingLine && linePoints.length >= 2) { finishLine(); } isDrawingLine = false; linePoints = []; activeTool = 'hand'; updateToolbar(); break; 1892 + case 'x': case 'X': if (isDrawingLine && linePoints.length >= 2) { finishLine(); } isDrawingLine = false; linePoints = []; activeTool = 'eraser'; updateToolbar(); break; 1893 + case 'n': case 'N': if (isDrawingLine && linePoints.length >= 2) { finishLine(); } isDrawingLine = false; linePoints = []; activeTool = 'note'; updateToolbar(); break; 1889 1894 case 'Delete': case 'Backspace': 1890 1895 if (selectedShapeIds.size > 0) { 1891 1896 pushHistory();
+1 -1
src/diagrams/whiteboard.ts
··· 696 696 const shape = state.shapes.get(shapeId); 697 697 if (!shape) return state; 698 698 const shapes = new Map(state.shapes); 699 - shapes.set(shapeId, { ...shape, rotation: (shape.rotation + angle) % 360 }); 699 + shapes.set(shapeId, { ...shape, rotation: ((shape.rotation + angle) % 360 + 360) % 360 }); 700 700 return { ...state, shapes }; 701 701 } 702 702
+841
tests/diagrams-bugs.test.ts
··· 1 + /** 2 + * Diagrams Bug Tests — failing tests that demonstrate discovered bugs. 3 + * 4 + * These tests cover BOTH bugs found in the UI layer (documented for manual fix) 5 + * AND the missing unit test coverage for pure-logic functions in whiteboard.ts. 6 + * 7 + * Every test in this file should either: 8 + * - Fail because it tests a bug (marked with comment: BUG) 9 + * - Pass because it fills a test coverage gap (marked with comment: COVERAGE GAP) 10 + */ 11 + import { describe, it, expect } from 'vitest'; 12 + import { 13 + createWhiteboard, 14 + addShape, 15 + addArrow, 16 + removeShape, 17 + groupShapes, 18 + ungroupShapes, 19 + getGroupMembers, 20 + rotateShape, 21 + setShapeRotation, 22 + setShapeStyle, 23 + setShapeOpacity, 24 + setShapeFontFamily, 25 + setShapeFontSize, 26 + duplicateShapes, 27 + applyResize, 28 + hitTestShape, 29 + snapPoint, 30 + getBoundingBox, 31 + toggleSnap, 32 + } from '../src/diagrams/whiteboard'; 33 + import type { Shape, WhiteboardState } from '../src/diagrams/whiteboard'; 34 + import { exportToSVG } from '../src/diagrams/export'; 35 + import History from '../src/diagrams/history'; 36 + 37 + // --------------------------------------------------------------------------- 38 + // Helpers 39 + // --------------------------------------------------------------------------- 40 + 41 + function noSnap(state: WhiteboardState): WhiteboardState { 42 + return { ...state, snapToGrid: false }; 43 + } 44 + 45 + function makeShape(overrides: Partial<Shape> & { id: string; kind: Shape['kind'] }): Shape { 46 + return { 47 + x: 0, 48 + y: 0, 49 + width: 120, 50 + height: 80, 51 + rotation: 0, 52 + label: '', 53 + style: {}, 54 + opacity: 1, 55 + ...overrides, 56 + }; 57 + } 58 + 59 + // --------------------------------------------------------------------------- 60 + // COVERAGE GAP: groupShapes / ungroupShapes / getGroupMembers 61 + // --------------------------------------------------------------------------- 62 + 63 + describe('groupShapes', () => { 64 + it('assigns a shared groupId to all specified shapes', () => { 65 + let wb = noSnap(createWhiteboard()); 66 + wb = addShape(wb, 'rectangle', 0, 0); 67 + wb = addShape(wb, 'ellipse', 100, 100); 68 + const ids = [...wb.shapes.keys()]; 69 + const { state, groupId } = groupShapes(wb, ids); 70 + expect(groupId).toBeTruthy(); 71 + expect(groupId.startsWith('group-')).toBe(true); 72 + for (const id of ids) { 73 + expect(state.shapes.get(id)!.groupId).toBe(groupId); 74 + } 75 + }); 76 + 77 + it('returns empty groupId for fewer than 2 shapes', () => { 78 + let wb = noSnap(createWhiteboard()); 79 + wb = addShape(wb, 'rectangle', 0, 0); 80 + const ids = [...wb.shapes.keys()]; 81 + const { state, groupId } = groupShapes(wb, ids); 82 + expect(groupId).toBe(''); 83 + // State should be unchanged 84 + expect(state.shapes.get(ids[0])!.groupId).toBeUndefined(); 85 + }); 86 + 87 + it('returns empty groupId for empty array', () => { 88 + const wb = createWhiteboard(); 89 + const { groupId } = groupShapes(wb, []); 90 + expect(groupId).toBe(''); 91 + }); 92 + }); 93 + 94 + describe('ungroupShapes', () => { 95 + it('removes groupId from all shapes in the group', () => { 96 + let wb = noSnap(createWhiteboard()); 97 + wb = addShape(wb, 'rectangle', 0, 0); 98 + wb = addShape(wb, 'ellipse', 100, 100); 99 + const ids = [...wb.shapes.keys()]; 100 + const { state, groupId } = groupShapes(wb, ids); 101 + const ungrouped = ungroupShapes(state, groupId); 102 + for (const id of ids) { 103 + expect(ungrouped.shapes.get(id)!.groupId).toBeUndefined(); 104 + } 105 + }); 106 + 107 + it('does not affect shapes in other groups', () => { 108 + let wb = noSnap(createWhiteboard()); 109 + wb = addShape(wb, 'rectangle', 0, 0); 110 + wb = addShape(wb, 'ellipse', 100, 0); 111 + wb = addShape(wb, 'diamond', 200, 0); 112 + const ids = [...wb.shapes.keys()]; 113 + const { state: s1, groupId: g1 } = groupShapes(wb, [ids[0], ids[1]]); 114 + // Manually group the third shape into a different group 115 + const s2 = { ...s1, shapes: new Map(s1.shapes) }; 116 + s2.shapes.set(ids[2], { ...s2.shapes.get(ids[2])!, groupId: 'other-group' }); 117 + const ungrouped = ungroupShapes(s2, g1); 118 + expect(ungrouped.shapes.get(ids[0])!.groupId).toBeUndefined(); 119 + expect(ungrouped.shapes.get(ids[1])!.groupId).toBeUndefined(); 120 + expect(ungrouped.shapes.get(ids[2])!.groupId).toBe('other-group'); 121 + }); 122 + }); 123 + 124 + describe('getGroupMembers', () => { 125 + it('returns all shape IDs belonging to a group', () => { 126 + let wb = noSnap(createWhiteboard()); 127 + wb = addShape(wb, 'rectangle', 0, 0); 128 + wb = addShape(wb, 'ellipse', 100, 0); 129 + wb = addShape(wb, 'diamond', 200, 0); 130 + const ids = [...wb.shapes.keys()]; 131 + const { state, groupId } = groupShapes(wb, [ids[0], ids[1]]); 132 + const members = getGroupMembers(state, groupId); 133 + expect(members).toHaveLength(2); 134 + expect(members).toContain(ids[0]); 135 + expect(members).toContain(ids[1]); 136 + expect(members).not.toContain(ids[2]); 137 + }); 138 + 139 + it('returns empty array for non-existent group', () => { 140 + const wb = createWhiteboard(); 141 + expect(getGroupMembers(wb, 'fake-group')).toEqual([]); 142 + }); 143 + }); 144 + 145 + // --------------------------------------------------------------------------- 146 + // COVERAGE GAP: rotateShape / setShapeRotation 147 + // --------------------------------------------------------------------------- 148 + 149 + describe('rotateShape', () => { 150 + it('accumulates rotation angle', () => { 151 + let wb = noSnap(createWhiteboard()); 152 + wb = addShape(wb, 'rectangle', 0, 0); 153 + const id = [...wb.shapes.keys()][0]; 154 + wb = rotateShape(wb, id, 45); 155 + expect(wb.shapes.get(id)!.rotation).toBe(45); 156 + wb = rotateShape(wb, id, 30); 157 + expect(wb.shapes.get(id)!.rotation).toBe(75); 158 + }); 159 + 160 + it('wraps rotation at 360 degrees', () => { 161 + let wb = noSnap(createWhiteboard()); 162 + wb = addShape(wb, 'rectangle', 0, 0); 163 + const id = [...wb.shapes.keys()][0]; 164 + wb = rotateShape(wb, id, 350); 165 + wb = rotateShape(wb, id, 20); 166 + expect(wb.shapes.get(id)!.rotation).toBe(10); // (350 + 20) % 360 167 + }); 168 + 169 + it('handles negative angles', () => { 170 + let wb = noSnap(createWhiteboard()); 171 + wb = addShape(wb, 'rectangle', 0, 0); 172 + const id = [...wb.shapes.keys()][0]; 173 + wb = rotateShape(wb, id, -45); 174 + // Fixed: negative angles now normalize to positive (315 instead of -45) 175 + expect(wb.shapes.get(id)!.rotation).toBe(315); 176 + }); 177 + 178 + it('returns unchanged state for non-existent shape', () => { 179 + const wb = createWhiteboard(); 180 + expect(rotateShape(wb, 'fake', 90)).toBe(wb); 181 + }); 182 + }); 183 + 184 + describe('setShapeRotation', () => { 185 + it('sets absolute rotation', () => { 186 + let wb = noSnap(createWhiteboard()); 187 + wb = addShape(wb, 'rectangle', 0, 0); 188 + const id = [...wb.shapes.keys()][0]; 189 + wb = setShapeRotation(wb, id, 90); 190 + expect(wb.shapes.get(id)!.rotation).toBe(90); 191 + }); 192 + 193 + it('wraps at 360', () => { 194 + let wb = noSnap(createWhiteboard()); 195 + wb = addShape(wb, 'rectangle', 0, 0); 196 + const id = [...wb.shapes.keys()][0]; 197 + wb = setShapeRotation(wb, id, 400); 198 + expect(wb.shapes.get(id)!.rotation).toBe(40); 199 + }); 200 + 201 + it('returns unchanged state for non-existent shape', () => { 202 + const wb = createWhiteboard(); 203 + expect(setShapeRotation(wb, 'fake', 90)).toBe(wb); 204 + }); 205 + }); 206 + 207 + // --------------------------------------------------------------------------- 208 + // COVERAGE GAP: setShapeStyle 209 + // --------------------------------------------------------------------------- 210 + 211 + describe('setShapeStyle', () => { 212 + it('sets style properties on a shape', () => { 213 + let wb = noSnap(createWhiteboard()); 214 + wb = addShape(wb, 'rectangle', 0, 0); 215 + const id = [...wb.shapes.keys()][0]; 216 + wb = setShapeStyle(wb, [id], { fill: '#ff0000', stroke: '#00ff00' }); 217 + const shape = wb.shapes.get(id)!; 218 + expect(shape.style.fill).toBe('#ff0000'); 219 + expect(shape.style.stroke).toBe('#00ff00'); 220 + }); 221 + 222 + it('merges with existing styles', () => { 223 + let wb = noSnap(createWhiteboard()); 224 + wb = addShape(wb, 'rectangle', 0, 0); 225 + const id = [...wb.shapes.keys()][0]; 226 + wb = setShapeStyle(wb, [id], { fill: '#ff0000' }); 227 + wb = setShapeStyle(wb, [id], { stroke: '#00ff00' }); 228 + const shape = wb.shapes.get(id)!; 229 + expect(shape.style.fill).toBe('#ff0000'); 230 + expect(shape.style.stroke).toBe('#00ff00'); 231 + }); 232 + 233 + it('applies to multiple shapes', () => { 234 + let wb = noSnap(createWhiteboard()); 235 + wb = addShape(wb, 'rectangle', 0, 0); 236 + wb = addShape(wb, 'ellipse', 100, 0); 237 + const ids = [...wb.shapes.keys()]; 238 + wb = setShapeStyle(wb, ids, { fill: '#0000ff' }); 239 + for (const id of ids) { 240 + expect(wb.shapes.get(id)!.style.fill).toBe('#0000ff'); 241 + } 242 + }); 243 + 244 + it('ignores non-existent shape IDs', () => { 245 + let wb = noSnap(createWhiteboard()); 246 + wb = addShape(wb, 'rectangle', 0, 0); 247 + const sizeBefore = wb.shapes.size; 248 + wb = setShapeStyle(wb, ['fake-id'], { fill: 'red' }); 249 + expect(wb.shapes.size).toBe(sizeBefore); 250 + }); 251 + }); 252 + 253 + // --------------------------------------------------------------------------- 254 + // COVERAGE GAP: setShapeOpacity 255 + // --------------------------------------------------------------------------- 256 + 257 + describe('setShapeOpacity', () => { 258 + it('sets opacity on a shape', () => { 259 + let wb = noSnap(createWhiteboard()); 260 + wb = addShape(wb, 'rectangle', 0, 0); 261 + const id = [...wb.shapes.keys()][0]; 262 + wb = setShapeOpacity(wb, [id], 0.5); 263 + expect(wb.shapes.get(id)!.opacity).toBe(0.5); 264 + }); 265 + 266 + it('clamps opacity to 0', () => { 267 + let wb = noSnap(createWhiteboard()); 268 + wb = addShape(wb, 'rectangle', 0, 0); 269 + const id = [...wb.shapes.keys()][0]; 270 + wb = setShapeOpacity(wb, [id], -0.5); 271 + expect(wb.shapes.get(id)!.opacity).toBe(0); 272 + }); 273 + 274 + it('clamps opacity to 1', () => { 275 + let wb = noSnap(createWhiteboard()); 276 + wb = addShape(wb, 'rectangle', 0, 0); 277 + const id = [...wb.shapes.keys()][0]; 278 + wb = setShapeOpacity(wb, [id], 1.5); 279 + expect(wb.shapes.get(id)!.opacity).toBe(1); 280 + }); 281 + 282 + it('sets opacity to exact boundaries', () => { 283 + let wb = noSnap(createWhiteboard()); 284 + wb = addShape(wb, 'rectangle', 0, 0); 285 + const id = [...wb.shapes.keys()][0]; 286 + wb = setShapeOpacity(wb, [id], 0); 287 + expect(wb.shapes.get(id)!.opacity).toBe(0); 288 + wb = setShapeOpacity(wb, [id], 1); 289 + expect(wb.shapes.get(id)!.opacity).toBe(1); 290 + }); 291 + 292 + it('applies to multiple shapes', () => { 293 + let wb = noSnap(createWhiteboard()); 294 + wb = addShape(wb, 'rectangle', 0, 0); 295 + wb = addShape(wb, 'ellipse', 100, 0); 296 + const ids = [...wb.shapes.keys()]; 297 + wb = setShapeOpacity(wb, ids, 0.3); 298 + for (const id of ids) { 299 + expect(wb.shapes.get(id)!.opacity).toBe(0.3); 300 + } 301 + }); 302 + }); 303 + 304 + // --------------------------------------------------------------------------- 305 + // COVERAGE GAP: setShapeFontFamily / setShapeFontSize 306 + // --------------------------------------------------------------------------- 307 + 308 + describe('setShapeFontFamily', () => { 309 + it('sets font family on a shape', () => { 310 + let wb = noSnap(createWhiteboard()); 311 + wb = addShape(wb, 'text', 0, 0); 312 + const id = [...wb.shapes.keys()][0]; 313 + wb = setShapeFontFamily(wb, [id], 'monospace'); 314 + expect(wb.shapes.get(id)!.fontFamily).toBe('monospace'); 315 + }); 316 + 317 + it('applies to multiple shapes', () => { 318 + let wb = noSnap(createWhiteboard()); 319 + wb = addShape(wb, 'text', 0, 0); 320 + wb = addShape(wb, 'rectangle', 100, 0); 321 + const ids = [...wb.shapes.keys()]; 322 + wb = setShapeFontFamily(wb, ids, 'serif'); 323 + for (const id of ids) { 324 + expect(wb.shapes.get(id)!.fontFamily).toBe('serif'); 325 + } 326 + }); 327 + }); 328 + 329 + describe('setShapeFontSize', () => { 330 + it('sets font size on a shape', () => { 331 + let wb = noSnap(createWhiteboard()); 332 + wb = addShape(wb, 'text', 0, 0); 333 + const id = [...wb.shapes.keys()][0]; 334 + wb = setShapeFontSize(wb, [id], 24); 335 + expect(wb.shapes.get(id)!.fontSize).toBe(24); 336 + }); 337 + 338 + it('enforces minimum font size of 8', () => { 339 + let wb = noSnap(createWhiteboard()); 340 + wb = addShape(wb, 'text', 0, 0); 341 + const id = [...wb.shapes.keys()][0]; 342 + wb = setShapeFontSize(wb, [id], 4); 343 + expect(wb.shapes.get(id)!.fontSize).toBe(8); 344 + }); 345 + 346 + it('applies to multiple shapes', () => { 347 + let wb = noSnap(createWhiteboard()); 348 + wb = addShape(wb, 'text', 0, 0); 349 + wb = addShape(wb, 'text', 100, 0); 350 + const ids = [...wb.shapes.keys()]; 351 + wb = setShapeFontSize(wb, ids, 36); 352 + for (const id of ids) { 353 + expect(wb.shapes.get(id)!.fontSize).toBe(36); 354 + } 355 + }); 356 + }); 357 + 358 + // --------------------------------------------------------------------------- 359 + // COVERAGE GAP: duplicateShapes 360 + // --------------------------------------------------------------------------- 361 + 362 + describe('duplicateShapes', () => { 363 + it('duplicates a shape with offset', () => { 364 + let wb = noSnap(createWhiteboard()); 365 + wb = addShape(wb, 'rectangle', 100, 200, 50, 50); 366 + const ids = [...wb.shapes.keys()]; 367 + const { state, idMap } = duplicateShapes(wb, ids); 368 + expect(state.shapes.size).toBe(2); 369 + expect(idMap.size).toBe(1); 370 + const newId = idMap.get(ids[0])!; 371 + const original = state.shapes.get(ids[0])!; 372 + const copy = state.shapes.get(newId)!; 373 + expect(copy.x).toBe(original.x + 20); 374 + expect(copy.y).toBe(original.y + 20); 375 + expect(copy.kind).toBe('rectangle'); 376 + expect(copy.width).toBe(50); 377 + expect(copy.height).toBe(50); 378 + }); 379 + 380 + it('uses custom offset', () => { 381 + let wb = noSnap(createWhiteboard()); 382 + wb = addShape(wb, 'rectangle', 0, 0); 383 + const ids = [...wb.shapes.keys()]; 384 + const { state, idMap } = duplicateShapes(wb, ids, 50, 50); 385 + const newId = idMap.get(ids[0])!; 386 + expect(state.shapes.get(newId)!.x).toBe(50); 387 + expect(state.shapes.get(newId)!.y).toBe(50); 388 + }); 389 + 390 + it('preserves shape properties (style, opacity, rotation, label)', () => { 391 + let wb = noSnap(createWhiteboard()); 392 + wb = addShape(wb, 'rectangle', 0, 0, 100, 80, 'Hello'); 393 + const id = [...wb.shapes.keys()][0]; 394 + const shapes = new Map(wb.shapes); 395 + shapes.set(id, { 396 + ...shapes.get(id)!, 397 + style: { fill: '#ff0000' }, 398 + opacity: 0.7, 399 + rotation: 45, 400 + fontFamily: 'serif', 401 + fontSize: 20, 402 + }); 403 + wb = { ...wb, shapes }; 404 + 405 + const { state, idMap } = duplicateShapes(wb, [id]); 406 + const newId = idMap.get(id)!; 407 + const copy = state.shapes.get(newId)!; 408 + expect(copy.label).toBe('Hello'); 409 + expect(copy.style.fill).toBe('#ff0000'); 410 + expect(copy.opacity).toBe(0.7); 411 + expect(copy.rotation).toBe(45); 412 + expect(copy.fontFamily).toBe('serif'); 413 + expect(copy.fontSize).toBe(20); 414 + }); 415 + 416 + it('preserves freehand points', () => { 417 + let wb = noSnap(createWhiteboard()); 418 + wb = addShape(wb, 'freehand', 0, 0, 100, 100); 419 + const id = [...wb.shapes.keys()][0]; 420 + const shapes = new Map(wb.shapes); 421 + shapes.set(id, { ...shapes.get(id)!, points: [{ x: 0, y: 0 }, { x: 50, y: 50 }] }); 422 + wb = { ...wb, shapes }; 423 + 424 + const { state, idMap } = duplicateShapes(wb, [id]); 425 + const newId = idMap.get(id)!; 426 + expect(state.shapes.get(newId)!.points).toEqual([{ x: 0, y: 0 }, { x: 50, y: 50 }]); 427 + }); 428 + 429 + it('clears groupId on duplicated shapes', () => { 430 + let wb = noSnap(createWhiteboard()); 431 + wb = addShape(wb, 'rectangle', 0, 0); 432 + wb = addShape(wb, 'ellipse', 100, 0); 433 + const ids = [...wb.shapes.keys()]; 434 + const { state: grouped } = groupShapes(wb, ids); 435 + const { state, idMap } = duplicateShapes(grouped, ids); 436 + for (const newId of idMap.values()) { 437 + expect(state.shapes.get(newId)!.groupId).toBeUndefined(); 438 + } 439 + }); 440 + 441 + it('duplicates arrows between duplicated shapes', () => { 442 + let wb = noSnap(createWhiteboard()); 443 + wb = addShape(wb, 'rectangle', 0, 0); 444 + wb = addShape(wb, 'rectangle', 200, 0); 445 + const ids = [...wb.shapes.keys()]; 446 + wb = addArrow(wb, { shapeId: ids[0], anchor: 'right' }, { shapeId: ids[1], anchor: 'left' }); 447 + expect(wb.arrows.size).toBe(1); 448 + 449 + const { state, idMap } = duplicateShapes(wb, ids); 450 + expect(state.arrows.size).toBe(2); 451 + // The new arrow should connect the new shapes 452 + const newArrows = [...state.arrows.values()].filter( 453 + a => !wb.arrows.has(a.id), 454 + ); 455 + expect(newArrows).toHaveLength(1); 456 + const newArrow = newArrows[0]; 457 + expect('shapeId' in newArrow.from && idMap.get(ids[0]) === (newArrow.from as any).shapeId).toBe(true); 458 + expect('shapeId' in newArrow.to && idMap.get(ids[1]) === (newArrow.to as any).shapeId).toBe(true); 459 + }); 460 + 461 + it('does not duplicate arrows when only one endpoint is duplicated', () => { 462 + let wb = noSnap(createWhiteboard()); 463 + wb = addShape(wb, 'rectangle', 0, 0); 464 + wb = addShape(wb, 'rectangle', 200, 0); 465 + const ids = [...wb.shapes.keys()]; 466 + wb = addArrow(wb, { shapeId: ids[0], anchor: 'right' }, { shapeId: ids[1], anchor: 'left' }); 467 + 468 + // Only duplicate the first shape 469 + const { state } = duplicateShapes(wb, [ids[0]]); 470 + expect(state.arrows.size).toBe(1); // Original arrow only 471 + }); 472 + 473 + it('handles empty input gracefully', () => { 474 + const wb = createWhiteboard(); 475 + const { state, idMap } = duplicateShapes(wb, []); 476 + expect(state.shapes.size).toBe(0); 477 + expect(idMap.size).toBe(0); 478 + }); 479 + }); 480 + 481 + // --------------------------------------------------------------------------- 482 + // COVERAGE GAP: applyResize -- untested handles (NE, SW, S, W) 483 + // --------------------------------------------------------------------------- 484 + 485 + describe('applyResize additional handles', () => { 486 + const base = { x: 100, y: 100, width: 80, height: 60 }; 487 + 488 + it('resizes NE corner', () => { 489 + const result = applyResize(base, 'ne', 20, -10); 490 + expect(result.width).toBe(100); // width + 20 491 + expect(result.height).toBe(70); // height - (-10) = 70 492 + expect(result.x).toBe(100); // x unchanged 493 + expect(result.y).toBe(90); // y + (-10) = 90 494 + }); 495 + 496 + it('resizes SW corner', () => { 497 + const result = applyResize(base, 'sw', -10, 20); 498 + expect(result.width).toBe(90); // width - (-10) = 90 499 + expect(result.height).toBe(80); // height + 20 500 + expect(result.x).toBe(90); // x + (-10) = 90 501 + expect(result.y).toBe(100); // y unchanged 502 + }); 503 + 504 + it('resizes S edge', () => { 505 + const result = applyResize(base, 's', 0, 15); 506 + expect(result.width).toBe(80); // unchanged 507 + expect(result.height).toBe(75); // height + 15 508 + expect(result.x).toBe(100); // unchanged 509 + expect(result.y).toBe(100); // unchanged 510 + }); 511 + 512 + it('resizes W edge', () => { 513 + const result = applyResize(base, 'w', -20, 0); 514 + expect(result.width).toBe(100); // width - (-20) = 100 515 + expect(result.height).toBe(60); // unchanged 516 + expect(result.x).toBe(80); // x + (-20) = 80 517 + expect(result.y).toBe(100); // unchanged 518 + }); 519 + 520 + it('clamps NE resize to minimum size', () => { 521 + const result = applyResize(base, 'ne', -200, 200); 522 + expect(result.width).toBe(10); 523 + expect(result.height).toBe(10); 524 + }); 525 + 526 + it('clamps SW resize to minimum size', () => { 527 + const result = applyResize(base, 'sw', 200, -200); 528 + expect(result.width).toBe(10); 529 + expect(result.height).toBe(10); 530 + }); 531 + }); 532 + 533 + // --------------------------------------------------------------------------- 534 + // COVERAGE GAP: Export of additional shape types 535 + // --------------------------------------------------------------------------- 536 + 537 + describe('exportToSVG additional shapes', () => { 538 + it('renders triangle as polygon', () => { 539 + const state = createWhiteboard(); 540 + state.shapes.set('t1', makeShape({ id: 't1', kind: 'triangle', x: 0, y: 0, width: 100, height: 80 })); 541 + const svg = exportToSVG(state); 542 + expect(svg).toContain('<polygon'); 543 + expect(svg).toContain('50,0'); // top center 544 + }); 545 + 546 + it('renders star as polygon with 10 points', () => { 547 + const state = createWhiteboard(); 548 + state.shapes.set('s1', makeShape({ id: 's1', kind: 'star', x: 0, y: 0, width: 100, height: 100 })); 549 + const svg = exportToSVG(state); 550 + expect(svg).toContain('<polygon'); 551 + }); 552 + 553 + it('renders hexagon as polygon with 6 points', () => { 554 + const state = createWhiteboard(); 555 + state.shapes.set('h1', makeShape({ id: 'h1', kind: 'hexagon', x: 0, y: 0, width: 100, height: 100 })); 556 + const svg = exportToSVG(state); 557 + expect(svg).toContain('<polygon'); 558 + }); 559 + 560 + it('renders cloud as path', () => { 561 + const state = createWhiteboard(); 562 + state.shapes.set('c1', makeShape({ id: 'c1', kind: 'cloud', x: 0, y: 0, width: 120, height: 80 })); 563 + const svg = exportToSVG(state); 564 + expect(svg).toContain('<path'); 565 + }); 566 + 567 + it('renders cylinder with ellipse and lines', () => { 568 + const state = createWhiteboard(); 569 + state.shapes.set('cy1', makeShape({ id: 'cy1', kind: 'cylinder', x: 0, y: 0, width: 80, height: 120 })); 570 + const svg = exportToSVG(state); 571 + expect(svg).toContain('<ellipse'); 572 + expect(svg).toContain('<line'); 573 + }); 574 + 575 + it('renders parallelogram as polygon', () => { 576 + const state = createWhiteboard(); 577 + state.shapes.set('p1', makeShape({ id: 'p1', kind: 'parallelogram', x: 0, y: 0, width: 120, height: 80 })); 578 + const svg = exportToSVG(state); 579 + expect(svg).toContain('<polygon'); 580 + }); 581 + 582 + it('renders note with folded corner path', () => { 583 + const state = createWhiteboard(); 584 + state.shapes.set('n1', makeShape({ id: 'n1', kind: 'note', x: 0, y: 0, width: 100, height: 100 })); 585 + const svg = exportToSVG(state); 586 + expect(svg).toContain('<path'); 587 + // Note default fill should be #fef08a when no custom fill 588 + expect(svg).toContain('#fef08a'); 589 + }); 590 + 591 + it('renders line shape as polyline', () => { 592 + const state = createWhiteboard(); 593 + state.shapes.set('l1', makeShape({ 594 + id: 'l1', 595 + kind: 'line', 596 + x: 10, 597 + y: 20, 598 + width: 100, 599 + height: 80, 600 + points: [{ x: 0, y: 0 }, { x: 50, y: 40 }, { x: 100, y: 80 }], 601 + })); 602 + const svg = exportToSVG(state); 603 + expect(svg).toContain('<polyline'); 604 + }); 605 + 606 + it('renders line shape with offset points', () => { 607 + const state = createWhiteboard(); 608 + state.shapes.set('l1', makeShape({ 609 + id: 'l1', 610 + kind: 'line', 611 + x: 10, 612 + y: 20, 613 + width: 100, 614 + height: 80, 615 + points: [{ x: 0, y: 0 }, { x: 100, y: 80 }], 616 + })); 617 + const svg = exportToSVG(state); 618 + // Line export should offset points by shape.x, shape.y: "10,20 110,100" 619 + expect(svg).toContain('10,20'); 620 + expect(svg).toContain('110,100'); 621 + }); 622 + }); 623 + 624 + // --------------------------------------------------------------------------- 625 + // COVERAGE GAP: Export with opacity and rotation 626 + // --------------------------------------------------------------------------- 627 + 628 + describe('exportToSVG with opacity', () => { 629 + it('does not include opacity in SVG output (opacity is a canvas-only feature)', () => { 630 + // This test documents the current behavior: exportToSVG does NOT render 631 + // opacity. If opacity support is added to export, this test should be 632 + // updated to verify it works. 633 + const state = createWhiteboard(); 634 + state.shapes.set('s1', makeShape({ 635 + id: 's1', kind: 'rectangle', x: 0, y: 0, opacity: 0.5, 636 + })); 637 + const svg = exportToSVG(state); 638 + // Currently no opacity attribute in export -- this is a coverage gap 639 + // export should ideally include opacity="0.5" 640 + expect(svg).toContain('<rect'); 641 + }); 642 + }); 643 + 644 + describe('exportToSVG with font properties', () => { 645 + it('renders text shape with default font', () => { 646 + const state = createWhiteboard(); 647 + state.shapes.set('t1', makeShape({ 648 + id: 't1', kind: 'text', x: 0, y: 0, label: 'Hello', 649 + })); 650 + const svg = exportToSVG(state); 651 + expect(svg).toContain('Hello'); 652 + expect(svg).toContain('font-family='); 653 + }); 654 + }); 655 + 656 + // --------------------------------------------------------------------------- 657 + // COVERAGE GAP: History with all optional shape properties 658 + // --------------------------------------------------------------------------- 659 + 660 + describe('History round-trip with optional shape properties', () => { 661 + it('preserves points through undo/redo', () => { 662 + const history = new History(); 663 + let wb = noSnap(createWhiteboard()); 664 + wb = addShape(wb, 'freehand', 0, 0, 100, 100); 665 + const id = [...wb.shapes.keys()][0]; 666 + const shapes = new Map(wb.shapes); 667 + shapes.set(id, { ...shapes.get(id)!, points: [{ x: 0, y: 0 }, { x: 50, y: 50 }] }); 668 + wb = { ...wb, shapes }; 669 + history.push(wb); 670 + 671 + const wb2 = addShape(wb, 'rectangle', 200, 200); 672 + history.push(wb2); 673 + 674 + const undone = history.undo()!; 675 + expect(undone.shapes.get(id)!.points).toEqual([{ x: 0, y: 0 }, { x: 50, y: 50 }]); 676 + }); 677 + 678 + it('preserves groupId through undo/redo', () => { 679 + const history = new History(); 680 + let wb = noSnap(createWhiteboard()); 681 + wb = addShape(wb, 'rectangle', 0, 0); 682 + wb = addShape(wb, 'ellipse', 100, 0); 683 + const ids = [...wb.shapes.keys()]; 684 + const { state, groupId } = groupShapes(wb, ids); 685 + history.push(state); 686 + 687 + const s2 = addShape(state, 'diamond', 200, 0); 688 + history.push(s2); 689 + 690 + const undone = history.undo()!; 691 + expect(undone.shapes.get(ids[0])!.groupId).toBe(groupId); 692 + }); 693 + 694 + it('preserves fontFamily and fontSize through undo/redo', () => { 695 + const history = new History(); 696 + let wb = noSnap(createWhiteboard()); 697 + wb = addShape(wb, 'text', 0, 0, 100, 50, 'Test'); 698 + const id = [...wb.shapes.keys()][0]; 699 + wb = setShapeFontFamily(wb, [id], 'monospace'); 700 + wb = setShapeFontSize(wb, [id], 24); 701 + history.push(wb); 702 + 703 + const wb2 = addShape(wb, 'rectangle', 200, 200); 704 + history.push(wb2); 705 + 706 + const undone = history.undo()!; 707 + expect(undone.shapes.get(id)!.fontFamily).toBe('monospace'); 708 + expect(undone.shapes.get(id)!.fontSize).toBe(24); 709 + }); 710 + }); 711 + 712 + // --------------------------------------------------------------------------- 713 + // COVERAGE GAP: hitTestShape edge cases 714 + // --------------------------------------------------------------------------- 715 + 716 + describe('hitTestShape edge cases', () => { 717 + it('handles zero-width shape (line)', () => { 718 + const shape = makeShape({ id: 's1', kind: 'rectangle', x: 50, y: 50, width: 0, height: 100 }); 719 + // Point on the zero-width line 720 + expect(hitTestShape(shape, 50, 75)).toBe(true); 721 + // Point off the line 722 + expect(hitTestShape(shape, 51, 75)).toBe(false); 723 + }); 724 + 725 + it('handles zero-height shape', () => { 726 + const shape = makeShape({ id: 's1', kind: 'rectangle', x: 50, y: 50, width: 100, height: 0 }); 727 + expect(hitTestShape(shape, 75, 50)).toBe(true); 728 + expect(hitTestShape(shape, 75, 51)).toBe(false); 729 + }); 730 + 731 + it('handles zero-dimension shape (point)', () => { 732 + const shape = makeShape({ id: 's1', kind: 'rectangle', x: 50, y: 50, width: 0, height: 0 }); 733 + expect(hitTestShape(shape, 50, 50)).toBe(true); 734 + expect(hitTestShape(shape, 50, 51)).toBe(false); 735 + }); 736 + }); 737 + 738 + // --------------------------------------------------------------------------- 739 + // COVERAGE GAP: snapPoint edge cases 740 + // --------------------------------------------------------------------------- 741 + 742 + describe('snapPoint edge cases', () => { 743 + it('handles grid size of 1 (identity)', () => { 744 + expect(snapPoint(13.7, 27.3, 1)).toEqual({ x: 14, y: 27 }); 745 + }); 746 + 747 + it('handles very large grid size', () => { 748 + expect(snapPoint(50, 50, 1000)).toEqual({ x: 0, y: 0 }); 749 + expect(snapPoint(600, 600, 1000)).toEqual({ x: 1000, y: 1000 }); 750 + }); 751 + 752 + it('handles negative coordinates', () => { 753 + expect(snapPoint(-13, -27, 20)).toEqual({ x: -20, y: -20 }); 754 + }); 755 + }); 756 + 757 + // --------------------------------------------------------------------------- 758 + // BUG: rotateShape produces negative rotation with negative angles 759 + // This is arguably a bug -- negative rotation values are semantically 760 + // odd and could cause issues in SVG transforms. 761 + // --------------------------------------------------------------------------- 762 + 763 + describe('BUG: rotateShape negative angle produces negative rotation', () => { 764 + it('should normalize negative rotation to 0-360 range', () => { 765 + let wb = noSnap(createWhiteboard()); 766 + wb = addShape(wb, 'rectangle', 0, 0); 767 + const id = [...wb.shapes.keys()][0]; 768 + wb = rotateShape(wb, id, -45); 769 + // JavaScript's % operator returns negative for negative operands: 770 + // (-45) % 360 = -45, not 315 771 + // This test FAILS because rotateShape returns -45 instead of 315 772 + expect(wb.shapes.get(id)!.rotation).toBe(315); 773 + }); 774 + }); 775 + 776 + // --------------------------------------------------------------------------- 777 + // BUG: Export does not render opacity attribute 778 + // --------------------------------------------------------------------------- 779 + 780 + describe('BUG: exportToSVG should include opacity on shapes', () => { 781 + it('should render opacity attribute on shapes with opacity < 1', () => { 782 + const state = createWhiteboard(); 783 + state.shapes.set('s1', makeShape({ 784 + id: 's1', kind: 'rectangle', x: 0, y: 0, opacity: 0.5, 785 + })); 786 + const svg = exportToSVG(state); 787 + // This test FAILS because exportToSVG does not render opacity 788 + expect(svg).toContain('opacity="0.5"'); 789 + }); 790 + }); 791 + 792 + // --------------------------------------------------------------------------- 793 + // BUG: Export does not render custom font properties 794 + // --------------------------------------------------------------------------- 795 + 796 + describe('BUG: exportToSVG should use shape font properties', () => { 797 + it('should render shape-specific fontSize in label text', () => { 798 + const state = createWhiteboard(); 799 + state.shapes.set('s1', makeShape({ 800 + id: 's1', kind: 'rectangle', x: 0, y: 0, label: 'Big Text', 801 + fontSize: 36, fontFamily: 'monospace', 802 + })); 803 + const svg = exportToSVG(state); 804 + // This test FAILS because export uses DEFAULT_FONT_SIZE (14) and 805 + // DEFAULT_FONT_FAMILY (system-ui), ignoring shape.fontSize and shape.fontFamily 806 + expect(svg).toContain('font-size="36"'); 807 + expect(svg).toContain('font-family="monospace"'); 808 + }); 809 + }); 810 + 811 + // --------------------------------------------------------------------------- 812 + // BUG: Export line shape viewBox computation uses local points 813 + // --------------------------------------------------------------------------- 814 + 815 + describe('BUG: Export computeBounds should handle line shape points correctly', () => { 816 + it('line shape points should not shrink the bounding box', () => { 817 + // Line shapes store points in local coords (0-based, relative to shape.x/y) 818 + // But computeBounds in export.ts iterates shape.points and uses raw values, 819 + // which are local coords like {x:0, y:0} -- these can pull minX/minY toward 0 820 + // even when the shape is at a distant position. 821 + const state = createWhiteboard(); 822 + state.shapes.set('l1', makeShape({ 823 + id: 'l1', 824 + kind: 'line', 825 + x: 500, 826 + y: 500, 827 + width: 100, 828 + height: 80, 829 + points: [{ x: 0, y: 0 }, { x: 100, y: 80 }], 830 + })); 831 + const svg = exportToSVG(state); 832 + const match = svg.match(/viewBox="([^"]*)"/); 833 + expect(match).not.toBeNull(); 834 + const [vx, vy] = match![1].split(' ').map(Number); 835 + // ViewBox should start near 500,500 (with padding), NOT near 0,0 836 + // This test FAILS because computeBounds uses raw point coords {x:0,y:0} 837 + // which pulls minX/minY to 0 838 + expect(vx).toBeGreaterThan(400); 839 + expect(vy).toBeGreaterThan(400); 840 + }); 841 + });
+145
tests/diagrams-qa-report.md
··· 1 + # Diagrams QA Report 2 + 3 + Date: 2026-04-04 4 + Reviewed by: QA Agent 5 + 6 + ## Critical Bugs (broken functionality) 7 + 8 + ### BUG-1: Double-click inline text editing is broken -- canvas mousedown intercepts textarea clicks 9 + 10 + **Symptom:** User double-clicks a shape to edit its text. The textarea appears momentarily, but clicking inside it to type triggers the canvas mousedown handler, which clears selection, starts marquee, calls render(), and destroys the textarea via `layer.innerHTML = ''`. 11 + 12 + **Root cause:** `src/diagrams/main.ts:902` -- the canvas mousedown handler has NO early return for `editingShapeId`. The textarea at line 834-840 has `keydown` stopPropagation but NO `mousedown` stopPropagation. When the user clicks inside the textarea, the event bubbles to the canvas, enters the select tool path (line 927), calls `shapeAtPoint` which finds the shape or empty space, triggers either drag or marquee, then `render()` at line 997 does `layer.innerHTML = ''` destroying the foreignObject+textarea. 13 + 14 + **Fix needed:** Add `if (editingShapeId) return;` at the top of the mousedown handler, or add `textarea.addEventListener('mousedown', e => e.stopPropagation())` in startTextEditing. 15 + 16 + ### BUG-2: Eraser drag-to-erase does not push undo history for subsequent shapes 17 + 18 + **Symptom:** Dragging the eraser across multiple shapes only records the first deletion in history. Undo only restores the first shape. 19 + 20 + **Root cause:** `src/diagrams/main.ts:1042-1051` -- the mousemove eraser path calls `removeShape`, `syncToYjs`, and `render` but never calls `pushHistory()`. Only the mousedown handler at line 918 pushes history. 21 + 22 + **Fix needed:** Track whether history was pushed for the current erase stroke, or batch all erasures. 23 + 24 + ### BUG-3: Opacity slider changes cannot be undone 25 + 26 + **Symptom:** Changing opacity via the style panel is not undoable. 27 + 28 + **Root cause:** `src/diagrams/main.ts:1489-1496` -- the opacity input handler does NOT call `pushHistory()`, unlike every other style panel handler (fill:1462, stroke:1468, strokeWidth:1475, strokeStyle:1483, fontFamily:1499, fontSize:1506). 29 + 30 + **Fix needed:** Add `pushHistory()` before line 1492. 31 + 32 + ### BUG-4: Line tool ghost state persists when switching tools mid-draw 33 + 34 + **Symptom:** Drawing a multi-point line, then pressing a tool key (V, R, etc.) switches tools but leaves `isDrawingLine=true` and `linePoints` populated. The line preview remains on screen. Only Escape properly cleans up. 35 + 36 + **Root cause:** `src/diagrams/main.ts:1878-1888` -- tool switch keys don't reset active drawing state. 37 + 38 + **Fix needed:** Add drawing state cleanup before each tool switch. 39 + 40 + ## Medium Issues (degraded UX) 41 + 42 + ### MED-1: Style panel and Properties panel overlap visually 43 + 44 + Both panels use `position: absolute; top: var(--space-sm); right: var(--space-sm)` and render on top of each other when a single shape is selected. 45 + 46 + **Root cause:** `src/css/app.css:8121-8131` and `src/css/app.css:8244-8255` 47 + 48 + ### MED-2: Hand tool and Freehand tool share the same icon 49 + 50 + Users cannot visually distinguish between them in the toolbar. Both use `&#9997;`. 51 + 52 + **Root cause:** `src/diagrams/index.html:40` and `src/diagrams/index.html:58` 53 + 54 + ### MED-3: Group and Ungroup buttons share the same icon 55 + 56 + Both use `&#9744;` (ballot box). 57 + 58 + **Root cause:** `src/diagrams/index.html:79-80` 59 + 60 + ### MED-4: Focus mode exit does not restore panel visibility 61 + 62 + `toggleFocusMode()` hides style/props panels but exiting focus mode doesn't call `render()` to restore them. 63 + 64 + **Root cause:** `src/diagrams/main.ts:1699-1708` 65 + 66 + ### MED-5: Double-click pushes two spurious undo history entries 67 + 68 + Every double-click generates two zero-distance-drag mousedown/mouseup cycles, each pushing a history entry. 69 + 70 + **Root cause:** `src/diagrams/main.ts:1168-1169` -- no zero-distance-drag check. 71 + 72 + ### MED-6: Text/Note shapes don't auto-enter edit mode after creation 73 + 74 + User must double-click after placing a text/note shape to edit its content. 75 + 76 + **Root cause:** `src/diagrams/main.ts:1191-1218` 77 + 78 + ### MED-7: Line tool shortcut 'L' missing from shortcuts dialog 79 + 80 + 'L' activates line tool (line 1883) but is absent from the shortcuts dialog (lines 1750-1781). 81 + 82 + **Root cause:** `src/diagrams/main.ts:1750-1781` 83 + 84 + ### MED-8: Shape creation does not auto-switch back to select tool 85 + 86 + After drawing a shape, the tool remains set to the shape tool. 87 + 88 + **Root cause:** `src/diagrams/main.ts:1191-1218` 89 + 90 + ### MED-9: Context menu doesn't close on Escape 91 + 92 + Pressing Escape resets tools and selection but doesn't close the open context menu. 93 + 94 + **Root cause:** `src/diagrams/main.ts:1857` -- no `hideContextMenu()` call. 95 + 96 + ### MED-10: Export viewBox computation for line shapes has offset error 97 + 98 + Export `computeBounds` iterates `shape.points` using raw local coordinates, but line shapes store points in local space (0-based). The bounds function adds these local coords directly to the shape extent computation, which can produce incorrect viewBox bounds. 99 + 100 + **Root cause:** `src/diagrams/export.ts:94-102` 101 + 102 + ## Minor Issues (polish) 103 + 104 + ### MIN-1: Default canvas-area cursor is crosshair even in select mode 105 + 106 + `src/css/app.css:8103` sets `cursor: crosshair`. updateCursor overrides on mousemove but initial load shows crosshair. 107 + 108 + ### MIN-2: Freehand strokes with 1-2 points are silently discarded 109 + 110 + `src/diagrams/main.ts:1268` requires `freehandPoints.length > 2`. Short strokes vanish with no feedback. 111 + 112 + ### MIN-3: Grid pattern doesn't respond to pan/zoom 113 + 114 + Static SVG pattern at `src/diagrams/index.html:107-109` doesn't track viewport transform. 115 + 116 + ### MIN-4: Arrow marker color mismatch between canvas and export 117 + 118 + Canvas: `var(--color-text)` (line 459). Export: `#000000` (export.ts:272). Dark theme arrows look different in export. 119 + 120 + ### MIN-5: No user feedback when crypto initialization fails 121 + 122 + `src/diagrams/main.ts:228` silently catches failures. No indication that E2EE is inactive. 123 + 124 + ## Missing Test Coverage 125 + 126 + ### Functions with zero test coverage 127 + 128 + 1. **groupShapes / ungroupShapes / getGroupMembers** -- core grouping 129 + 2. **rotateShape / setShapeRotation** -- rotation 130 + 3. **setShapeStyle** -- style management 131 + 4. **setShapeOpacity** -- opacity clamping 132 + 5. **setShapeFontFamily / setShapeFontSize** -- font properties 133 + 6. **duplicateShapes** -- copy/paste/alt-drag foundation 134 + 7. **Export of**: triangle, star, hexagon, cloud, cylinder, parallelogram, note, line shapes 135 + 8. **applyResize** for NE, SW, S, W handles 136 + 137 + ### Edge cases not tested 138 + 139 + 1. duplicateShapes preserving points, style, opacity, rotation, font 140 + 2. duplicateShapes arrow duplication between copies 141 + 3. rotateShape with negative angles, accumulation past 360 142 + 4. setShapeOpacity at boundary values (0, 1, negative, >1) 143 + 5. groupShapes with fewer than 2 shapes 144 + 6. hitTestShape with zero-dimension shapes 145 + 7. History round-trip for shapes with optional properties