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

Configure Feed

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

Merge pull request 'feat: wireframe components, ASCII export, frame shapes (0.43.0)' (#381) from feat/wireframe-components into main

scott c4592a77 d776dfb3

+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 + });