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-like canvas interactions (#218)

scott d17fab27 955df6c4

+908 -62
+17
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.21.0] — 2026-04-03 9 + 10 + ### Added 11 + - Drag-to-create shapes on diagrams canvas instead of fixed-size click-to-place (#307) 12 + - On-canvas resize handles with 8 corner/edge handles for precise shape sizing (#308) 13 + - Persistent tool mode — drawing tools stay active until Escape or manual switch (#309) 14 + - Marquee box selection — drag on empty canvas to multi-select shapes (#310) 15 + - Smoothed freehand drawing using Catmull-Rom spline interpolation (#311) 16 + - Visual arrow routing with edge anchors and snap-to-target hover feedback (#312) 17 + - Multi-shape move and batch delete operations 18 + - 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 + 8 24 ## [0.20.0] — 2026-04-01 9 25 10 26 ### Added ··· 57 73 - Unit tests for mobile CSS media query rules (sidebar overlays, link tooltip, toolbar dropdowns) 58 74 59 75 ### Fixed 76 + - Fix E2E: stale SQLite CHECK constraint blocks new doc types (#305) 60 77 - Mobile: sidebars overlay content instead of pushing layout on tablet/phone (#271) 61 78 - Mobile: link preview tooltip constrained to viewport width 62 79 - Mobile: toolbar dropdowns use full-width fixed overlay on phones
+26 -18
e2e/diagrams.spec.ts
··· 73 73 // Shape should contain a rect element (rectangle kind) 74 74 await expect(shape.locator('rect')).toBeAttached(); 75 75 76 - // Tool should revert to select after placing a shape 77 - await expect(page.locator('#tool-select')).toHaveClass(/active/); 76 + // Tool stays active (persistent tool mode) — does NOT revert to select 77 + await expect(page.locator('#tool-rectangle')).toHaveClass(/active/); 78 78 }); 79 79 80 80 test('add ellipse shape', async ({ page }) => { ··· 224 224 await page.click('#tool-rectangle'); 225 225 await canvas.click({ position: { x: 300, y: 300 } }); 226 226 227 - // Now we are back in select mode; click on the shape to select it 228 - // The shape was placed around (300,300) in screen coords; click same spot 229 - await canvas.click({ position: { x: 300, y: 300 } }); 227 + // Switch to select mode (tool stays active after creation) 228 + await page.click('#tool-select'); 229 + // Click on the shape center to select it (shape is 120x80 at snapped pos) 230 + await canvas.click({ position: { x: 360, y: 340 } }); 230 231 231 232 // Properties panel should be visible 232 233 await expect(page.locator('#props-panel')).toBeVisible(); ··· 237 238 test('properties panel shows width and height inputs', async ({ page }) => { 238 239 const canvas = page.locator('#diagram-canvas'); 239 240 240 - // Add and select a rectangle 241 + // Add a rectangle, switch to select, then select it at center 241 242 await page.click('#tool-rectangle'); 242 243 await canvas.click({ position: { x: 300, y: 300 } }); 243 - await canvas.click({ position: { x: 300, y: 300 } }); 244 + await page.click('#tool-select'); 245 + await canvas.click({ position: { x: 360, y: 340 } }); 244 246 245 247 await expect(page.locator('#props-panel')).toBeVisible(); 246 248 await expect(page.locator('#prop-width')).toBeVisible(); ··· 251 253 test('properties panel shows correct default dimensions', async ({ page }) => { 252 254 const canvas = page.locator('#diagram-canvas'); 253 255 254 - // Add and select a rectangle (default size is 120x80) 256 + // Add a rectangle (default size is 120x80), switch to select, then select it at center 255 257 await page.click('#tool-rectangle'); 256 258 await canvas.click({ position: { x: 300, y: 300 } }); 257 - await canvas.click({ position: { x: 300, y: 300 } }); 259 + await page.click('#tool-select'); 260 + await canvas.click({ position: { x: 360, y: 340 } }); 258 261 259 262 await expect(page.locator('#props-panel')).toBeVisible(); 260 263 await expect(page.locator('#prop-width')).toHaveValue('120'); ··· 264 267 test('clicking empty canvas deselects shape and hides properties panel', async ({ page }) => { 265 268 const canvas = page.locator('#diagram-canvas'); 266 269 267 - // Add a rectangle and select it 270 + // Add a rectangle, switch to select, then select it at center 268 271 await page.click('#tool-rectangle'); 269 272 await canvas.click({ position: { x: 300, y: 300 } }); 270 - await canvas.click({ position: { x: 300, y: 300 } }); 273 + await page.click('#tool-select'); 274 + await canvas.click({ position: { x: 360, y: 340 } }); 271 275 await expect(page.locator('#props-panel')).toBeVisible(); 272 276 273 277 // Click on an empty area of the canvas ··· 294 298 await canvas.click({ position: { x: 300, y: 300 } }); 295 299 await expect(page.locator('.diagram-shape')).toHaveCount(1); 296 300 297 - // Select the shape 298 - await canvas.click({ position: { x: 300, y: 300 } }); 301 + // Switch to select mode and select the shape at center 302 + await page.click('#tool-select'); 303 + await canvas.click({ position: { x: 360, y: 340 } }); 299 304 await expect(page.locator('.diagram-shape.selected')).toHaveCount(1); 300 305 301 306 // Delete it ··· 309 314 test('delete with keyboard (Delete key) removes selected shape', async ({ page }) => { 310 315 const canvas = page.locator('#diagram-canvas'); 311 316 312 - // Add and select a rectangle 317 + // Add a rectangle, switch to select, then select it at center 313 318 await page.click('#tool-rectangle'); 314 319 await canvas.click({ position: { x: 300, y: 300 } }); 315 - await canvas.click({ position: { x: 300, y: 300 } }); 320 + await page.click('#tool-select'); 321 + await canvas.click({ position: { x: 360, y: 340 } }); 316 322 await expect(page.locator('.diagram-shape')).toHaveCount(1); 317 323 318 324 // Press Delete key ··· 323 329 test('delete with Backspace key removes selected shape', async ({ page }) => { 324 330 const canvas = page.locator('#diagram-canvas'); 325 331 326 - // Add and select a rectangle 332 + // Add a rectangle, switch to select, then select it at center 327 333 await page.click('#tool-rectangle'); 328 334 await canvas.click({ position: { x: 300, y: 300 } }); 329 - await canvas.click({ position: { x: 300, y: 300 } }); 335 + await page.click('#tool-select'); 336 + await canvas.click({ position: { x: 360, y: 340 } }); 330 337 await expect(page.locator('.diagram-shape')).toHaveCount(1); 331 338 332 339 // Press Backspace key ··· 337 344 test('delete does nothing when no shape is selected', async ({ page }) => { 338 345 const canvas = page.locator('#diagram-canvas'); 339 346 340 - // Add a rectangle but do not select it (click empty space after) 347 + // Add a rectangle, switch to select, click empty space to ensure nothing is selected 341 348 await page.click('#tool-rectangle'); 342 349 await canvas.click({ position: { x: 300, y: 300 } }); 350 + await page.click('#tool-select'); 343 351 await canvas.click({ position: { x: 50, y: 50 } }); 344 352 await expect(page.locator('.diagram-shape')).toHaveCount(1); 345 353 await expect(page.locator('.diagram-shape.selected')).toHaveCount(0);
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.20.0", 3 + "version": "0.21.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+50
src/css/app.css
··· 8154 8154 margin-top: 2px; 8155 8155 } 8156 8156 8157 + .diagram-shape.selected rect, 8158 + .diagram-shape.selected ellipse, 8159 + .diagram-shape.selected polygon, 8160 + .diagram-shape.selected path { 8161 + stroke: var(--color-teal); 8162 + } 8163 + 8164 + .resize-handle { 8165 + fill: white; 8166 + stroke: var(--color-teal); 8167 + stroke-width: 1.5; 8168 + cursor: pointer; 8169 + } 8170 + 8171 + .marquee-rect { 8172 + fill: var(--color-teal); 8173 + fill-opacity: 0.08; 8174 + stroke: var(--color-teal); 8175 + stroke-width: 1; 8176 + stroke-dasharray: 4 3; 8177 + pointer-events: none; 8178 + } 8179 + 8180 + .creation-preview { 8181 + pointer-events: none; 8182 + } 8183 + .creation-preview rect, 8184 + .creation-preview ellipse, 8185 + .creation-preview polygon { 8186 + fill: var(--color-teal); 8187 + fill-opacity: 0.06; 8188 + stroke: var(--color-teal); 8189 + stroke-width: 1.5; 8190 + stroke-dasharray: 6 3; 8191 + } 8192 + 8193 + .arrow-preview { 8194 + stroke: var(--color-text-muted); 8195 + stroke-width: 1.5; 8196 + stroke-dasharray: 6 3; 8197 + pointer-events: none; 8198 + } 8199 + 8200 + .arrow-hover-target rect, 8201 + .arrow-hover-target ellipse, 8202 + .arrow-hover-target polygon { 8203 + stroke: var(--color-teal); 8204 + stroke-dasharray: 4 2; 8205 + } 8206 + 8157 8207 /* ── Comments Sidebar ───────────────────────────────────────────────── */ 8158 8208 8159 8209 .comments-sidebar {
+397 -43
src/diagrams/main.ts
··· 8 8 import { importKey } from '../lib/crypto.js'; 9 9 import { EncryptedProvider } from '../lib/provider.js'; 10 10 import { 11 - createWhiteboard, addShape, removeShape, moveShape, resizeShape, setShapeLabel, 12 - addArrow, removeArrow, toggleSnap, pan, setZoom, 13 - hitTestShape, shapeAtPoint, arrowsForShape, getBoundingBox, elementCounts, 11 + createWhiteboard, addShape, removeShape, removeShapes, moveShape, moveShapes, 12 + resizeShape, setShapeLabel, addArrow, removeArrow, toggleSnap, setZoom, 13 + hitTestShape, shapeAtPoint, shapesInRect, arrowsForShape, getBoundingBox, 14 + getResizeHandles, hitTestResizeHandle, applyResize, pointsToCatmullRomPath, 15 + nearestEdgeAnchor, snapPoint, 14 16 } from './whiteboard.js'; 15 - import type { WhiteboardState, Shape, Arrow, ShapeKind, ArrowEndpoint, Point } from './whiteboard.js'; 17 + import type { 18 + WhiteboardState, Shape, Arrow, ShapeKind, ArrowEndpoint, Point, ResizeHandle, 19 + } from './whiteboard.js'; 16 20 17 21 // --- DOM refs --- 18 22 const $ = (id: string) => document.getElementById(id)!; ··· 28 32 // --- State --- 29 33 let wb: WhiteboardState = createWhiteboard(); 30 34 let activeTool: string = 'select'; 31 - let selectedShapeId: string | null = null; 35 + 36 + // Selection (multi-select) 37 + let selectedShapeIds: Set<string> = new Set(); 38 + 39 + // Shape dragging 32 40 let isDragging = false; 33 - let isPanning = false; 34 41 let dragStart: Point = { x: 0, y: 0 }; 35 - let dragShapeStart: Point = { x: 0, y: 0 }; 42 + let dragShapesStart: Map<string, Point> = new Map(); 43 + 44 + // Pan (middle-click or Space+drag) 45 + let isPanning = false; 36 46 let panStart: Point = { x: 0, y: 0 }; 37 47 let panWbStart: Point = { x: 0, y: 0 }; 48 + let spaceHeld = false; 49 + 50 + // Marquee selection 51 + let isMarqueeSelecting = false; 52 + let marqueeStart: Point = { x: 0, y: 0 }; 53 + let marqueeEnd: Point = { x: 0, y: 0 }; 54 + 55 + // Drag-to-create shapes 56 + let isCreatingShape = false; 57 + let createShapeKind: ShapeKind | null = null; 58 + let createStart: Point = { x: 0, y: 0 }; 59 + 60 + // Resize handles 61 + let isResizing = false; 62 + let resizeHandle: ResizeHandle | null = null; 63 + let resizeShapeId: string | null = null; 64 + let resizeStart: Point = { x: 0, y: 0 }; 65 + let resizeShapeOriginal: { x: number; y: number; width: number; height: number } | null = null; 66 + 67 + // Arrow drawing 38 68 let isDrawingArrow = false; 39 69 let arrowFromShape: string | null = null; 70 + let arrowFromAnchor: { anchor: string; x: number; y: number } | null = null; 71 + 72 + // Freehand drawing 40 73 let freehandPoints: Point[] = []; 41 74 let isDrawingFreehand = false; 75 + 76 + // Arrow hover target for visual feedback 77 + let arrowHoverTargetId: string | null = null; 42 78 43 79 // --- Yjs setup --- 44 80 const docId = window.location.pathname.split('/').pop() || ''; ··· 96 132 g.setAttribute('data-shape-id', shape.id); 97 133 g.setAttribute('transform', `translate(${shape.x}, ${shape.y})${shape.rotation ? ` rotate(${shape.rotation})` : ''}`); 98 134 g.classList.add('diagram-shape'); 99 - if (shape.id === selectedShapeId) g.classList.add('selected'); 135 + if (selectedShapeIds.has(shape.id)) g.classList.add('selected'); 136 + if (shape.id === arrowHoverTargetId) g.classList.add('arrow-hover-target'); 100 137 101 138 const fill = shape.style?.fill || 'var(--color-surface)'; 102 139 const stroke = shape.style?.stroke || 'var(--color-text)'; ··· 117 154 case 'freehand': 118 155 if (shape.points && shape.points.length > 1) { 119 156 const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 120 - const d = shape.points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' '); 121 - path.setAttribute('d', d); 157 + path.setAttribute('d', pointsToCatmullRomPath(shape.points)); 122 158 path.setAttribute('fill', 'none'); 123 159 path.setAttribute('stroke', stroke); 124 160 path.setAttribute('stroke-width', '2'); ··· 145 181 layer.appendChild(g); 146 182 }); 147 183 184 + // Render resize handles for single selection 185 + if (selectedShapeIds.size === 1) { 186 + const selId = [...selectedShapeIds][0]; 187 + const selShape = wb.shapes.get(selId); 188 + if (selShape) { 189 + const handles = getResizeHandles(selShape); 190 + for (const h of handles) { 191 + const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); 192 + circle.setAttribute('cx', String(h.x)); 193 + circle.setAttribute('cy', String(h.y)); 194 + circle.setAttribute('r', '4'); 195 + circle.classList.add('resize-handle'); 196 + circle.setAttribute('data-handle', h.handle); 197 + layer.appendChild(circle); 198 + } 199 + } 200 + } 201 + 148 202 // Render arrows 149 203 wb.arrows.forEach((arrow) => { 150 204 const from = resolveEndpoint(arrow.from); ··· 175 229 } 176 230 }); 177 231 232 + // Render marquee selection rect 233 + if (isMarqueeSelecting) { 234 + const mx = Math.min(marqueeStart.x, marqueeEnd.x); 235 + const my = Math.min(marqueeStart.y, marqueeEnd.y); 236 + const mw = Math.abs(marqueeEnd.x - marqueeStart.x); 237 + const mh = Math.abs(marqueeEnd.y - marqueeStart.y); 238 + const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); 239 + rect.setAttribute('x', String(mx)); 240 + rect.setAttribute('y', String(my)); 241 + rect.setAttribute('width', String(mw)); 242 + rect.setAttribute('height', String(mh)); 243 + rect.classList.add('marquee-rect'); 244 + layer.appendChild(rect); 245 + } 246 + 178 247 // Ensure arrowhead marker exists 179 248 let defs = canvas.querySelector('defs'); 180 249 if (defs && !defs.querySelector('#arrowhead')) { ··· 252 321 } 253 322 254 323 function updateProps() { 255 - if (!selectedShapeId) { 324 + if (selectedShapeIds.size !== 1) { 256 325 propsPanel.style.display = 'none'; 257 326 return; 258 327 } 259 - const shape = wb.shapes.get(selectedShapeId); 328 + const shape = wb.shapes.get([...selectedShapeIds][0]); 260 329 if (!shape) { propsPanel.style.display = 'none'; return; } 261 330 propsPanel.style.display = ''; 262 331 propLabel.value = shape.label || ''; ··· 273 342 }; 274 343 } 275 344 345 + // --- Creation preview --- 346 + function renderCreationPreview(start: Point, end: Point, kind: ShapeKind) { 347 + removeCreationPreview(); 348 + const x = Math.min(start.x, end.x); 349 + const y = Math.min(start.y, end.y); 350 + const w = Math.abs(end.x - start.x); 351 + const h = Math.abs(end.y - start.y); 352 + if (w < 2 && h < 2) return; 353 + 354 + const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); 355 + g.classList.add('creation-preview'); 356 + g.setAttribute('transform', `translate(${x}, ${y})`); 357 + 358 + switch (kind) { 359 + case 'rectangle': case 'text': { 360 + const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); 361 + rect.setAttribute('width', String(w)); 362 + rect.setAttribute('height', String(h)); 363 + rect.setAttribute('rx', '4'); 364 + g.appendChild(rect); 365 + break; 366 + } 367 + case 'ellipse': { 368 + const el = document.createElementNS('http://www.w3.org/2000/svg', 'ellipse'); 369 + el.setAttribute('cx', String(w / 2)); 370 + el.setAttribute('cy', String(h / 2)); 371 + el.setAttribute('rx', String(w / 2)); 372 + el.setAttribute('ry', String(h / 2)); 373 + g.appendChild(el); 374 + break; 375 + } 376 + case 'diamond': { 377 + const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); 378 + poly.setAttribute('points', `${w/2},0 ${w},${h/2} ${w/2},${h} 0,${h/2}`); 379 + g.appendChild(poly); 380 + break; 381 + } 382 + } 383 + layer.appendChild(g); 384 + } 385 + 386 + function removeCreationPreview() { 387 + layer.querySelector('.creation-preview')?.remove(); 388 + } 389 + 390 + // --- Arrow preview --- 391 + function renderArrowPreview(from: Point, to: Point) { 392 + removeArrowPreview(); 393 + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); 394 + line.setAttribute('x1', String(from.x)); 395 + line.setAttribute('y1', String(from.y)); 396 + line.setAttribute('x2', String(to.x)); 397 + line.setAttribute('y2', String(to.y)); 398 + line.setAttribute('marker-end', 'url(#arrowhead)'); 399 + line.classList.add('arrow-preview'); 400 + layer.appendChild(line); 401 + } 402 + 403 + function removeArrowPreview() { 404 + layer.querySelector('.arrow-preview')?.remove(); 405 + } 406 + 407 + // --- Cursor management --- 408 + const HANDLE_CURSORS: Record<ResizeHandle, string> = { 409 + nw: 'nwse-resize', se: 'nwse-resize', 410 + ne: 'nesw-resize', sw: 'nesw-resize', 411 + n: 'ns-resize', s: 'ns-resize', 412 + e: 'ew-resize', w: 'ew-resize', 413 + }; 414 + 415 + function updateCursor(e: MouseEvent) { 416 + const canvasArea = $('canvas-area'); 417 + if (activeTool !== 'select' || selectedShapeIds.size !== 1) { 418 + canvasArea.style.cursor = activeTool === 'select' ? '' : 'crosshair'; 419 + return; 420 + } 421 + const pt = screenToCanvas(e.clientX, e.clientY); 422 + const selShape = wb.shapes.get([...selectedShapeIds][0]); 423 + if (selShape) { 424 + const handle = hitTestResizeHandle(selShape, pt.x, pt.y); 425 + if (handle) { 426 + canvasArea.style.cursor = HANDLE_CURSORS[handle]; 427 + return; 428 + } 429 + } 430 + canvasArea.style.cursor = ''; 431 + } 432 + 433 + // --- Mouse events --- 276 434 canvas.addEventListener('mousedown', (e) => { 277 435 const pt = screenToCanvas(e.clientX, e.clientY); 278 436 437 + // Middle-click or Space+drag = pan 438 + if (e.button === 1 || spaceHeld) { 439 + isPanning = true; 440 + panStart = { x: e.clientX, y: e.clientY }; 441 + panWbStart = { x: wb.panX, y: wb.panY }; 442 + e.preventDefault(); 443 + return; 444 + } 445 + 279 446 if (activeTool === 'select') { 447 + // Check resize handles first (single selection) 448 + if (selectedShapeIds.size === 1) { 449 + const selId = [...selectedShapeIds][0]; 450 + const selShape = wb.shapes.get(selId); 451 + if (selShape) { 452 + const handle = hitTestResizeHandle(selShape, pt.x, pt.y); 453 + if (handle) { 454 + isResizing = true; 455 + resizeHandle = handle; 456 + resizeShapeId = selId; 457 + resizeStart = { x: e.clientX, y: e.clientY }; 458 + resizeShapeOriginal = { x: selShape.x, y: selShape.y, width: selShape.width, height: selShape.height }; 459 + return; 460 + } 461 + } 462 + } 463 + 280 464 const hit = shapeAtPoint(wb, pt.x, pt.y); 281 465 if (hit) { 282 - selectedShapeId = hit.id; 466 + // Shift+click toggles selection 467 + if (e.shiftKey) { 468 + const newSet = new Set(selectedShapeIds); 469 + if (newSet.has(hit.id)) newSet.delete(hit.id); 470 + else newSet.add(hit.id); 471 + selectedShapeIds = newSet; 472 + } else if (!selectedShapeIds.has(hit.id)) { 473 + selectedShapeIds = new Set([hit.id]); 474 + } 475 + // Start dragging all selected shapes 283 476 isDragging = true; 284 477 dragStart = { x: e.clientX, y: e.clientY }; 285 - dragShapeStart = { x: hit.x, y: hit.y }; 478 + dragShapesStart = new Map(); 479 + for (const id of selectedShapeIds) { 480 + const s = wb.shapes.get(id); 481 + if (s) dragShapesStart.set(id, { x: s.x, y: s.y }); 482 + } 286 483 } else { 287 - selectedShapeId = null; 288 - isPanning = true; 289 - panStart = { x: e.clientX, y: e.clientY }; 290 - panWbStart = { x: wb.panX, y: wb.panY }; 484 + // Empty canvas click → start marquee selection 485 + if (!e.shiftKey) selectedShapeIds = new Set(); 486 + isMarqueeSelecting = true; 487 + marqueeStart = pt; 488 + marqueeEnd = pt; 291 489 } 292 490 render(); 491 + 293 492 } else if (activeTool === 'arrow') { 294 493 const hit = shapeAtPoint(wb, pt.x, pt.y); 295 494 if (hit) { 296 495 isDrawingArrow = true; 297 496 arrowFromShape = hit.id; 497 + arrowFromAnchor = nearestEdgeAnchor(hit, pt.x, pt.y); 298 498 } 499 + 299 500 } else if (activeTool === 'freehand') { 300 501 isDrawingFreehand = true; 301 502 freehandPoints = [pt]; 503 + 302 504 } else { 303 - // Shape creation tools 505 + // Shape creation tools — start drag-to-create 304 506 const kind = activeTool as ShapeKind; 305 507 if (['rectangle', 'ellipse', 'diamond', 'text'].includes(kind)) { 306 - wb = addShape(wb, kind, pt.x, pt.y, 120, 80, kind === 'text' ? 'Text' : ''); 307 - syncToYjs(); 308 - render(); 309 - activeTool = 'select'; 310 - updateToolbar(); 508 + isCreatingShape = true; 509 + createShapeKind = kind; 510 + createStart = pt; 311 511 } 312 512 } 313 513 }); 314 514 315 515 canvas.addEventListener('mousemove', (e) => { 316 - if (isDragging && selectedShapeId) { 516 + updateCursor(e); 517 + 518 + if (isDragging && selectedShapeIds.size > 0) { 317 519 const dx = (e.clientX - dragStart.x) / wb.zoom; 318 520 const dy = (e.clientY - dragStart.y) / wb.zoom; 319 - wb = moveShape(wb, selectedShapeId, dragShapeStart.x + dx, dragShapeStart.y + dy); 521 + // Move all selected shapes from their original positions 522 + const shapes = new Map(wb.shapes); 523 + for (const [id, startPos] of dragShapesStart) { 524 + const shape = shapes.get(id); 525 + if (!shape) continue; 526 + const nx = startPos.x + dx; 527 + const ny = startPos.y + dy; 528 + const snapped = wb.snapToGrid ? snapPoint(nx, ny, wb.gridSize) : { x: nx, y: ny }; 529 + shapes.set(id, { ...shape, x: snapped.x, y: snapped.y }); 530 + } 531 + wb = { ...wb, shapes }; 320 532 render(); 533 + 321 534 } else if (isPanning) { 322 535 const dx = e.clientX - panStart.x; 323 536 const dy = e.clientY - panStart.y; 324 537 wb = { ...wb, panX: panWbStart.x + dx, panY: panWbStart.y + dy }; 325 538 render(); 539 + 540 + } else if (isMarqueeSelecting) { 541 + marqueeEnd = screenToCanvas(e.clientX, e.clientY); 542 + // Live preview: select shapes intersecting marquee 543 + const mx = Math.min(marqueeStart.x, marqueeEnd.x); 544 + const my = Math.min(marqueeStart.y, marqueeEnd.y); 545 + const mw = Math.abs(marqueeEnd.x - marqueeStart.x); 546 + const mh = Math.abs(marqueeEnd.y - marqueeStart.y); 547 + if (mw > 3 || mh > 3) { 548 + selectedShapeIds = new Set(shapesInRect(wb, { x: mx, y: my, width: mw, height: mh })); 549 + } 550 + render(); 551 + 552 + } else if (isCreatingShape && createShapeKind) { 553 + const pt = screenToCanvas(e.clientX, e.clientY); 554 + renderCreationPreview(createStart, pt, createShapeKind); 555 + 556 + } else if (isResizing && resizeHandle && resizeShapeId && resizeShapeOriginal) { 557 + const dx = (e.clientX - resizeStart.x) / wb.zoom; 558 + const dy = (e.clientY - resizeStart.y) / wb.zoom; 559 + const newBounds = applyResize(resizeShapeOriginal, resizeHandle, dx, dy); 560 + const shape = wb.shapes.get(resizeShapeId); 561 + if (shape) { 562 + const snapped = wb.snapToGrid 563 + ? snapPoint(newBounds.x, newBounds.y, wb.gridSize) 564 + : { x: newBounds.x, y: newBounds.y }; 565 + const shapes = new Map(wb.shapes); 566 + shapes.set(resizeShapeId, { ...shape, x: snapped.x, y: snapped.y, width: Math.max(10, newBounds.width), height: Math.max(10, newBounds.height) }); 567 + wb = { ...wb, shapes }; 568 + render(); 569 + } 570 + 571 + } else if (isDrawingArrow && arrowFromAnchor) { 572 + const pt = screenToCanvas(e.clientX, e.clientY); 573 + renderArrowPreview(arrowFromAnchor, pt); 574 + // Highlight hover target 575 + const hover = shapeAtPoint(wb, pt.x, pt.y); 576 + const newTarget = hover && hover.id !== arrowFromShape ? hover.id : null; 577 + if (newTarget !== arrowHoverTargetId) { 578 + arrowHoverTargetId = newTarget; 579 + render(); 580 + // Re-render arrow preview since render() clears it 581 + renderArrowPreview(arrowFromAnchor, pt); 582 + } 583 + 326 584 } else if (isDrawingFreehand) { 327 585 const pt = screenToCanvas(e.clientX, e.clientY); 328 586 freehandPoints.push(pt); 329 - // Live preview: draw temporary path 330 - let tempPath = layer.querySelector('.freehand-preview'); 587 + // Live preview with smooth curves 588 + let tempPath = layer.querySelector('.freehand-preview') as SVGPathElement | null; 331 589 if (!tempPath) { 332 - tempPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 590 + tempPath = document.createElementNS('http://www.w3.org/2000/svg', 'path') as SVGPathElement; 333 591 tempPath.classList.add('freehand-preview'); 334 592 tempPath.setAttribute('fill', 'none'); 335 593 tempPath.setAttribute('stroke', 'var(--color-text)'); ··· 337 595 tempPath.setAttribute('stroke-linecap', 'round'); 338 596 layer.appendChild(tempPath); 339 597 } 340 - const d = freehandPoints.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' '); 341 - tempPath.setAttribute('d', d); 598 + tempPath.setAttribute('d', pointsToCatmullRomPath(freehandPoints)); 342 599 } 343 600 }); 344 601 345 602 canvas.addEventListener('mouseup', (e) => { 346 603 if (isDragging) { 347 604 isDragging = false; 605 + dragShapesStart.clear(); 348 606 syncToYjs(); 349 607 } 608 + 350 609 if (isPanning) { 351 610 isPanning = false; 352 611 } 353 - if (isDrawingArrow && arrowFromShape) { 612 + 613 + if (isMarqueeSelecting) { 614 + isMarqueeSelecting = false; 615 + // If marquee was tiny (< 5px), treat as click-to-deselect 616 + const dx = Math.abs(marqueeEnd.x - marqueeStart.x); 617 + const dy = Math.abs(marqueeEnd.y - marqueeStart.y); 618 + if (dx < 5 && dy < 5) { 619 + selectedShapeIds = new Set(); 620 + } 621 + render(); 622 + } 623 + 624 + if (isCreatingShape && createShapeKind) { 625 + const pt = screenToCanvas(e.clientX, e.clientY); 626 + const dx = Math.abs(pt.x - createStart.x); 627 + const dy = Math.abs(pt.y - createStart.y); 628 + 629 + 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' : ''); 633 + } else { 634 + // Drag — use dragged bounds 635 + const x = Math.min(createStart.x, pt.x); 636 + 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' : ''); 639 + } 640 + syncToYjs(); 641 + removeCreationPreview(); 642 + isCreatingShape = false; 643 + createShapeKind = null; 644 + // Tool stays active (persistent tool mode) 645 + render(); 646 + } 647 + 648 + if (isResizing) { 649 + isResizing = false; 650 + resizeHandle = null; 651 + resizeShapeId = null; 652 + resizeShapeOriginal = null; 653 + syncToYjs(); 654 + } 655 + 656 + if (isDrawingArrow && arrowFromShape && arrowFromAnchor) { 354 657 const pt = screenToCanvas(e.clientX, e.clientY); 355 658 const hit = shapeAtPoint(wb, pt.x, pt.y); 356 659 if (hit && hit.id !== arrowFromShape) { 357 - wb = addArrow(wb, { shapeId: arrowFromShape, anchor: 'center' }, { shapeId: hit.id, anchor: 'center' }); 660 + const fromAnchor = arrowFromAnchor.anchor as 'top' | 'bottom' | 'left' | 'right'; 661 + const toAnchorInfo = nearestEdgeAnchor(hit, arrowFromAnchor.x, arrowFromAnchor.y); 662 + wb = addArrow(wb, 663 + { shapeId: arrowFromShape, anchor: fromAnchor }, 664 + { shapeId: hit.id, anchor: toAnchorInfo.anchor }, 665 + ); 358 666 syncToYjs(); 359 - render(); 360 667 } 668 + removeArrowPreview(); 669 + arrowHoverTargetId = null; 361 670 isDrawingArrow = false; 362 671 arrowFromShape = null; 672 + arrowFromAnchor = null; 673 + render(); 363 674 } 675 + 364 676 if (isDrawingFreehand && freehandPoints.length > 2) { 365 677 // Calculate bounding box for freehand 366 678 let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; ··· 379 691 if (preview) preview.remove(); 380 692 isDrawingFreehand = false; 381 693 freehandPoints = []; 382 - activeTool = 'select'; 383 - updateToolbar(); 694 + // Tool stays active (persistent tool mode) 384 695 } else if (isDrawingFreehand) { 385 696 isDrawingFreehand = false; 386 697 freehandPoints = []; ··· 421 732 }); 422 733 423 734 $('btn-delete').addEventListener('click', () => { 424 - if (selectedShapeId) { 425 - wb = removeShape(wb, selectedShapeId); 426 - selectedShapeId = null; 735 + if (selectedShapeIds.size > 0) { 736 + wb = removeShapes(wb, selectedShapeIds); 737 + selectedShapeIds = new Set(); 427 738 syncToYjs(); 428 739 render(); 429 740 } ··· 431 742 432 743 // Properties panel 433 744 propLabel.addEventListener('change', () => { 434 - if (selectedShapeId) { wb = setShapeLabel(wb, selectedShapeId, propLabel.value); syncToYjs(); render(); } 745 + if (selectedShapeIds.size === 1) { 746 + const id = [...selectedShapeIds][0]; 747 + wb = setShapeLabel(wb, id, propLabel.value); syncToYjs(); render(); 748 + } 435 749 }); 436 750 propWidth.addEventListener('change', () => { 437 - if (selectedShapeId) { wb = resizeShape(wb, selectedShapeId, Number(propWidth.value), Number(propHeight.value)); syncToYjs(); render(); } 751 + if (selectedShapeIds.size === 1) { 752 + const id = [...selectedShapeIds][0]; 753 + wb = resizeShape(wb, id, Number(propWidth.value), Number(propHeight.value)); syncToYjs(); render(); 754 + } 438 755 }); 439 756 propHeight.addEventListener('change', () => { 440 - if (selectedShapeId) { wb = resizeShape(wb, selectedShapeId, Number(propWidth.value), Number(propHeight.value)); syncToYjs(); render(); } 757 + if (selectedShapeIds.size === 1) { 758 + const id = [...selectedShapeIds][0]; 759 + wb = resizeShape(wb, id, Number(propWidth.value), Number(propHeight.value)); syncToYjs(); render(); 760 + } 441 761 }); 442 762 443 763 // Keyboard shortcuts 444 764 document.addEventListener('keydown', (e) => { 445 765 if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; 766 + 767 + if (e.key === ' ') { 768 + spaceHeld = true; 769 + e.preventDefault(); 770 + return; 771 + } 772 + 446 773 switch (e.key) { 774 + case 'Escape': 775 + activeTool = 'select'; 776 + selectedShapeIds = new Set(); 777 + // Cancel any in-progress operations 778 + isDrawingArrow = false; 779 + arrowFromShape = null; 780 + arrowFromAnchor = null; 781 + isDrawingFreehand = false; 782 + freehandPoints = []; 783 + isCreatingShape = false; 784 + createShapeKind = null; 785 + removeCreationPreview(); 786 + removeArrowPreview(); 787 + layer.querySelector('.freehand-preview')?.remove(); 788 + arrowHoverTargetId = null; 789 + updateToolbar(); 790 + render(); 791 + break; 447 792 case 'v': case 'V': activeTool = 'select'; updateToolbar(); break; 448 793 case 'r': case 'R': activeTool = 'rectangle'; updateToolbar(); break; 449 794 case 'e': case 'E': activeTool = 'ellipse'; updateToolbar(); break; ··· 452 797 case 'p': case 'P': activeTool = 'freehand'; updateToolbar(); break; 453 798 case 'a': case 'A': activeTool = 'arrow'; updateToolbar(); break; 454 799 case 'Delete': case 'Backspace': 455 - if (selectedShapeId) { wb = removeShape(wb, selectedShapeId); selectedShapeId = null; syncToYjs(); render(); } 800 + if (selectedShapeIds.size > 0) { 801 + wb = removeShapes(wb, selectedShapeIds); 802 + selectedShapeIds = new Set(); 803 + syncToYjs(); 804 + render(); 805 + } 456 806 break; 457 807 } 808 + }); 809 + 810 + document.addEventListener('keyup', (e) => { 811 + if (e.key === ' ') spaceHeld = false; 458 812 }); 459 813 460 814 // Title editing
+171
src/diagrams/whiteboard.ts
··· 271 271 export function elementCounts(state: WhiteboardState): { shapes: number; arrows: number } { 272 272 return { shapes: state.shapes.size, arrows: state.arrows.size }; 273 273 } 274 + 275 + // --- Multi-selection --- 276 + 277 + /** 278 + * Find all shapes whose bounding boxes intersect a rectangle. 279 + */ 280 + export function shapesInRect( 281 + state: WhiteboardState, 282 + rect: { x: number; y: number; width: number; height: number }, 283 + ): string[] { 284 + const result: string[] = []; 285 + for (const shape of state.shapes.values()) { 286 + if (shape.x + shape.width >= rect.x && shape.x <= rect.x + rect.width && 287 + shape.y + shape.height >= rect.y && shape.y <= rect.y + rect.height) { 288 + result.push(shape.id); 289 + } 290 + } 291 + return result; 292 + } 293 + 294 + /** 295 + * Move multiple shapes by a delta. 296 + */ 297 + export function moveShapes( 298 + state: WhiteboardState, 299 + shapeIds: Iterable<string>, 300 + dx: number, 301 + dy: number, 302 + ): WhiteboardState { 303 + const shapes = new Map(state.shapes); 304 + for (const id of shapeIds) { 305 + const shape = shapes.get(id); 306 + if (!shape) continue; 307 + const nx = shape.x + dx; 308 + const ny = shape.y + dy; 309 + const snapped = state.snapToGrid ? snapPoint(nx, ny, state.gridSize) : { x: nx, y: ny }; 310 + shapes.set(id, { ...shape, x: snapped.x, y: snapped.y }); 311 + } 312 + return { ...state, shapes }; 313 + } 314 + 315 + /** 316 + * Remove multiple shapes and their connected arrows in one pass. 317 + */ 318 + export function removeShapes(state: WhiteboardState, shapeIds: Iterable<string>): WhiteboardState { 319 + const idSet = new Set(shapeIds); 320 + if (idSet.size === 0) return state; 321 + const shapes = new Map(state.shapes); 322 + for (const id of idSet) shapes.delete(id); 323 + 324 + const arrows = new Map(state.arrows); 325 + for (const [aid, arrow] of arrows) { 326 + const fromConnected = 'shapeId' in arrow.from && idSet.has(arrow.from.shapeId); 327 + const toConnected = 'shapeId' in arrow.to && idSet.has(arrow.to.shapeId); 328 + if (fromConnected || toConnected) arrows.delete(aid); 329 + } 330 + return { ...state, shapes, arrows }; 331 + } 332 + 333 + // --- Resize handles --- 334 + 335 + export type ResizeHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'; 336 + 337 + /** 338 + * Get the 8 resize handle positions for a shape. 339 + */ 340 + export function getResizeHandles(shape: Shape): Array<{ handle: ResizeHandle; x: number; y: number }> { 341 + const { x, y, width: w, height: h } = shape; 342 + return [ 343 + { handle: 'nw', x, y }, 344 + { handle: 'n', x: x + w / 2, y }, 345 + { handle: 'ne', x: x + w, y }, 346 + { handle: 'e', x: x + w, y: y + h / 2 }, 347 + { handle: 'se', x: x + w, y: y + h }, 348 + { handle: 's', x: x + w / 2, y: y + h }, 349 + { handle: 'sw', x, y: y + h }, 350 + { handle: 'w', x, y: y + h / 2 }, 351 + ]; 352 + } 353 + 354 + /** 355 + * Check if a point hits any resize handle. 356 + */ 357 + export function hitTestResizeHandle(shape: Shape, px: number, py: number, radius = 6): ResizeHandle | null { 358 + for (const h of getResizeHandles(shape)) { 359 + if (Math.abs(px - h.x) <= radius && Math.abs(py - h.y) <= radius) return h.handle; 360 + } 361 + return null; 362 + } 363 + 364 + /** 365 + * Compute new shape bounds after dragging a resize handle by (dx, dy). 366 + */ 367 + export function applyResize( 368 + shape: { x: number; y: number; width: number; height: number }, 369 + handle: ResizeHandle, 370 + dx: number, 371 + dy: number, 372 + ): { x: number; y: number; width: number; height: number } { 373 + let { x, y, width, height } = shape; 374 + const MIN = 10; 375 + switch (handle) { 376 + case 'se': width += dx; height += dy; break; 377 + case 'e': width += dx; break; 378 + case 's': height += dy; break; 379 + case 'nw': x += dx; y += dy; width -= dx; height -= dy; break; 380 + case 'n': y += dy; height -= dy; break; 381 + case 'ne': y += dy; width += dx; height -= dy; break; 382 + case 'sw': x += dx; width -= dx; height += dy; break; 383 + case 'w': x += dx; width -= dx; break; 384 + } 385 + // Clamp minimum size — if width/height would be < MIN, undo the position shift 386 + if (width < MIN) { if (handle.includes('w')) x -= (MIN - width); width = MIN; } 387 + if (height < MIN) { if (handle.includes('n')) y -= (MIN - height); height = MIN; } 388 + return { x, y, width, height }; 389 + } 390 + 391 + // --- Smoothed freehand --- 392 + 393 + /** 394 + * Convert points to a smooth Catmull-Rom spline SVG path. 395 + */ 396 + export function pointsToCatmullRomPath(points: Point[], tension = 6): string { 397 + if (points.length === 0) return ''; 398 + if (points.length === 1) return `M${points[0].x},${points[0].y}`; 399 + if (points.length === 2) return `M${points[0].x},${points[0].y} L${points[1].x},${points[1].y}`; 400 + 401 + const pts = [points[0], ...points, points[points.length - 1]]; 402 + let d = `M${points[0].x},${points[0].y}`; 403 + 404 + for (let i = 0; i < pts.length - 3; i++) { 405 + const p0 = pts[i], p1 = pts[i + 1], p2 = pts[i + 2], p3 = pts[i + 3]; 406 + const cp1x = p1.x + (p2.x - p0.x) / tension; 407 + const cp1y = p1.y + (p2.y - p0.y) / tension; 408 + const cp2x = p2.x - (p3.x - p1.x) / tension; 409 + const cp2y = p2.y - (p3.y - p1.y) / tension; 410 + d += ` C${cp1x},${cp1y} ${cp2x},${cp2y} ${p2.x},${p2.y}`; 411 + } 412 + return d; 413 + } 414 + 415 + // --- Arrow edge anchors --- 416 + 417 + function clamp(val: number, min: number, max: number): number { 418 + return Math.max(min, Math.min(max, val)); 419 + } 420 + 421 + /** 422 + * Find the nearest edge anchor point on a shape relative to an external point. 423 + */ 424 + export function nearestEdgeAnchor( 425 + shape: Shape, 426 + px: number, 427 + py: number, 428 + ): { anchor: 'top' | 'bottom' | 'left' | 'right'; x: number; y: number } { 429 + const cx = shape.x + shape.width / 2; 430 + const cy = shape.y + shape.height / 2; 431 + const angle = Math.atan2(py - cy, px - cx); 432 + const absAngle = Math.abs(angle); 433 + const aspectThreshold = Math.atan2(shape.height / 2, shape.width / 2); 434 + 435 + if (absAngle < aspectThreshold) { 436 + return { anchor: 'right', x: shape.x + shape.width, y: clamp(cy, shape.y, shape.y + shape.height) }; 437 + } else if (absAngle > Math.PI - aspectThreshold) { 438 + return { anchor: 'left', x: shape.x, y: clamp(cy, shape.y, shape.y + shape.height) }; 439 + } else if (angle < 0) { 440 + return { anchor: 'top', x: clamp(cx, shape.x, shape.x + shape.width), y: shape.y }; 441 + } else { 442 + return { anchor: 'bottom', x: clamp(cx, shape.x, shape.x + shape.width), y: shape.y + shape.height }; 443 + } 444 + }
+246
tests/whiteboard.test.ts
··· 3 3 createWhiteboard, 4 4 addShape, 5 5 removeShape, 6 + removeShapes, 6 7 moveShape, 8 + moveShapes, 7 9 resizeShape, 8 10 setShapeLabel, 9 11 addArrow, ··· 14 16 setZoom, 15 17 hitTestShape, 16 18 shapeAtPoint, 19 + shapesInRect, 17 20 arrowsForShape, 18 21 getBoundingBox, 19 22 elementCounts, 23 + getResizeHandles, 24 + hitTestResizeHandle, 25 + applyResize, 26 + pointsToCatmullRomPath, 27 + nearestEdgeAnchor, 20 28 } from '../src/diagrams/whiteboard'; 21 29 22 30 describe('whiteboard', () => { ··· 289 297 const counts = elementCounts(wb); 290 298 expect(counts.shapes).toBe(2); 291 299 expect(counts.arrows).toBe(1); 300 + }); 301 + }); 302 + 303 + describe('shapesInRect', () => { 304 + it('finds shapes intersecting a rectangle', () => { 305 + let wb = createWhiteboard(); 306 + wb = addShape(wb, 'rectangle', 0, 0, 50, 50); 307 + wb = addShape(wb, 'rectangle', 200, 200, 50, 50); 308 + const ids = shapesInRect(wb, { x: -10, y: -10, width: 70, height: 70 }); 309 + expect(ids).toHaveLength(1); 310 + }); 311 + 312 + it('returns empty for no intersections', () => { 313 + let wb = createWhiteboard(); 314 + wb = addShape(wb, 'rectangle', 0, 0, 50, 50); 315 + expect(shapesInRect(wb, { x: 100, y: 100, width: 10, height: 10 })).toHaveLength(0); 316 + }); 317 + 318 + it('returns all shapes when rect covers everything', () => { 319 + let wb = createWhiteboard(); 320 + wb = addShape(wb, 'rectangle', 0, 0, 50, 50); 321 + wb = addShape(wb, 'rectangle', 100, 100, 50, 50); 322 + const ids = shapesInRect(wb, { x: -10, y: -10, width: 500, height: 500 }); 323 + expect(ids).toHaveLength(2); 324 + }); 325 + 326 + it('includes partially overlapping shapes', () => { 327 + let wb = createWhiteboard(); 328 + wb = addShape(wb, 'rectangle', 0, 0, 100, 100); 329 + // Rect overlaps just the corner 330 + const ids = shapesInRect(wb, { x: 80, y: 80, width: 50, height: 50 }); 331 + expect(ids).toHaveLength(1); 332 + }); 333 + }); 334 + 335 + describe('moveShapes', () => { 336 + it('moves multiple shapes by a delta', () => { 337 + let wb = createWhiteboard(); 338 + wb = { ...wb, snapToGrid: false }; 339 + wb = addShape(wb, 'rectangle', 0, 0, 50, 50); 340 + wb = addShape(wb, 'rectangle', 100, 100, 50, 50); 341 + const ids = [...wb.shapes.keys()]; 342 + const updated = moveShapes(wb, ids, 10, 20); 343 + expect(updated.shapes.get(ids[0])!.x).toBe(10); 344 + expect(updated.shapes.get(ids[0])!.y).toBe(20); 345 + expect(updated.shapes.get(ids[1])!.x).toBe(110); 346 + expect(updated.shapes.get(ids[1])!.y).toBe(120); 347 + }); 348 + 349 + it('applies snapping', () => { 350 + let wb = createWhiteboard(20); 351 + wb = addShape(wb, 'rectangle', 0, 0, 50, 50); 352 + const ids = [...wb.shapes.keys()]; 353 + const updated = moveShapes(wb, ids, 13, 27); 354 + const shape = updated.shapes.get(ids[0])!; 355 + expect(shape.x).toBe(20); 356 + expect(shape.y).toBe(20); 357 + }); 358 + 359 + it('skips non-existent shape IDs', () => { 360 + let wb = createWhiteboard(); 361 + wb = addShape(wb, 'rectangle', 0, 0); 362 + const updated = moveShapes(wb, ['fake-id'], 10, 10); 363 + expect(updated.shapes.size).toBe(1); 364 + }); 365 + }); 366 + 367 + describe('removeShapes', () => { 368 + it('removes multiple shapes', () => { 369 + let wb = createWhiteboard(); 370 + wb = addShape(wb, 'rectangle', 0, 0); 371 + wb = addShape(wb, 'rectangle', 100, 0); 372 + wb = addShape(wb, 'rectangle', 200, 0); 373 + const ids = [...wb.shapes.keys()]; 374 + const updated = removeShapes(wb, [ids[0], ids[1]]); 375 + expect(updated.shapes.size).toBe(1); 376 + expect(updated.shapes.has(ids[2])).toBe(true); 377 + }); 378 + 379 + it('removes connected arrows', () => { 380 + let wb = createWhiteboard(); 381 + wb = addShape(wb, 'rectangle', 0, 0); 382 + wb = addShape(wb, 'rectangle', 200, 0); 383 + wb = addShape(wb, 'rectangle', 400, 0); 384 + const [id1, id2, id3] = [...wb.shapes.keys()]; 385 + wb = addArrow(wb, { shapeId: id1, anchor: 'right' }, { shapeId: id2, anchor: 'left' }); 386 + wb = addArrow(wb, { shapeId: id2, anchor: 'right' }, { shapeId: id3, anchor: 'left' }); 387 + const updated = removeShapes(wb, [id2]); 388 + expect(updated.arrows.size).toBe(0); 389 + }); 390 + 391 + it('does nothing for empty set', () => { 392 + let wb = createWhiteboard(); 393 + wb = addShape(wb, 'rectangle', 0, 0); 394 + const updated = removeShapes(wb, []); 395 + expect(updated).toBe(wb); 396 + }); 397 + }); 398 + 399 + describe('getResizeHandles', () => { 400 + it('returns 8 handles', () => { 401 + const shape = { id: 's1', kind: 'rectangle' as const, x: 100, y: 100, width: 80, height: 60, rotation: 0, label: '', style: {} }; 402 + const handles = getResizeHandles(shape); 403 + expect(handles).toHaveLength(8); 404 + }); 405 + 406 + it('positions handles at corners and midpoints', () => { 407 + const shape = { id: 's1', kind: 'rectangle' as const, x: 0, y: 0, width: 100, height: 50, rotation: 0, label: '', style: {} }; 408 + const handles = getResizeHandles(shape); 409 + const byName = Object.fromEntries(handles.map(h => [h.handle, { x: h.x, y: h.y }])); 410 + expect(byName.nw).toEqual({ x: 0, y: 0 }); 411 + expect(byName.ne).toEqual({ x: 100, y: 0 }); 412 + expect(byName.se).toEqual({ x: 100, y: 50 }); 413 + expect(byName.sw).toEqual({ x: 0, y: 50 }); 414 + expect(byName.n).toEqual({ x: 50, y: 0 }); 415 + expect(byName.s).toEqual({ x: 50, y: 50 }); 416 + expect(byName.e).toEqual({ x: 100, y: 25 }); 417 + expect(byName.w).toEqual({ x: 0, y: 25 }); 418 + }); 419 + }); 420 + 421 + describe('hitTestResizeHandle', () => { 422 + const shape = { id: 's1', kind: 'rectangle' as const, x: 100, y: 100, width: 80, height: 60, rotation: 0, label: '', style: {} }; 423 + 424 + it('detects handle within radius', () => { 425 + // SE handle is at (180, 160) 426 + expect(hitTestResizeHandle(shape, 182, 158)).toBe('se'); 427 + }); 428 + 429 + it('returns null for point not near any handle', () => { 430 + expect(hitTestResizeHandle(shape, 140, 130)).toBeNull(); 431 + }); 432 + 433 + it('detects NW handle', () => { 434 + expect(hitTestResizeHandle(shape, 101, 101)).toBe('nw'); 435 + }); 436 + }); 437 + 438 + describe('applyResize', () => { 439 + const base = { x: 100, y: 100, width: 80, height: 60 }; 440 + 441 + it('resizes SE (bottom-right) corner', () => { 442 + const result = applyResize(base, 'se', 20, 10); 443 + expect(result).toEqual({ x: 100, y: 100, width: 100, height: 70 }); 444 + }); 445 + 446 + it('resizes NW (top-left) corner', () => { 447 + const result = applyResize(base, 'nw', -10, -20); 448 + expect(result).toEqual({ x: 90, y: 80, width: 90, height: 80 }); 449 + }); 450 + 451 + it('resizes E edge (width only)', () => { 452 + const result = applyResize(base, 'e', 30, 0); 453 + expect(result).toEqual({ x: 100, y: 100, width: 110, height: 60 }); 454 + }); 455 + 456 + it('resizes N edge (height only)', () => { 457 + const result = applyResize(base, 'n', 0, -15); 458 + expect(result).toEqual({ x: 100, y: 85, width: 80, height: 75 }); 459 + }); 460 + 461 + it('clamps to minimum size', () => { 462 + const result = applyResize(base, 'se', -200, -200); 463 + expect(result.width).toBe(10); 464 + expect(result.height).toBe(10); 465 + }); 466 + 467 + it('adjusts position when clamping NW resize', () => { 468 + const result = applyResize(base, 'nw', 200, 200); 469 + expect(result.width).toBe(10); 470 + expect(result.height).toBe(10); 471 + }); 472 + }); 473 + 474 + describe('pointsToCatmullRomPath', () => { 475 + it('returns empty string for no points', () => { 476 + expect(pointsToCatmullRomPath([])).toBe(''); 477 + }); 478 + 479 + it('returns M for single point', () => { 480 + expect(pointsToCatmullRomPath([{ x: 10, y: 20 }])).toBe('M10,20'); 481 + }); 482 + 483 + it('returns M...L for two points', () => { 484 + expect(pointsToCatmullRomPath([{ x: 0, y: 0 }, { x: 10, y: 10 }])).toBe('M0,0 L10,10'); 485 + }); 486 + 487 + it('returns path with C commands for 3+ points', () => { 488 + const points = [{ x: 0, y: 0 }, { x: 50, y: 50 }, { x: 100, y: 0 }]; 489 + const path = pointsToCatmullRomPath(points); 490 + expect(path).toMatch(/^M0,0/); 491 + expect(path).toContain('C'); 492 + // Path should end at the last point 493 + expect(path).toContain('100,0'); 494 + }); 495 + 496 + it('produces smooth curves for many points', () => { 497 + const points = Array.from({ length: 10 }, (_, i) => ({ x: i * 10, y: Math.sin(i) * 20 })); 498 + const path = pointsToCatmullRomPath(points); 499 + // Should have multiple C commands 500 + const cCount = (path.match(/C/g) || []).length; 501 + expect(cCount).toBeGreaterThanOrEqual(8); 502 + }); 503 + }); 504 + 505 + describe('nearestEdgeAnchor', () => { 506 + const shape = { id: 's1', kind: 'rectangle' as const, x: 100, y: 100, width: 100, height: 100, rotation: 0, label: '', style: {} }; 507 + 508 + it('returns right for point to the right', () => { 509 + const result = nearestEdgeAnchor(shape, 300, 150); 510 + expect(result.anchor).toBe('right'); 511 + expect(result.x).toBe(200); 512 + }); 513 + 514 + it('returns left for point to the left', () => { 515 + const result = nearestEdgeAnchor(shape, 0, 150); 516 + expect(result.anchor).toBe('left'); 517 + expect(result.x).toBe(100); 518 + }); 519 + 520 + it('returns top for point above', () => { 521 + const result = nearestEdgeAnchor(shape, 150, 0); 522 + expect(result.anchor).toBe('top'); 523 + expect(result.y).toBe(100); 524 + }); 525 + 526 + it('returns bottom for point below', () => { 527 + const result = nearestEdgeAnchor(shape, 150, 300); 528 + expect(result.anchor).toBe('bottom'); 529 + expect(result.y).toBe(200); 530 + }); 531 + 532 + it('clamps anchor point to shape bounds', () => { 533 + // Point far to the right and high — should return right anchor with y clamped 534 + const result = nearestEdgeAnchor(shape, 300, 150); 535 + expect(result.anchor).toBe('right'); 536 + expect(result.y).toBeGreaterThanOrEqual(100); 537 + expect(result.y).toBeLessThanOrEqual(200); 292 538 }); 293 539 }); 294 540 });