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

Configure Feed

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

feat: connector routing + remove @ts-nocheck from diagrams (0.46.0)

Add orthogonal (right-angle) and curved (cubic bezier) arrow routing
with anchor-aware control points. Remove @ts-nocheck from all 10
diagram modules with proper type fixes.

Closes #666, closes #667

+322 -30
+8
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.46.0] — 2026-04-15 11 + 12 + ### Added 13 + - Diagrams: connector routing — straight, orthogonal (right-angle), and curved (cubic bezier) arrow paths with anchor-aware control points (#667) 14 + 15 + ### Changed 16 + - Diagrams: removed @ts-nocheck from all 10 diagram modules — proper type annotations replace blanket suppression (#666) 17 + 10 18 ## [0.45.0] — 2026-04-15 11 19 12 20 ### Added
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.45.0", 3 + "version": "0.46.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
-1
src/diagrams/ai-chat-wiring.ts
··· 1 - // @ts-nocheck 2 1 /** 3 2 * AI Chat sidebar setup and wiring for diagrams. 4 3 * Extracted from main.ts.
-1
src/diagrams/canvas-events.ts
··· 1 - // @ts-nocheck 2 1 /** 3 2 * Canvas Event Handlers — mouse, touch, and wheel interactions for the diagram canvas. 4 3 *
-1
src/diagrams/canvas-interaction-helpers.ts
··· 1 - // @ts-nocheck 2 1 /** 3 2 * Canvas interaction helpers — cursor, drag, resize, rotate, arrow, freehand move/finalize. 4 3 * Extracted from canvas-events.ts.
-1
src/diagrams/keyboard-shortcuts.ts
··· 1 - // @ts-nocheck 2 1 /** 3 2 * Keyboard Shortcuts — key event handler and shortcuts help dialog. 4 3 *
+5 -6
src/diagrams/main.ts
··· 1 - // @ts-nocheck 2 1 /** 3 2 * Tools Diagrams — E2EE collaborative whiteboard/diagrams. 4 3 * Backed by Yjs for real-time collaboration. ··· 11 10 import { setupTooltips } from '../lib/tooltips.js'; 12 11 import { mountOfflineIndicator } from '../lib/offline-indicator.js'; 13 12 import { 14 - createWhiteboard, setShapeLabel, 13 + createWhiteboard, setShapeLabel, addShape, 15 14 } from './whiteboard.js'; 16 15 import type { 17 - WhiteboardState, Shape, Arrow, Point, 16 + WhiteboardState, Shape, Arrow, Point, ShapeKind, 18 17 } from './whiteboard.js'; 19 18 import { 20 19 createLayerState, addLayer, removeLayer, renameLayer, ··· 420 419 const sorted = sortedLayers(layerState); 421 420 let html = ''; 422 421 for (let i = sorted.length - 1; i >= 0; i--) { 423 - const l = sorted[i]; 422 + const l = sorted[i]!; 424 423 const activeLayer = selectedShapeIds.size === 1 425 - ? getShapeLayer(layerState, [...selectedShapeIds][0]) 424 + ? getShapeLayer(layerState, [...selectedShapeIds][0]!) 426 425 : null; 427 426 const isActive = l.id === activeLayer; 428 427 html += `<div class="layer-item${isActive ? ' layer-item--active' : ''}" data-layer-id="${l.id}">`; ··· 554 553 const cy = (canvasRect.height / 2 - wb.panY) / wb.zoom; 555 554 556 555 pushHistory(); 557 - wb = addShape(wb, kind as any, cx - w / 2, cy - h / 2, w, h); 556 + wb = addShape(wb, kind as ShapeKind, cx - w / 2, cy - h / 2, w, h); 558 557 syncToYjs(); 559 558 render(); 560 559 wfMenu.style.display = 'none';
+70 -14
src/diagrams/rendering.ts
··· 1 - // @ts-nocheck 2 1 /** 3 2 * Rendering logic for the diagram canvas — shapes, arrows, handles, marquee. 4 3 * Extracted from main.ts. ··· 237 236 } 238 237 } 239 238 240 - // Render arrows 239 + // Render arrows (supports straight, orthogonal, and curved routing) 241 240 wb.arrows.forEach((arrow) => { 242 241 const from = resolveEndpoint(wb, arrow.from); 243 242 const to = resolveEndpoint(wb, arrow.to); 244 243 if (!from || !to) return; 245 244 246 - const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); 247 - line.setAttribute('x1', String(from.x)); 248 - line.setAttribute('y1', String(from.y)); 249 - line.setAttribute('x2', String(to.x)); 250 - line.setAttribute('y2', String(to.y)); 251 - line.setAttribute('stroke', arrow.style?.stroke || 'var(--color-text)'); 252 - line.setAttribute('stroke-width', arrow.style?.strokeWidth || '2'); 253 - if (arrow.style?.strokeDasharray) line.setAttribute('stroke-dasharray', arrow.style.strokeDasharray); 254 - line.setAttribute('marker-end', 'url(#arrowhead)'); 255 - line.classList.add('diagram-arrow'); 256 - line.setAttribute('data-arrow-id', arrow.id); 257 - layer.appendChild(line); 245 + const strokeColor = arrow.style?.stroke || 'var(--color-text)'; 246 + const strokeWidth = arrow.style?.strokeWidth || '2'; 247 + const routeType = arrow.routeType || 'straight'; 248 + 249 + let el: SVGElement; 250 + 251 + if (routeType === 'orthogonal') { 252 + // Right-angle path via midpoint 253 + const midX = (from.x + to.x) / 2; 254 + const d = `M ${from.x} ${from.y} L ${midX} ${from.y} L ${midX} ${to.y} L ${to.x} ${to.y}`; 255 + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 256 + path.setAttribute('d', d); 257 + path.setAttribute('fill', 'none'); 258 + path.setAttribute('stroke', strokeColor); 259 + path.setAttribute('stroke-width', strokeWidth); 260 + if (arrow.style?.strokeDasharray) path.setAttribute('stroke-dasharray', arrow.style.strokeDasharray); 261 + path.setAttribute('marker-end', 'url(#arrowhead)'); 262 + el = path; 263 + } else if (routeType === 'curved') { 264 + // Cubic bezier with anchor-aware control points 265 + const dx = Math.abs(to.x - from.x) * 0.5; 266 + const dy = Math.abs(to.y - from.y) * 0.5; 267 + const offset = Math.max(dx, dy, 50); 268 + 269 + // Determine control point direction from anchor 270 + let cp1 = { x: from.x, y: from.y }; 271 + let cp2 = { x: to.x, y: to.y }; 272 + 273 + const fromAnchor = 'anchor' in arrow.from ? arrow.from.anchor : null; 274 + const toAnchor = 'anchor' in arrow.to ? arrow.to.anchor : null; 275 + 276 + if (fromAnchor === 'left') cp1 = { x: from.x - offset, y: from.y }; 277 + else if (fromAnchor === 'right') cp1 = { x: from.x + offset, y: from.y }; 278 + else if (fromAnchor === 'top') cp1 = { x: from.x, y: from.y - offset }; 279 + else if (fromAnchor === 'bottom') cp1 = { x: from.x, y: from.y + offset }; 280 + else cp1 = { x: from.x + offset, y: from.y }; 281 + 282 + if (toAnchor === 'left') cp2 = { x: to.x - offset, y: to.y }; 283 + else if (toAnchor === 'right') cp2 = { x: to.x + offset, y: to.y }; 284 + else if (toAnchor === 'top') cp2 = { x: to.x, y: to.y - offset }; 285 + else if (toAnchor === 'bottom') cp2 = { x: to.x, y: to.y + offset }; 286 + else cp2 = { x: to.x - offset, y: to.y }; 287 + 288 + const d = `M ${from.x} ${from.y} C ${cp1.x} ${cp1.y}, ${cp2.x} ${cp2.y}, ${to.x} ${to.y}`; 289 + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 290 + path.setAttribute('d', d); 291 + path.setAttribute('fill', 'none'); 292 + path.setAttribute('stroke', strokeColor); 293 + path.setAttribute('stroke-width', strokeWidth); 294 + if (arrow.style?.strokeDasharray) path.setAttribute('stroke-dasharray', arrow.style.strokeDasharray); 295 + path.setAttribute('marker-end', 'url(#arrowhead)'); 296 + el = path; 297 + } else { 298 + // Straight line (default) 299 + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); 300 + line.setAttribute('x1', String(from.x)); 301 + line.setAttribute('y1', String(from.y)); 302 + line.setAttribute('x2', String(to.x)); 303 + line.setAttribute('y2', String(to.y)); 304 + line.setAttribute('stroke', strokeColor); 305 + line.setAttribute('stroke-width', strokeWidth); 306 + if (arrow.style?.strokeDasharray) line.setAttribute('stroke-dasharray', arrow.style.strokeDasharray); 307 + line.setAttribute('marker-end', 'url(#arrowhead)'); 308 + el = line; 309 + } 310 + 311 + el.classList.add('diagram-arrow'); 312 + el.setAttribute('data-arrow-id', arrow.id); 313 + layer.appendChild(el); 258 314 259 315 if (arrow.label) { 260 316 const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
-1
src/diagrams/shape-renderers.ts
··· 1 - // @ts-nocheck 2 1 /** 3 2 * SVG Shape Renderers — creates SVG elements for each shape kind, 4 3 * plus creation/arrow/line preview overlays.
-1
src/diagrams/snap-guides.ts
··· 1 - // @ts-nocheck 2 1 /** 3 2 * Snap guide computation and rendering for alignment snapping during drag. 4 3 * Extracted from main.ts.
+1 -2
src/diagrams/toolbar-wiring.ts
··· 1 - // @ts-nocheck 2 1 /** 3 2 * Toolbar Wiring — tool buttons, z-order, alignment, group/ungroup, 4 3 * export, style panel, properties panel, flip buttons, focus mode, ··· 310 309 const selectedShapeIds = deps.getSelectedShapeIds(); 311 310 if (selectedShapeIds.size < 2) return; 312 311 deps.pushHistory(); 313 - const alignment = (btn as HTMLElement).dataset.align as any; 312 + const alignment = (btn as HTMLElement).dataset.align as 'left' | 'center-h' | 'right' | 'top' | 'center-v' | 'bottom'; 314 313 deps.setState(alignShapes(deps.getState(), [...selectedShapeIds], alignment)); 315 314 deps.syncToYjs(); 316 315 deps.render();
-1
src/diagrams/touch-events.ts
··· 1 - // @ts-nocheck 2 1 /** 3 2 * Touch event handlers — pinch-to-zoom and single-finger pan. 4 3 * Extracted from canvas-events.ts.
+4
src/diagrams/whiteboard-types.ts
··· 37 37 fontSize?: number; 38 38 } 39 39 40 + export type ArrowRouteType = 'straight' | 'orthogonal' | 'curved'; 41 + 40 42 export interface Arrow { 41 43 id: string; 42 44 from: ArrowEndpoint; 43 45 to: ArrowEndpoint; 44 46 label: string; 45 47 style: Record<string, string>; 48 + /** Routing mode: straight (default), orthogonal (right-angle), or curved (bezier). */ 49 + routeType?: ArrowRouteType; 46 50 } 47 51 48 52 export interface WhiteboardState {
+2
src/diagrams/whiteboard.ts
··· 204 204 from: ArrowEndpoint, 205 205 to: ArrowEndpoint, 206 206 label = '', 207 + routeType?: Arrow['routeType'], 207 208 ): WhiteboardState { 208 209 const arrow: Arrow = { 209 210 id: `arrow-${Date.now()}-${++_counter}`, ··· 211 212 to, 212 213 label, 213 214 style: {}, 215 + ...(routeType && routeType !== 'straight' ? { routeType } : {}), 214 216 }; 215 217 const arrows = new Map(state.arrows); 216 218 arrows.set(arrow.id, arrow);
+231
tests/connector-routing.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createWhiteboard, 4 + addArrow, 5 + addShape, 6 + removeArrow, 7 + } from '../src/diagrams/whiteboard.js'; 8 + import type { 9 + Arrow, 10 + ArrowEndpoint, 11 + ArrowRouteType, 12 + WhiteboardState, 13 + } from '../src/diagrams/whiteboard-types.js'; 14 + import { resolveEndpoint } from '../src/diagrams/rendering.js'; 15 + 16 + /** 17 + * Connector routing tests. 18 + * 19 + * Tests cover: 20 + * 1. addArrow with explicit routeType values 21 + * 2. Backward compatibility (no routeType = no property stored) 22 + * 3. 'straight' is treated as default and not stored 23 + * 4. resolveEndpoint for shape anchors and free points 24 + * 5. ArrowRouteType union completeness 25 + */ 26 + 27 + // --- Helpers --- 28 + 29 + /** Get the single arrow from a state that has exactly one. */ 30 + function getOnlyArrow(state: WhiteboardState): Arrow { 31 + const arrows = [...state.arrows.values()]; 32 + expect(arrows).toHaveLength(1); 33 + return arrows[0]!; 34 + } 35 + 36 + /** Create a whiteboard with snap disabled (deterministic positions). */ 37 + function emptyBoard(): WhiteboardState { 38 + const wb = createWhiteboard(20); 39 + return { ...wb, snapToGrid: false }; 40 + } 41 + 42 + /** Two free-point endpoints for quick arrow creation. */ 43 + const pointA: ArrowEndpoint = { x: 0, y: 0 }; 44 + const pointB: ArrowEndpoint = { x: 100, y: 100 }; 45 + 46 + // ===================================================================== 47 + // 1. addArrow WITH routeType 48 + // ===================================================================== 49 + 50 + describe('addArrow — routeType parameter', () => { 51 + it('stores orthogonal routeType on the arrow', () => { 52 + const wb = emptyBoard(); 53 + const next = addArrow(wb, pointA, pointB, '', 'orthogonal'); 54 + const arrow = getOnlyArrow(next); 55 + 56 + expect(arrow.routeType).toBe('orthogonal'); 57 + }); 58 + 59 + it('stores curved routeType on the arrow', () => { 60 + const wb = emptyBoard(); 61 + const next = addArrow(wb, pointA, pointB, '', 'curved'); 62 + const arrow = getOnlyArrow(next); 63 + 64 + expect(arrow.routeType).toBe('curved'); 65 + }); 66 + 67 + it('preserves label alongside routeType', () => { 68 + const wb = emptyBoard(); 69 + const next = addArrow(wb, pointA, pointB, 'my-label', 'orthogonal'); 70 + const arrow = getOnlyArrow(next); 71 + 72 + expect(arrow.label).toBe('my-label'); 73 + expect(arrow.routeType).toBe('orthogonal'); 74 + }); 75 + 76 + it('preserves endpoint data alongside routeType', () => { 77 + const from: ArrowEndpoint = { x: 10, y: 20 }; 78 + const to: ArrowEndpoint = { x: 300, y: 400 }; 79 + const wb = emptyBoard(); 80 + const next = addArrow(wb, from, to, '', 'curved'); 81 + const arrow = getOnlyArrow(next); 82 + 83 + expect(arrow.from).toEqual(from); 84 + expect(arrow.to).toEqual(to); 85 + }); 86 + }); 87 + 88 + // ===================================================================== 89 + // 2. addArrow WITHOUT routeType (backward compatibility) 90 + // ===================================================================== 91 + 92 + describe('addArrow — backward compatibility (no routeType)', () => { 93 + it('does not set routeType when parameter is omitted', () => { 94 + const wb = emptyBoard(); 95 + const next = addArrow(wb, pointA, pointB, ''); 96 + const arrow = getOnlyArrow(next); 97 + 98 + expect(arrow).not.toHaveProperty('routeType'); 99 + }); 100 + 101 + it('does not set routeType when parameter is undefined', () => { 102 + const wb = emptyBoard(); 103 + const next = addArrow(wb, pointA, pointB, '', undefined); 104 + const arrow = getOnlyArrow(next); 105 + 106 + expect(arrow).not.toHaveProperty('routeType'); 107 + }); 108 + }); 109 + 110 + // ===================================================================== 111 + // 3. addArrow WITH 'straight' (default — not stored) 112 + // ===================================================================== 113 + 114 + describe('addArrow — straight is treated as default', () => { 115 + it('does not store routeType when value is straight', () => { 116 + const wb = emptyBoard(); 117 + const next = addArrow(wb, pointA, pointB, '', 'straight'); 118 + const arrow = getOnlyArrow(next); 119 + 120 + // 'straight' is the implicit default; storing it would be redundant 121 + expect(arrow).not.toHaveProperty('routeType'); 122 + }); 123 + 124 + it('straight arrow is structurally identical to no-routeType arrow', () => { 125 + const wb = emptyBoard(); 126 + const withStraight = addArrow(wb, pointA, pointB, '', 'straight'); 127 + const withoutType = addArrow(wb, pointA, pointB, ''); 128 + 129 + const arrowA = getOnlyArrow(withStraight); 130 + const arrowB = getOnlyArrow(withoutType); 131 + 132 + // Same keys present (id will differ, but routeType presence should match) 133 + expect(Object.keys(arrowA).sort()).toEqual(Object.keys(arrowB).sort()); 134 + }); 135 + }); 136 + 137 + // ===================================================================== 138 + // 4. resolveEndpoint — shape anchors and free points 139 + // ===================================================================== 140 + 141 + describe('resolveEndpoint — shape anchors', () => { 142 + /** Create a board with one shape at a known position. */ 143 + function boardWithShape() { 144 + let wb = emptyBoard(); 145 + wb = addShape(wb, 'rectangle', 100, 200, 60, 40, 'box'); 146 + const shape = [...wb.shapes.values()][0]!; 147 + return { wb, shapeId: shape.id }; 148 + } 149 + 150 + it('resolves center anchor to shape center', () => { 151 + const { wb, shapeId } = boardWithShape(); 152 + const ep: ArrowEndpoint = { shapeId, anchor: 'center' }; 153 + const pt = resolveEndpoint(wb, ep); 154 + 155 + // shape at (100,200) with size 60x40 => center (130, 220) 156 + expect(pt).toEqual({ x: 130, y: 220 }); 157 + }); 158 + 159 + it('resolves top anchor to top-center of shape', () => { 160 + const { wb, shapeId } = boardWithShape(); 161 + const pt = resolveEndpoint(wb, { shapeId, anchor: 'top' }); 162 + 163 + expect(pt).toEqual({ x: 130, y: 200 }); 164 + }); 165 + 166 + it('resolves bottom anchor to bottom-center of shape', () => { 167 + const { wb, shapeId } = boardWithShape(); 168 + const pt = resolveEndpoint(wb, { shapeId, anchor: 'bottom' }); 169 + 170 + expect(pt).toEqual({ x: 130, y: 240 }); 171 + }); 172 + 173 + it('resolves left anchor to left-center of shape', () => { 174 + const { wb, shapeId } = boardWithShape(); 175 + const pt = resolveEndpoint(wb, { shapeId, anchor: 'left' }); 176 + 177 + expect(pt).toEqual({ x: 100, y: 220 }); 178 + }); 179 + 180 + it('resolves right anchor to right-center of shape', () => { 181 + const { wb, shapeId } = boardWithShape(); 182 + const pt = resolveEndpoint(wb, { shapeId, anchor: 'right' }); 183 + 184 + expect(pt).toEqual({ x: 160, y: 220 }); 185 + }); 186 + 187 + it('returns null for a nonexistent shape', () => { 188 + const wb = emptyBoard(); 189 + const ep: ArrowEndpoint = { shapeId: 'no-such-shape', anchor: 'top' }; 190 + const pt = resolveEndpoint(wb, ep); 191 + 192 + expect(pt).toBeNull(); 193 + }); 194 + 195 + it('resolves free-point endpoint as-is', () => { 196 + const wb = emptyBoard(); 197 + const ep: ArrowEndpoint = { x: 42, y: 99 }; 198 + const pt = resolveEndpoint(wb, ep); 199 + 200 + expect(pt).toEqual({ x: 42, y: 99 }); 201 + }); 202 + }); 203 + 204 + // ===================================================================== 205 + // 5. ArrowRouteType — type-level completeness check 206 + // ===================================================================== 207 + 208 + describe('ArrowRouteType — union completeness', () => { 209 + it('accepts all three valid route types', () => { 210 + // This is a compile-time check expressed as a runtime assertion. 211 + // If ArrowRouteType changes, this array literal will cause a TS error. 212 + const allTypes: ArrowRouteType[] = ['straight', 'orthogonal', 'curved']; 213 + 214 + expect(allTypes).toHaveLength(3); 215 + expect(allTypes).toContain('straight'); 216 + expect(allTypes).toContain('orthogonal'); 217 + expect(allTypes).toContain('curved'); 218 + }); 219 + 220 + it('Arrow.routeType is optional (undefined is valid)', () => { 221 + // Compile-time check: an Arrow with no routeType must be assignable 222 + const arrow: Arrow = { 223 + id: 'test', 224 + from: { x: 0, y: 0 }, 225 + to: { x: 1, y: 1 }, 226 + label: '', 227 + style: {}, 228 + }; 229 + expect(arrow.routeType).toBeUndefined(); 230 + }); 231 + });