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

Configure Feed

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

feat: wireframe components, ASCII export, frame shapes for diagrams (0.43.0)

Wireframe component library (#648): 16 UI components rendered as SVG
wireframes — browser window (traffic lights + URL bar), mobile frame
(notch + home indicator), modal (shadow + close X), card (image placeholder),
button, input field, select dropdown, checkbox, radio, toggle switch,
navbar, table, tabs, progress bar, avatar, alert.

Dropdown palette in toolbar with categorized sections (Frames, Form,
Layout, Display). Components placed at canvas center with appropriate
default sizes.

ASCII/Unicode text export (#649): converts whiteboard to text art using
box-drawing characters. Shapes become outlined boxes with centered labels.
Arrows render as horizontal/vertical/L-shaped lines with arrowheads.
Grid capped at 200x100 characters. Export via TXT toolbar button.

ShapeKind type extended with 16 wireframe variants. Rendering switch
updated. All shapes render in existing SVG pipeline.

Closes #648, #649, #650

+682 -2
+10
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.43.0] — 2026-04-15 11 + 12 + ### Added 13 + - Diagrams: wireframe component library with 16 UI components — browser window, mobile frame, modal, card, button, input field, select, checkbox, radio, toggle, navbar, table, tabs, progress bar, avatar, alert (#648) 14 + - Diagrams: wireframe component dropdown palette in toolbar with categorized menu (Frames, Form, Layout, Display) 15 + - Diagrams: ASCII/Unicode text export using box-drawing characters (┌─┐│└─┘) with arrow rendering and label centering (#649) 16 + - Diagrams: wireframe frame shapes (browser window with traffic lights + URL bar, mobile frame with notch + home indicator) (#650) 17 + 10 18 ## [0.42.3] — 2026-04-14 11 19 12 20 ### Fixed 21 + - fix: Electron title bar hiding under macOS traffic light icons (#647) 13 22 - fix: markdown toggle destroys custom blocks (mermaid, math, page breaks, footnotes, suggestions) on roundtrip (#646) 14 23 - Custom TipTap nodes are now extracted before HTML→Markdown conversion and restored afterward 15 24 - Preserved blocks appear as HTML comments in markdown source so users can see them ··· 26 35 - Vite: explicit `sourcemap: false` in production build config (#645) 27 36 28 37 ### Fixed 38 + - fix: bug switching from markdown mode to normal mode in docs (#646) 29 39 - CI: unit tests + typecheck now run before Docker build in pipeline (#641) 30 40 - A11y: passphrase dialog — `role="dialog"`, `aria-modal`, `aria-labelledby`, focus trap, Escape handling (#643) 31 41 - A11y: share dialog — `role="dialog"`, `aria-modal`, `aria-label`, focus trap, focus restoration on Escape (#643)
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.42.3", 3 + "version": "0.43.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+39
src/css/app.css
··· 8762 8762 } 8763 8763 } 8764 8764 8765 + /* ── Wireframe Component Palette ──────────────────────────────── */ 8766 + 8767 + .wireframe-dropdown { position: relative; } 8768 + .wireframe-menu { 8769 + position: absolute; 8770 + top: 100%; 8771 + left: 0; 8772 + z-index: 50; 8773 + background: var(--color-surface); 8774 + border: 1px solid var(--color-border); 8775 + border-radius: var(--radius-sm, 4px); 8776 + box-shadow: 0 4px 12px rgba(0,0,0,0.1); 8777 + padding: 4px; 8778 + min-width: 140px; 8779 + max-height: 320px; 8780 + overflow-y: auto; 8781 + } 8782 + .wireframe-category { 8783 + font-size: 0.68rem; 8784 + font-weight: 600; 8785 + text-transform: uppercase; 8786 + letter-spacing: 0.05em; 8787 + color: var(--color-text-faint); 8788 + padding: 6px 8px 2px; 8789 + } 8790 + .wireframe-item { 8791 + display: block; 8792 + width: 100%; 8793 + text-align: left; 8794 + border: none; 8795 + background: transparent; 8796 + color: var(--color-text); 8797 + font-size: 0.78rem; 8798 + padding: 4px 8px; 8799 + border-radius: 3px; 8800 + cursor: pointer; 8801 + } 8802 + .wireframe-item:hover { background: var(--color-surface-alt); } 8803 + 8765 8804 /* ── Electron traffic-light padding (landing page, supplements rules at line ~1850) ── */ 8766 8805 .is-electron .landing-header .brand { 8767 8806 padding-left: 88px;
+197
src/diagrams/ascii-export.ts
··· 1 + /** 2 + * ASCII/Unicode Text Export — renders a whiteboard as text art. 3 + * 4 + * Converts shapes and arrows to Unicode box-drawing characters on a 5 + * character grid. Each shape gets a box outline with its label centered. 6 + * Arrows become ASCII lines with arrowhead characters. 7 + * 8 + * Pure logic module — no DOM. Returns a plain string. 9 + */ 10 + 11 + import type { WhiteboardState, Shape, Arrow, ArrowEndpoint } from './whiteboard-types.js'; 12 + 13 + /** Character width/height in "pixels" for coordinate mapping */ 14 + const CHAR_W = 8; 15 + const CHAR_H = 16; 16 + 17 + /** Box-drawing characters */ 18 + const BOX = { 19 + tl: '┌', tr: '┐', bl: '└', br: '┘', 20 + h: '─', v: '│', 21 + // Arrow characters 22 + right: '→', left: '←', up: '↑', down: '↓', 23 + cross: '┼', 24 + }; 25 + 26 + interface Grid { 27 + width: number; 28 + height: number; 29 + cells: string[][]; 30 + } 31 + 32 + function createGrid(width: number, height: number): Grid { 33 + const cells: string[][] = []; 34 + for (let y = 0; y < height; y++) { 35 + cells.push(new Array(width).fill(' ')); 36 + } 37 + return { width, height, cells }; 38 + } 39 + 40 + function setCell(grid: Grid, x: number, y: number, ch: string): void { 41 + if (x >= 0 && x < grid.width && y >= 0 && y < grid.height) { 42 + grid.cells[y]![x] = ch; 43 + } 44 + } 45 + 46 + function getCell(grid: Grid, x: number, y: number): string { 47 + if (x >= 0 && x < grid.width && y >= 0 && y < grid.height) { 48 + return grid.cells[y]![x]!; 49 + } 50 + return ' '; 51 + } 52 + 53 + /** Draw a box outline on the grid */ 54 + function drawBox(grid: Grid, x: number, y: number, w: number, h: number): void { 55 + if (w < 2 || h < 2) return; 56 + setCell(grid, x, y, BOX.tl); 57 + setCell(grid, x + w - 1, y, BOX.tr); 58 + setCell(grid, x, y + h - 1, BOX.bl); 59 + setCell(grid, x + w - 1, y + h - 1, BOX.br); 60 + for (let i = 1; i < w - 1; i++) { 61 + setCell(grid, x + i, y, BOX.h); 62 + setCell(grid, x + i, y + h - 1, BOX.h); 63 + } 64 + for (let i = 1; i < h - 1; i++) { 65 + setCell(grid, x, y + i, BOX.v); 66 + setCell(grid, x + w - 1, y + i, BOX.v); 67 + } 68 + } 69 + 70 + /** Write text centered in a box */ 71 + function drawLabel(grid: Grid, x: number, y: number, w: number, h: number, label: string): void { 72 + if (!label || w < 4 || h < 3) return; 73 + const maxLen = w - 2; 74 + const text = label.length > maxLen ? label.slice(0, maxLen - 1) + '…' : label; 75 + const startX = x + 1 + Math.floor((maxLen - text.length) / 2); 76 + const startY = y + Math.floor(h / 2); 77 + for (let i = 0; i < text.length; i++) { 78 + setCell(grid, startX + i, startY, text[i]!); 79 + } 80 + } 81 + 82 + /** Draw a horizontal or vertical line with an arrowhead */ 83 + function drawLine(grid: Grid, x1: number, y1: number, x2: number, y2: number): void { 84 + if (y1 === y2) { 85 + // Horizontal line 86 + const [minX, maxX] = x1 < x2 ? [x1, x2] : [x2, x1]; 87 + for (let x = minX; x <= maxX; x++) { 88 + setCell(grid, x, y1, BOX.h); 89 + } 90 + setCell(grid, x2, y2, x2 > x1 ? BOX.right : BOX.left); 91 + } else if (x1 === x2) { 92 + // Vertical line 93 + const [minY, maxY] = y1 < y2 ? [y1, y2] : [y2, y1]; 94 + for (let y = minY; y <= maxY; y++) { 95 + setCell(grid, x1, y, BOX.v); 96 + } 97 + setCell(grid, x2, y2, y2 > y1 ? BOX.down : BOX.up); 98 + } else { 99 + // L-shaped: go horizontal first, then vertical 100 + for (let x = Math.min(x1, x2); x <= Math.max(x1, x2); x++) { 101 + setCell(grid, x, y1, BOX.h); 102 + } 103 + for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) { 104 + setCell(grid, x2, y, BOX.v); 105 + } 106 + setCell(grid, x2, y1, BOX.cross); 107 + setCell(grid, x2, y2, y2 > y1 ? BOX.down : BOX.up); 108 + } 109 + } 110 + 111 + /** Convert pixel coordinates to grid coordinates */ 112 + function toGrid(px: number, py: number): [number, number] { 113 + return [Math.round(px / CHAR_W), Math.round(py / CHAR_H)]; 114 + } 115 + 116 + /** Resolve an arrow endpoint to pixel coordinates */ 117 + function resolveEndpoint(ep: ArrowEndpoint, shapes: Map<string, Shape>): [number, number] { 118 + if ('x' in ep) return [ep.x, ep.y]; 119 + const shape = shapes.get(ep.shapeId); 120 + if (!shape) return [0, 0]; 121 + switch (ep.anchor) { 122 + case 'top': return [shape.x + shape.width / 2, shape.y]; 123 + case 'bottom': return [shape.x + shape.width / 2, shape.y + shape.height]; 124 + case 'left': return [shape.x, shape.y + shape.height / 2]; 125 + case 'right': return [shape.x + shape.width, shape.y + shape.height / 2]; 126 + case 'center': return [shape.x + shape.width / 2, shape.y + shape.height / 2]; 127 + default: return [shape.x + shape.width / 2, shape.y + shape.height / 2]; 128 + } 129 + } 130 + 131 + /** 132 + * Export a whiteboard to ASCII/Unicode text art. 133 + * Returns a multi-line string using box-drawing characters. 134 + */ 135 + export function exportToAscii(state: WhiteboardState): string { 136 + if (state.shapes.size === 0) return ''; 137 + 138 + // Find bounding box 139 + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; 140 + for (const shape of state.shapes.values()) { 141 + minX = Math.min(minX, shape.x); 142 + minY = Math.min(minY, shape.y); 143 + maxX = Math.max(maxX, shape.x + shape.width); 144 + maxY = Math.max(maxY, shape.y + shape.height); 145 + } 146 + 147 + // Add padding 148 + minX -= CHAR_W; 149 + minY -= CHAR_H; 150 + maxX += CHAR_W; 151 + maxY += CHAR_H; 152 + 153 + const gridW = Math.min(200, Math.ceil((maxX - minX) / CHAR_W) + 2); 154 + const gridH = Math.min(100, Math.ceil((maxY - minY) / CHAR_H) + 2); 155 + const grid = createGrid(gridW, gridH); 156 + 157 + // Draw shapes as boxes 158 + for (const shape of state.shapes.values()) { 159 + const [gx, gy] = toGrid(shape.x - minX, shape.y - minY); 160 + const [gw] = toGrid(shape.width, 0); 161 + const [, gh] = toGrid(0, shape.height); 162 + const w = Math.max(4, gw); 163 + const h = Math.max(3, gh); 164 + drawBox(grid, gx, gy, w, h); 165 + drawLabel(grid, gx, gy, w, h, shape.label); 166 + } 167 + 168 + // Draw arrows as lines 169 + for (const arrow of state.arrows.values()) { 170 + const [px1, py1] = resolveEndpoint(arrow.from, state.shapes); 171 + const [px2, py2] = resolveEndpoint(arrow.to, state.shapes); 172 + const [gx1, gy1] = toGrid(px1 - minX, py1 - minY); 173 + const [gx2, gy2] = toGrid(px2 - minX, py2 - minY); 174 + drawLine(grid, gx1, gy1, gx2, gy2); 175 + } 176 + 177 + // Convert grid to string, trimming trailing whitespace per line 178 + return grid.cells.map(row => row.join('').trimEnd()).join('\n').trimEnd() + '\n'; 179 + } 180 + 181 + /** 182 + * Get the grid dimensions that would be used for export. 183 + */ 184 + export function getExportDimensions(state: WhiteboardState): { cols: number; rows: number } { 185 + if (state.shapes.size === 0) return { cols: 0, rows: 0 }; 186 + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; 187 + for (const shape of state.shapes.values()) { 188 + minX = Math.min(minX, shape.x); 189 + minY = Math.min(minY, shape.y); 190 + maxX = Math.max(maxX, shape.x + shape.width); 191 + maxY = Math.max(maxY, shape.y + shape.height); 192 + } 193 + return { 194 + cols: Math.min(200, Math.ceil((maxX - minX) / CHAR_W) + 4), 195 + rows: Math.min(100, Math.ceil((maxY - minY) / CHAR_H) + 4), 196 + }; 197 + }
+29
src/diagrams/index.html
··· 43 43 <input type="file" id="svg-import-input" accept=".svg,image/svg+xml" style="display:none"> 44 44 <button class="btn-icon btn-sm" id="btn-export-svg" title="Export SVG">Export SVG</button> 45 45 <button class="btn-icon btn-sm" id="btn-export-png" title="Export PNG">PNG</button> 46 + <button class="btn-icon btn-sm" id="btn-export-ascii" title="Export as Unicode text">TXT</button> 46 47 <button class="btn-icon btn-sm" id="btn-toggle-layers" title="Layers Panel">Layers</button> 47 48 <button class="btn-icon" id="btn-ai-chat" title="AI Chat (Cmd+Shift+L)"> 48 49 <svg class="tb-icon" viewBox="0 0 16 16" style="width:16px;height:16px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h12a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H5l-3 3V4a1 1 0 0 1 1-1z"/><circle cx="5.5" cy="7.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="8" cy="7.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="10.5" cy="7.5" r="0.5" fill="currentColor" stroke="none"/></svg> ··· 112 113 <button class="btn-icon btn-sm" data-align="bottom" title="Align bottom">&#8675;</button> 113 114 <button class="btn-icon btn-sm" data-distribute="horizontal" title="Distribute horizontally">&#8214;</button> 114 115 <button class="btn-icon btn-sm" data-distribute="vertical" title="Distribute vertically">&#8801;</button> 116 + <span class="toolbar-divider"></span> 117 + 118 + <!-- Wireframe components dropdown --> 119 + <div class="wireframe-dropdown" id="wireframe-dropdown"> 120 + <button class="btn-icon btn-sm" id="btn-wireframe" title="Wireframe Components">&#9638; UI</button> 121 + <div class="wireframe-menu" id="wireframe-menu" style="display:none"> 122 + <div class="wireframe-category">Frames</div> 123 + <button class="wireframe-item" data-wf="browserWindow">&#9635; Browser</button> 124 + <button class="wireframe-item" data-wf="mobileFrame">&#9647; Mobile</button> 125 + <button class="wireframe-item" data-wf="modal">&#9634; Modal</button> 126 + <button class="wireframe-item" data-wf="card">&#9636; Card</button> 127 + <div class="wireframe-category">Form</div> 128 + <button class="wireframe-item" data-wf="button">&#9646; Button</button> 129 + <button class="wireframe-item" data-wf="inputField">&#9644; Input</button> 130 + <button class="wireframe-item" data-wf="select">&#9662; Select</button> 131 + <button class="wireframe-item" data-wf="checkbox">&#9745; Checkbox</button> 132 + <button class="wireframe-item" data-wf="radio">&#9899; Radio</button> 133 + <button class="wireframe-item" data-wf="toggle">&#9673; Toggle</button> 134 + <div class="wireframe-category">Layout</div> 135 + <button class="wireframe-item" data-wf="navbar">&#9776; Navbar</button> 136 + <button class="wireframe-item" data-wf="table">&#9638; Table</button> 137 + <button class="wireframe-item" data-wf="tabs">&#9783; Tabs</button> 138 + <div class="wireframe-category">Display</div> 139 + <button class="wireframe-item" data-wf="progressBar">&#9608; Progress</button> 140 + <button class="wireframe-item" data-wf="avatar">&#9679; Avatar</button> 141 + <button class="wireframe-item" data-wf="alert">&#9888; Alert</button> 142 + </div> 143 + </div> 115 144 <span class="toolbar-divider"></span> 116 145 117 146 <!-- Actions -->
+68
src/diagrams/main.ts
··· 492 492 } 493 493 }); 494 494 495 + // --- ASCII Export --- 496 + $('btn-export-ascii').addEventListener('click', async () => { 497 + const { exportToAscii } = await import('./ascii-export.js'); 498 + const text = exportToAscii(wb); 499 + if (!text) return; 500 + const blob = new Blob([text], { type: 'text/plain' }); 501 + const url = URL.createObjectURL(blob); 502 + const a = document.createElement('a'); 503 + a.href = url; 504 + a.download = (diagramTitle.value || 'diagram').replace(/[^a-zA-Z0-9_-]/g, '_') + '.txt'; 505 + a.click(); 506 + URL.revokeObjectURL(url); 507 + }); 508 + 509 + // --- Wireframe Component Palette --- 510 + const wfBtn = $('btn-wireframe'); 511 + const wfMenu = $('wireframe-menu'); 512 + 513 + wfBtn.addEventListener('click', (e) => { 514 + e.stopPropagation(); 515 + wfMenu.style.display = wfMenu.style.display === 'none' ? '' : 'none'; 516 + }); 517 + 518 + document.addEventListener('click', (e) => { 519 + if (!(e.target as HTMLElement).closest('#wireframe-dropdown')) { 520 + wfMenu.style.display = 'none'; 521 + } 522 + }); 523 + 524 + // Default sizes for wireframe components 525 + const WF_SIZES: Record<string, [number, number]> = { 526 + browserWindow: [400, 300], 527 + mobileFrame: [180, 360], 528 + modal: [320, 220], 529 + card: [200, 260], 530 + button: [100, 36], 531 + inputField: [200, 36], 532 + select: [180, 36], 533 + checkbox: [120, 20], 534 + radio: [120, 20], 535 + toggle: [44, 24], 536 + navbar: [400, 48], 537 + table: [300, 160], 538 + tabs: [300, 36], 539 + progressBar: [200, 16], 540 + avatar: [48, 48], 541 + alert: [280, 48], 542 + }; 543 + 544 + wfMenu.addEventListener('click', (e) => { 545 + const item = (e.target as HTMLElement).closest('.wireframe-item') as HTMLElement | null; 546 + if (!item) return; 547 + const kind = item.dataset.wf; 548 + if (!kind) return; 549 + 550 + const [w, h] = WF_SIZES[kind] || [120, 80]; 551 + // Place near center of viewport 552 + const canvasRect = canvas.getBoundingClientRect(); 553 + const cx = (canvasRect.width / 2 - wb.panX) / wb.zoom; 554 + const cy = (canvasRect.height / 2 - wb.panY) / wb.zoom; 555 + 556 + pushHistory(); 557 + wb = addShape(wb, kind as any, cx - w / 2, cy - h / 2, w, h); 558 + syncToYjs(); 559 + render(); 560 + wfMenu.style.display = 'none'; 561 + }); 562 + 495 563 init();
+23
src/diagrams/rendering.ts
··· 16 16 appendParallelogram, appendNote, 17 17 } from './shape-renderers.js'; 18 18 import { 19 + appendBrowserWindow, appendMobileFrame, appendModal, appendCard, 20 + appendButton, appendInputField, appendSelect, appendCheckbox, 21 + appendRadio, appendToggle, appendNavbar, appendTable, 22 + appendProgressBar, appendAvatar, appendAlert, appendTabs, 23 + } from './wireframe-renderers.js'; 24 + import { 19 25 getArrowHoverTargetId, getIsMarqueeSelecting, getMarqueeStart, getMarqueeEnd, 20 26 } from './canvas-events.js'; 21 27 ··· 130 136 case 'note': 131 137 appendNote(g, shape.width, shape.height, fill, stroke, strokeWidth, strokeDasharray); 132 138 break; 139 + // Wireframe components 140 + case 'browserWindow': appendBrowserWindow(g, shape.width, shape.height, fill, stroke, strokeWidth); break; 141 + case 'mobileFrame': appendMobileFrame(g, shape.width, shape.height, fill, stroke, strokeWidth); break; 142 + case 'modal': appendModal(g, shape.width, shape.height, fill, stroke, strokeWidth); break; 143 + case 'card': appendCard(g, shape.width, shape.height, fill, stroke, strokeWidth); break; 144 + case 'button': appendButton(g, shape.width, shape.height, fill, stroke, strokeWidth); break; 145 + case 'inputField': appendInputField(g, shape.width, shape.height, fill, stroke, strokeWidth); break; 146 + case 'select': appendSelect(g, shape.width, shape.height, fill, stroke, strokeWidth); break; 147 + case 'checkbox': appendCheckbox(g, shape.width, shape.height, fill, stroke, strokeWidth); break; 148 + case 'radio': appendRadio(g, shape.width, shape.height, fill, stroke, strokeWidth); break; 149 + case 'toggle': appendToggle(g, shape.width, shape.height, fill, stroke, strokeWidth); break; 150 + case 'navbar': appendNavbar(g, shape.width, shape.height, fill, stroke, strokeWidth); break; 151 + case 'table': appendTable(g, shape.width, shape.height, fill, stroke, strokeWidth); break; 152 + case 'progressBar': appendProgressBar(g, shape.width, shape.height, fill, stroke, strokeWidth); break; 153 + case 'avatar': appendAvatar(g, shape.width, shape.height, fill, stroke, strokeWidth); break; 154 + case 'alert': appendAlert(g, shape.width, shape.height, fill, stroke, strokeWidth); break; 155 + case 'tabs': appendTabs(g, shape.width, shape.height, fill, stroke, strokeWidth); break; 133 156 case 'text': 134 157 appendRect(g, shape.width, shape.height, 'transparent', 'transparent', '0', ''); 135 158 break;
+7 -1
src/diagrams/whiteboard-types.ts
··· 2 2 * Whiteboard type definitions — shape model, arrow endpoints, board state. 3 3 */ 4 4 5 - export type ShapeKind = 'rectangle' | 'ellipse' | 'diamond' | 'text' | 'freehand' | 'line' | 'triangle' | 'star' | 'hexagon' | 'cloud' | 'cylinder' | 'parallelogram' | 'note'; 5 + export type ShapeKind = 6 + | 'rectangle' | 'ellipse' | 'diamond' | 'text' | 'freehand' | 'line' 7 + | 'triangle' | 'star' | 'hexagon' | 'cloud' | 'cylinder' | 'parallelogram' | 'note' 8 + // Wireframe components 9 + | 'browserWindow' | 'mobileFrame' | 'modal' | 'card' 10 + | 'button' | 'inputField' | 'select' | 'checkbox' | 'radio' | 'toggle' 11 + | 'navbar' | 'table' | 'progressBar' | 'avatar' | 'alert' | 'tabs'; 6 12 export type ArrowEndpoint = { shapeId: string; anchor: 'top' | 'bottom' | 'left' | 'right' | 'center' } | { x: number; y: number }; 7 13 8 14 export interface Point {
+213
src/diagrams/wireframe-renderers.ts
··· 1 + /** 2 + * Wireframe Component Renderers — SVG elements for UI wireframe components. 3 + * 4 + * Each renderer draws a wireframe-style UI component (low-fidelity, sketch-like) 5 + * into an SVG <g> element. Used by the diagrams rendering pipeline. 6 + * 7 + * Components: browser window, mobile frame, modal, card, button, input field, 8 + * select dropdown, checkbox, radio button, toggle, navbar, table, progress bar, 9 + * avatar, alert, tabs. 10 + */ 11 + 12 + const NS = 'http://www.w3.org/2000/svg'; 13 + 14 + function el(tag: string, attrs: Record<string, string>): SVGElement { 15 + const e = document.createElementNS(NS, tag); 16 + for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v); 17 + return e; 18 + } 19 + 20 + function text(g: SVGGElement, x: number, y: number, content: string, size = 12, color = '#666') { 21 + const t = el('text', { x: String(x), y: String(y), fill: color, 'font-size': String(size), 'font-family': 'system-ui, sans-serif' }); 22 + t.textContent = content; 23 + g.appendChild(t); 24 + } 25 + 26 + // --- Browser Window --- 27 + export function appendBrowserWindow(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2') { 28 + // Outer frame 29 + g.appendChild(el('rect', { width: String(w), height: String(h), rx: '6', fill, stroke, 'stroke-width': strokeWidth })); 30 + // Title bar 31 + g.appendChild(el('rect', { x: '0', y: '0', width: String(w), height: '28', rx: '6', fill: '#e8e8e8', stroke, 'stroke-width': '1' })); 32 + // Bottom edge of title bar (square corners) 33 + g.appendChild(el('rect', { x: '0', y: '14', width: String(w), height: '14', fill: '#e8e8e8', stroke: 'none' })); 34 + g.appendChild(el('line', { x1: '0', y1: '28', x2: String(w), y2: '28', stroke, 'stroke-width': '1' })); 35 + // Traffic lights 36 + g.appendChild(el('circle', { cx: '14', cy: '14', r: '5', fill: '#ff5f57', stroke: 'none' })); 37 + g.appendChild(el('circle', { cx: '30', cy: '14', r: '5', fill: '#febc2e', stroke: 'none' })); 38 + g.appendChild(el('circle', { cx: '46', cy: '14', r: '5', fill: '#28c840', stroke: 'none' })); 39 + // URL bar 40 + g.appendChild(el('rect', { x: String(w * 0.2), y: '7', width: String(w * 0.6), height: '14', rx: '3', fill: '#fff', stroke: '#ccc', 'stroke-width': '1' })); 41 + } 42 + 43 + // --- Mobile Frame --- 44 + export function appendMobileFrame(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2') { 45 + g.appendChild(el('rect', { width: String(w), height: String(h), rx: '16', fill, stroke, 'stroke-width': strokeWidth })); 46 + // Status bar area 47 + g.appendChild(el('rect', { x: '0', y: '0', width: String(w), height: '24', rx: '16', fill: '#f0f0f0', stroke: 'none' })); 48 + g.appendChild(el('rect', { x: '0', y: '12', width: String(w), height: '12', fill: '#f0f0f0', stroke: 'none' })); 49 + // Notch 50 + const notchW = w * 0.3; 51 + g.appendChild(el('rect', { x: String((w - notchW) / 2), y: '0', width: String(notchW), height: '12', rx: '6', fill: '#333', stroke: 'none' })); 52 + // Home indicator 53 + g.appendChild(el('rect', { x: String((w - 40) / 2), y: String(h - 8), width: '40', height: '4', rx: '2', fill: '#ccc', stroke: 'none' })); 54 + } 55 + 56 + // --- Modal --- 57 + export function appendModal(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2') { 58 + // Shadow 59 + g.appendChild(el('rect', { x: '4', y: '4', width: String(w), height: String(h), rx: '8', fill: 'rgba(0,0,0,0.1)', stroke: 'none' })); 60 + // Modal body 61 + g.appendChild(el('rect', { width: String(w), height: String(h), rx: '8', fill, stroke, 'stroke-width': strokeWidth })); 62 + // Header bar 63 + g.appendChild(el('line', { x1: '0', y1: '36', x2: String(w), y2: '36', stroke, 'stroke-width': '1' })); 64 + // Close X 65 + const cx = w - 20; 66 + g.appendChild(el('line', { x1: String(cx - 5), y1: '13', x2: String(cx + 5), y2: '23', stroke: '#999', 'stroke-width': '2' })); 67 + g.appendChild(el('line', { x1: String(cx + 5), y1: '13', x2: String(cx - 5), y2: '23', stroke: '#999', 'stroke-width': '2' })); 68 + } 69 + 70 + // --- Card --- 71 + export function appendCard(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2') { 72 + // Shadow 73 + g.appendChild(el('rect', { x: '2', y: '2', width: String(w), height: String(h), rx: '6', fill: 'rgba(0,0,0,0.06)', stroke: 'none' })); 74 + // Card body 75 + g.appendChild(el('rect', { width: String(w), height: String(h), rx: '6', fill, stroke, 'stroke-width': strokeWidth })); 76 + // Image placeholder area (top 40%) 77 + const imgH = h * 0.4; 78 + g.appendChild(el('rect', { x: '0', y: '0', width: String(w), height: String(imgH), rx: '6', fill: '#f0f0f0', stroke: 'none' })); 79 + g.appendChild(el('rect', { x: '0', y: String(imgH / 2), width: String(w), height: String(imgH / 2), fill: '#f0f0f0', stroke: 'none' })); 80 + // Image icon 81 + const iconCx = w / 2; 82 + const iconCy = imgH / 2; 83 + g.appendChild(el('circle', { cx: String(iconCx), cy: String(iconCy - 4), r: '8', fill: '#ccc', stroke: 'none' })); 84 + g.appendChild(el('polygon', { points: `${iconCx - 14},${iconCy + 10} ${iconCx},${iconCy - 2} ${iconCx + 14},${iconCy + 10}`, fill: '#ccc', stroke: 'none' })); 85 + } 86 + 87 + // --- Button --- 88 + export function appendButton(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2') { 89 + g.appendChild(el('rect', { width: String(w), height: String(h), rx: '4', fill: fill === '#ffffff' ? '#4472c4' : fill, stroke, 'stroke-width': strokeWidth })); 90 + text(g, w / 2 - 20, h / 2 + 4, 'Button', 12, fill === '#ffffff' ? '#fff' : '#fff'); 91 + } 92 + 93 + // --- Input Field --- 94 + export function appendInputField(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2') { 95 + g.appendChild(el('rect', { width: String(w), height: String(h), rx: '3', fill, stroke: stroke || '#ccc', 'stroke-width': strokeWidth })); 96 + text(g, 8, h / 2 + 4, 'Placeholder...', 11, '#aaa'); 97 + } 98 + 99 + // --- Select / Dropdown --- 100 + export function appendSelect(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2') { 101 + g.appendChild(el('rect', { width: String(w), height: String(h), rx: '3', fill, stroke: stroke || '#ccc', 'stroke-width': strokeWidth })); 102 + text(g, 8, h / 2 + 4, 'Select...', 11, '#aaa'); 103 + // Chevron 104 + const cx = w - 16; 105 + const cy = h / 2; 106 + g.appendChild(el('polyline', { points: `${cx - 4},${cy - 3} ${cx},${cy + 3} ${cx + 4},${cy - 3}`, fill: 'none', stroke: '#999', 'stroke-width': '1.5' })); 107 + } 108 + 109 + // --- Checkbox --- 110 + export function appendCheckbox(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2') { 111 + const boxSize = Math.min(h, 16); 112 + const y = (h - boxSize) / 2; 113 + g.appendChild(el('rect', { x: '0', y: String(y), width: String(boxSize), height: String(boxSize), rx: '2', fill, stroke, 'stroke-width': strokeWidth })); 114 + // Checkmark 115 + g.appendChild(el('polyline', { points: `3,${y + boxSize / 2} ${boxSize * 0.4},${y + boxSize * 0.7} ${boxSize - 3},${y + 3}`, fill: 'none', stroke: '#4472c4', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' })); 116 + text(g, boxSize + 8, h / 2 + 4, 'Option', 11, '#333'); 117 + } 118 + 119 + // --- Radio Button --- 120 + export function appendRadio(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2') { 121 + const r = Math.min(h, 16) / 2; 122 + const cy = h / 2; 123 + g.appendChild(el('circle', { cx: String(r), cy: String(cy), r: String(r), fill, stroke, 'stroke-width': strokeWidth })); 124 + g.appendChild(el('circle', { cx: String(r), cy: String(cy), r: String(r * 0.5), fill: '#4472c4', stroke: 'none' })); 125 + text(g, r * 2 + 8, cy + 4, 'Option', 11, '#333'); 126 + } 127 + 128 + // --- Toggle Switch --- 129 + export function appendToggle(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2') { 130 + const trackW = Math.min(w, 40); 131 + const trackH = Math.min(h, 20); 132 + const ty = (h - trackH) / 2; 133 + g.appendChild(el('rect', { x: '0', y: String(ty), width: String(trackW), height: String(trackH), rx: String(trackH / 2), fill: '#4caf50', stroke, 'stroke-width': '1' })); 134 + g.appendChild(el('circle', { cx: String(trackW - trackH / 2), cy: String(ty + trackH / 2), r: String(trackH / 2 - 2), fill: '#fff', stroke: '#ddd', 'stroke-width': '1' })); 135 + } 136 + 137 + // --- Navbar --- 138 + export function appendNavbar(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2') { 139 + g.appendChild(el('rect', { width: String(w), height: String(h), rx: '0', fill: fill === '#ffffff' ? '#2c3e50' : fill, stroke, 'stroke-width': strokeWidth })); 140 + // Logo placeholder 141 + g.appendChild(el('rect', { x: '12', y: String((h - 14) / 2), width: '14', height: '14', rx: '2', fill: 'rgba(255,255,255,0.3)', stroke: 'none' })); 142 + text(g, 32, h / 2 + 4, 'Brand', 12, 'rgba(255,255,255,0.9)'); 143 + // Nav links 144 + const linkY = h / 2 + 4; 145 + text(g, w * 0.4, linkY, 'Home', 11, 'rgba(255,255,255,0.7)'); 146 + text(g, w * 0.55, linkY, 'About', 11, 'rgba(255,255,255,0.7)'); 147 + text(g, w * 0.72, linkY, 'Contact', 11, 'rgba(255,255,255,0.7)'); 148 + } 149 + 150 + // --- Table --- 151 + export function appendTable(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2') { 152 + g.appendChild(el('rect', { width: String(w), height: String(h), rx: '2', fill, stroke, 'stroke-width': strokeWidth })); 153 + // Header row 154 + const rowH = Math.min(h / 4, 28); 155 + g.appendChild(el('rect', { x: '0', y: '0', width: String(w), height: String(rowH), rx: '2', fill: '#f5f5f5', stroke: 'none' })); 156 + g.appendChild(el('rect', { x: '0', y: String(rowH / 2), width: String(w), height: String(rowH / 2), fill: '#f5f5f5', stroke: 'none' })); 157 + // Column dividers 158 + const cols = 3; 159 + for (let i = 1; i < cols; i++) { 160 + const x = (w / cols) * i; 161 + g.appendChild(el('line', { x1: String(x), y1: '0', x2: String(x), y2: String(h), stroke: '#ddd', 'stroke-width': '1' })); 162 + } 163 + // Row dividers 164 + const rows = Math.min(Math.floor(h / rowH), 5); 165 + for (let i = 1; i < rows; i++) { 166 + const y = rowH * i; 167 + g.appendChild(el('line', { x1: '0', y1: String(y), x2: String(w), y2: String(y), stroke: '#ddd', 'stroke-width': '1' })); 168 + } 169 + } 170 + 171 + // --- Progress Bar --- 172 + export function appendProgressBar(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2') { 173 + const barH = Math.min(h, 12); 174 + const y = (h - barH) / 2; 175 + g.appendChild(el('rect', { x: '0', y: String(y), width: String(w), height: String(barH), rx: String(barH / 2), fill: '#e0e0e0', stroke, 'stroke-width': '1' })); 176 + g.appendChild(el('rect', { x: '0', y: String(y), width: String(w * 0.65), height: String(barH), rx: String(barH / 2), fill: fill === '#ffffff' ? '#4472c4' : fill, stroke: 'none' })); 177 + } 178 + 179 + // --- Avatar --- 180 + export function appendAvatar(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2') { 181 + const r = Math.min(w, h) / 2; 182 + const cx = w / 2; 183 + const cy = h / 2; 184 + g.appendChild(el('circle', { cx: String(cx), cy: String(cy), r: String(r), fill: fill === '#ffffff' ? '#e0e0e0' : fill, stroke, 'stroke-width': strokeWidth })); 185 + // Head 186 + g.appendChild(el('circle', { cx: String(cx), cy: String(cy - r * 0.15), r: String(r * 0.3), fill: '#bbb', stroke: 'none' })); 187 + // Body 188 + g.appendChild(el('ellipse', { cx: String(cx), cy: String(cy + r * 0.55), rx: String(r * 0.45), ry: String(r * 0.3), fill: '#bbb', stroke: 'none' })); 189 + } 190 + 191 + // --- Alert --- 192 + export function appendAlert(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2') { 193 + g.appendChild(el('rect', { width: String(w), height: String(h), rx: '4', fill: fill === '#ffffff' ? '#fff3cd' : fill, stroke: stroke || '#ffc107', 'stroke-width': strokeWidth })); 194 + // Icon 195 + text(g, 10, h / 2 + 5, '⚠', 14, '#856404'); 196 + // Alert text placeholder 197 + g.appendChild(el('rect', { x: '30', y: String(h / 2 - 4), width: String(w * 0.6), height: '8', rx: '2', fill: '#c9b87a', stroke: 'none' })); 198 + } 199 + 200 + // --- Tabs --- 201 + export function appendTabs(g: SVGGElement, w: number, h: number, fill: string, stroke: string, strokeWidth = '2') { 202 + g.appendChild(el('rect', { width: String(w), height: String(h), rx: '0', fill, stroke, 'stroke-width': strokeWidth })); 203 + const tabW = w / 3; 204 + const tabH = Math.min(h, 32); 205 + // Active tab 206 + g.appendChild(el('rect', { x: '0', y: '0', width: String(tabW), height: String(tabH), fill: '#fff', stroke, 'stroke-width': '1' })); 207 + g.appendChild(el('line', { x1: '0', y1: String(tabH), x2: String(tabW), y2: String(tabH), stroke: '#4472c4', 'stroke-width': '2' })); 208 + text(g, tabW * 0.2, tabH / 2 + 4, 'Tab 1', 11, '#333'); 209 + // Inactive tabs 210 + text(g, tabW + tabW * 0.2, tabH / 2 + 4, 'Tab 2', 11, '#999'); 211 + text(g, tabW * 2 + tabW * 0.2, tabH / 2 + 4, 'Tab 3', 11, '#999'); 212 + g.appendChild(el('line', { x1: '0', y1: String(tabH), x2: String(w), y2: String(tabH), stroke: '#ddd', 'stroke-width': '1' })); 213 + }
+95
tests/ascii-export.test.ts
··· 1 + /** 2 + * Tests for ASCII/Unicode text export from diagrams. 3 + */ 4 + import { describe, it, expect } from 'vitest'; 5 + import { exportToAscii, getExportDimensions } from '../src/diagrams/ascii-export.js'; 6 + import { createWhiteboard, addShape, addArrow } from '../src/diagrams/whiteboard.js'; 7 + 8 + describe('exportToAscii', () => { 9 + it('returns empty string for empty whiteboard', () => { 10 + const wb = createWhiteboard(); 11 + expect(exportToAscii(wb)).toBe(''); 12 + }); 13 + 14 + it('draws a single rectangle with box characters', () => { 15 + let wb = createWhiteboard(); 16 + wb = { ...wb, snapToGrid: false }; 17 + wb = addShape(wb, 'rectangle', 0, 0, 80, 48, 'Box'); 18 + const ascii = exportToAscii(wb); 19 + expect(ascii).toContain('┌'); 20 + expect(ascii).toContain('┐'); 21 + expect(ascii).toContain('└'); 22 + expect(ascii).toContain('┘'); 23 + expect(ascii).toContain('─'); 24 + expect(ascii).toContain('│'); 25 + expect(ascii).toContain('Box'); 26 + }); 27 + 28 + it('draws multiple shapes', () => { 29 + let wb = createWhiteboard(); 30 + wb = { ...wb, snapToGrid: false }; 31 + wb = addShape(wb, 'rectangle', 0, 0, 80, 48, 'A'); 32 + wb = addShape(wb, 'rectangle', 120, 0, 80, 48, 'B'); 33 + const ascii = exportToAscii(wb); 34 + expect(ascii).toContain('A'); 35 + expect(ascii).toContain('B'); 36 + }); 37 + 38 + it('draws arrows between shapes', () => { 39 + let wb = createWhiteboard(); 40 + wb = { ...wb, snapToGrid: false }; 41 + wb = addShape(wb, 'rectangle', 0, 0, 80, 48, 'Start'); 42 + wb = addShape(wb, 'rectangle', 200, 0, 80, 48, 'End'); 43 + const shapeIds = [...wb.shapes.keys()]; 44 + wb = addArrow(wb, 45 + { shapeId: shapeIds[0]!, anchor: 'right' }, 46 + { shapeId: shapeIds[1]!, anchor: 'left' }, 47 + ); 48 + const ascii = exportToAscii(wb); 49 + expect(ascii).toContain('→'); 50 + }); 51 + 52 + it('truncates labels that are too long', () => { 53 + let wb = createWhiteboard(); 54 + wb = { ...wb, snapToGrid: false }; 55 + wb = addShape(wb, 'rectangle', 0, 0, 40, 48, 'VeryLongLabelThatShouldBeTruncated'); 56 + const ascii = exportToAscii(wb); 57 + expect(ascii).toContain('…'); 58 + }); 59 + 60 + it('handles wireframe shape kinds same as rectangles', () => { 61 + let wb = createWhiteboard(); 62 + wb = { ...wb, snapToGrid: false }; 63 + wb = addShape(wb, 'browserWindow' as any, 0, 0, 160, 96, 'Browser'); 64 + const ascii = exportToAscii(wb); 65 + expect(ascii).toContain('┌'); 66 + expect(ascii).toContain('Browser'); 67 + }); 68 + 69 + it('caps grid size at 200x100', () => { 70 + let wb = createWhiteboard(); 71 + wb = { ...wb, snapToGrid: false }; 72 + wb = addShape(wb, 'rectangle', 0, 0, 5000, 5000); 73 + const dims = getExportDimensions(wb); 74 + expect(dims.cols).toBeLessThanOrEqual(200); 75 + expect(dims.rows).toBeLessThanOrEqual(100); 76 + }); 77 + }); 78 + 79 + describe('getExportDimensions', () => { 80 + it('returns 0x0 for empty whiteboard', () => { 81 + const wb = createWhiteboard(); 82 + const dims = getExportDimensions(wb); 83 + expect(dims.cols).toBe(0); 84 + expect(dims.rows).toBe(0); 85 + }); 86 + 87 + it('returns positive dimensions for non-empty whiteboard', () => { 88 + let wb = createWhiteboard(); 89 + wb = { ...wb, snapToGrid: false }; 90 + wb = addShape(wb, 'rectangle', 0, 0, 80, 48); 91 + const dims = getExportDimensions(wb); 92 + expect(dims.cols).toBeGreaterThan(0); 93 + expect(dims.rows).toBeGreaterThan(0); 94 + }); 95 + });