reladraw 0.21.0 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -75,20 +75,22 @@ Each `<reladraw-diagram>` is replaced by its SVG. Indent the source however suit
75
75
  To install a skill to let your agent know how to use reladraw:
76
76
 
77
77
  ```
78
- npx skills add reladraw/reladraw -g
78
+ reladraw install-skill -g
79
79
  ```
80
80
 
81
- That installs it for every agent you use (Claude Code, Codex, Cursor, Copilot and others), each in its own skills directory. Leave off `-g` to install it into the current project only. To install it for just one agent, name it with `-a`:
81
+ That installs the skill that came with your copy of reladraw, so it teaches what your reladraw accepts, for every agent you use (Claude Code, Codex, Cursor, Copilot and others), each in its own skills directory. It hands the work to [skills](https://www.npmjs.com/package/skills), and passes on whatever you write after `install-skill`. Leave off `-g` to install it into the current project only. To install it for just one agent, name it with `-a`:
82
82
 
83
83
  ```
84
- npx skills add reladraw/reladraw -g -a claude-code
84
+ reladraw install-skill -g -a claude-code
85
85
  ```
86
86
 
87
- That gets you a copy of the skill at the time you run it, so you'll need to re-run that command to get the latest skill when there is a new release.
87
+ The skill is a copy, so after upgrading reladraw, run the command again to bring the skill up to date with it.
88
+
89
+ To install the skill from the main branch instead, which can run ahead of the release on npm, run `npx skills add reladraw/reladraw -g`.
88
90
 
89
91
  ## Status
90
92
 
91
- Version 0.21.0. Early stage, but works. The parser, layout engine, and SVG renderer are written in TypeScript, with zero runtime dependencies. There is a command-line tool that turns a .reladraw text file into an SVG, and an element that does the same inside a web page.
93
+ Version 0.22.1. Early stage, but works. The parser, layout engine, and SVG renderer are written in TypeScript, with zero runtime dependencies. There is a command-line tool that turns a .reladraw text file into an SVG, and an element that does the same inside a web page.
92
94
 
93
95
  ## License
94
96
 
package/SYNTAX.md CHANGED
@@ -1,4 +1,4 @@
1
- # Syntax reference — 0.21.0
1
+ # Syntax reference — 0.22.1
2
2
 
3
3
  What the language accepts. The changelog at the end lists what changed in each published version; 0.17.0's changes are large, and its entry has a table of every old spelling and what to write instead.
4
4
 
@@ -1186,6 +1186,8 @@ reladraw check diagram.reladraw
1186
1186
  | `--no-checks` | print no findings; errors still print |
1187
1187
  | `-v`, `--version` | print the version |
1188
1188
 
1189
+ `reladraw install-skill` installs the agent skill that came with this version of reladraw, by running `npx skills add` on it with `--copy`. What follows it goes to `skills`: `-g` to install for every project, `-a claude-code` for one agent.
1190
+
1189
1191
  The output is a standalone SVG: every picture is drawn into it as path data, never from a font or a linked file. A web page can draw a diagram in place with `<reladraw-diagram>`; see "In a web page" in the README.
1190
1192
 
1191
1193
  ### A page for looking at a diagram
@@ -1194,7 +1196,7 @@ The output is a standalone SVG: every picture is drawn into it as path data, nev
1194
1196
  reladraw render arch.reladraw --page
1195
1197
  ```
1196
1198
 
1197
- `--page` writes `arch.html`: one self-contained file, for opening in a browser, attaching or committing, that needs nothing running and fetches nothing. The page is in the diagram's own colors, so a dark diagram doesn't sit on a white page, and it is headed with `diagram title:`, or the file's name when there is none. Drag to pan; scroll, pinch or double-click to zoom; Fit and Actual size reset the view, and there is a full-screen button. Save SVG and Save PNG save the drawing as `render` would. The findings are listed beside it in a panel that folds away, and clicking one lights up what its line drew. A box's or a line's name shows under the pointer. Keys: `+` and `-` zoom, `0` fits, `1` is actual size, `f` is full screen.
1199
+ `--page` writes `arch.html`: one self-contained file, for opening in a browser, attaching or committing, that needs nothing running and fetches nothing. The page is in the diagram's own colors, so a dark diagram doesn't sit on a white page, and it is headed with `diagram title:`, or the file's name when there is none. Drag to pan; scroll, pinch or double-click to zoom; Fit shows the whole diagram and 100% returns it to its own scale. The zoom is always shown, with "Fit" beside it while the whole diagram is in view. There is a full-screen button. The Export menu copies the SVG, or saves it or a PNG, as `render` would draw it. The findings are listed beside it in a panel that folds away, and clicking one lights up what its line drew. A box's or a line's name shows under the pointer. Keys: `+` and `-` zoom, `0` fits, `1` is actual size, `f` is full screen.
1198
1200
 
1199
1201
  ### Formatting
1200
1202
 
@@ -1238,7 +1240,7 @@ clause-gap: 2
1238
1240
 
1239
1241
  An unknown setting, one written twice, or a value it doesn't take is an error naming it.
1240
1242
 
1241
- **Breaking a long statement.** With both at a number, a statement up to `wrap-at` stays on one line, one up to `clause-per-line-at` wraps, and a longer one goes one clause to a line. Its length is measured on its own, before columns, so whether it breaks never depends on its neighbors; its first line still lines up with theirs. A break falls only between clauses, never inside one or inside a bracket, so a clause longer than the width stands alone on its line, past it. A statement broken by hand is broken again by these rules, or joined when they leave it short enough; one kept on its lines for a comment among them is left as it is. A comment at the end of a broken statement follows its last line.
1243
+ **Breaking a long statement.** With both at a number, a statement up to `wrap-at` stays on one line, one up to `clause-per-line-at` wraps, and a longer one goes one clause to a line. Its length is measured as it would be printed, lined up with its neighbors, so a short statement pushed past the width by a long neighbor's columns breaks too; its first line still lines up with theirs. A statement that can't break, such as a node's name and text with nothing after them, is left as it is, even when its columns carry it past the width. A break falls only between clauses, never inside one or inside a bracket, so a clause longer than the width stands alone on its line, past it. A statement broken by hand is broken again by these rules, or joined when they leave it short enough; one kept on its lines for a comment among them is left as it is. A comment at the end of a broken statement follows its last line.
1242
1244
 
1243
1245
  ```
1244
1246
  // wrap-at: 60
@@ -1315,6 +1317,19 @@ Designed and absent from the code.
1315
1317
 
1316
1318
  Pre-1.0, so the minor number is where a breaking change goes. Every removal is refused by name with the replacement quoted, rather than dropped in silence: an older file stops with an error saying what to write instead.
1317
1319
 
1320
+ **0.22.1**
1321
+
1322
+ - **`reladraw format` keeps to `wrap-at` once the columns are lined up.** A statement's length used to be measured on its own, before it was lined up with its neighbors, so a short one like `node api "API" layout: column` stayed on one line and was then padded out by a long neighbor's name to nearly twice the width. It is now measured as printed and breaks like any other long statement; `clause-per-line-at` is measured the same way.
1323
+ - **Output sent to another program is no longer cut off.** `reladraw format -` and `reladraw render -o -` stopped after the first 64 KB when their output went into a pipe rather than a file, and still reported success, so an editor formatting a large diagram could replace it with a shortened copy. All of it now comes through.
1324
+ - **The agent skill puts a link on the thing it belongs to.** Asked to add links, an agent would add a new text node for each one; the skill now says to give the existing node or line a `url:`. When a diagram's things have real web addresses, such as a pull request's files, it offers to link them rather than doing it unasked.
1325
+
1326
+ **0.22.0**
1327
+
1328
+ - **`reladraw install-skill`** installs the agent skill that came with your reladraw, so it teaches what your copy accepts rather than what the main branch does. It hands the work to `skills`, passing on `-g`, `-a` and the rest. The npm package now carries the skill.
1329
+ - **The agent skill opens each diagram it draws as a page in the default browser**, with `reladraw render --page` as the last step of every drawing rather than only when asked to show it. It doesn't open one when there is no display, or when the user has said not to.
1330
+ - **The page says when it is showing the whole diagram**: its zoom reads "Fit · 62%" while fitted, and just the number once you zoom or pan. The Actual size button is now 100%, which is what it always did. Save SVG and Save PNG are under an Export button, which can also copy the SVG.
1331
+ - **The playground pans and zooms as the page does**: − and +, Fit, 100%, the wheel, a pinch, a drag, a double-click, and the keys + - 0 1, with the zoom shown between − and +. Its one Actual size button, whose label named the view you would go to rather than the one you were in, is gone. The view stays where you left it as you type. Copy SVG, Save SVG and Save PNG are now under one Export button, so the row of controls fits.
1332
+
1318
1333
  **0.21.0**
1319
1334
 
1320
1335
  - **A diagram can have a title**: `diagram title: "How a login reaches the database"`. The SVG carries it as its own title, which a browser shows on the tab. See [The diagram](#the-diagram).
package/dist/cli.js CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
+ import { spawnSync } from 'node:child_process';
2
3
  import { existsSync, readFileSync } from 'node:fs';
3
4
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
5
  import { basename, dirname, resolve as resolvePath } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
5
7
  import { nearMatch, readChecks } from './checks.js';
6
8
  import { SourceError } from './errors.js';
7
9
  import { format } from './format.js';
@@ -17,6 +19,7 @@ const USAGE = `reladraw — draw a diagram from stated placement
17
19
  reladraw format <input.reladraw>... [--check]
18
20
  reladraw format - [--check]
19
21
  reladraw format --init
22
+ reladraw install-skill [skills options]
20
23
 
21
24
  render draw the diagram to an SVG, and print any findings: places
22
25
  where the picture, drawn as written, may not read as meant.
@@ -32,6 +35,11 @@ const USAGE = `reladraw — draw a diagram from stated placement
32
35
  folder up.
33
36
  --init writes a .reladraw-format here, every setting at its
34
37
  default with what it does.
38
+ install-skill
39
+ install the agent skill that came with this version of
40
+ reladraw, so it teaches what this tool accepts. It runs
41
+ npx skills add with --copy, and passes on what follows:
42
+ -g for every project, -a claude-code for one agent.
35
43
 
36
44
  -o, --out where render writes the SVG. Defaults to the input path with
37
45
  its extension replaced by .svg. Use - for standard output.
@@ -51,8 +59,35 @@ const USAGE = `reladraw — draw a diagram from stated placement
51
59
  print the version.
52
60
  -h, --help print this.
53
61
  `;
54
- const COMMANDS = ['render', 'check', 'format'];
62
+ const COMMANDS = ['render', 'check', 'format', 'install-skill'];
63
+ // The version of skills this was tried with, so a change there can't break it.
64
+ const SKILLS_PACKAGE = 'skills@1.7.6';
65
+ // The skill ships in the package beside dist/, as the one in the repository.
66
+ // skills does the work of knowing where each agent keeps its skills; --copy
67
+ // because an npx run leaves the package in a cache, where a link would break.
68
+ function installSkill(args) {
69
+ const skills = fileURLToPath(new URL('../.claude/skills/', import.meta.url));
70
+ if (!existsSync(skills)) {
71
+ process.stderr.write(`reladraw: the skill isn't in this install of reladraw (looked in ${skills})\n`);
72
+ return 1;
73
+ }
74
+ const windows = process.platform === 'win32';
75
+ const command = [SKILLS_PACKAGE, 'add', skills, '--copy', ...args];
76
+ // On Windows npx is a .cmd, which Node runs only through a shell.
77
+ const result = spawnSync(windows ? 'npx.cmd' : 'npx', windows ? command.map((a) => `"${a}"`) : command, {
78
+ stdio: 'inherit',
79
+ shell: windows,
80
+ });
81
+ if (result.error) {
82
+ process.stderr.write(`reladraw: couldn't run npx to install the skill: ${result.error.message}\n`);
83
+ return 1;
84
+ }
85
+ return result.status ?? 1;
86
+ }
55
87
  async function main(argv) {
88
+ // Everything after install-skill is for skills, -h and -v included.
89
+ if (argv[0] === 'install-skill')
90
+ return installSkill(argv.slice(1));
56
91
  if (argv.length === 0 || argv.includes('-h') || argv.includes('--help')) {
57
92
  process.stdout.write(USAGE);
58
93
  return argv.length === 0 ? 1 : 0;
@@ -382,7 +417,13 @@ function nearestSettings(folder, found) {
382
417
  found.set(folder, result);
383
418
  return result;
384
419
  }
385
- main(process.argv.slice(2)).then((code) => process.exit(code), (error) => {
420
+ // The exit code is set, not exited with: process.exit ends the process at
421
+ // once, and a write to a pipe that hasn't drained yet is lost, which cut
422
+ // `format -` and `render -o -` off at 64 KB. Node exits on its own once the
423
+ // output is written. `./dev.sh pipe` checks it.
424
+ main(process.argv.slice(2)).then((code) => {
425
+ process.exitCode = code;
426
+ }, (error) => {
386
427
  process.stderr.write(`reladraw: ${error instanceof Error ? error.message : String(error)}\n`);
387
- process.exit(1);
428
+ process.exitCode = 1;
388
429
  });
package/dist/format.js CHANGED
@@ -280,16 +280,15 @@ function textWidth(text) {
280
280
  /** How wide a tab counts as, where a width has to be known. */
281
281
  const TAB_WIDTH = 4;
282
282
  /**
283
- * Whether a statement goes past `wrap-at` or `clause-per-line-at`, and if so
284
- * where its first line ends. Its length is measured on its own, unaligned, so
285
- * whether one statement breaks never depends on its neighbors. The first line
286
- * holds the keyword and name, and the texts after them if `first-line` says
287
- * so, and shares columns with its neighbors like a one-line statement; the
288
- * clauses after it are laid out by `continuedLines`.
283
+ * Whether a statement `length` characters long goes past `wrap-at` or
284
+ * `clause-per-line-at`, and if so where its first line ends. The length is
285
+ * the line as it would be printed, lined up with its neighbors (see
286
+ * `layOutItems`). The first line holds the keyword and name, and the texts
287
+ * after them if `first-line` says so, and shares columns with its neighbors
288
+ * like a one-line statement; the clauses after it are laid out by
289
+ * `continuedLines`.
289
290
  */
290
- function breakStatement(item, settings, gap) {
291
- const texts = item.cells.map((cell) => cell.text);
292
- const length = textWidth(texts.filter((text, at) => text !== '' || at === 0).join(gap));
291
+ function breakStatement(item, settings, length) {
293
292
  const perLine = settings['clause-per-line-at'];
294
293
  const wrap = settings['wrap-at'];
295
294
  const oneEach = perLine === 'always' || (typeof perLine === 'number' && length > perLine);
@@ -338,7 +337,8 @@ function continuedLines(item, indent, settings, gap, gapWidth) {
338
337
  function layOutItems(written, settings) {
339
338
  const gap = settings['clause-gap'] === 'tab' ? '\t' : ' '.repeat(settings['clause-gap']);
340
339
  const gapWidth = settings['clause-gap'] === 'tab' ? 1 : settings['clause-gap'];
341
- const items = written.map((item) => (item.kind === 'statement' ? breakStatement(item, settings, gap) : item));
340
+ // Which statements break is decided further down, once the columns are known.
341
+ const items = [...written];
342
342
  // What lines up in columns: a one-line statement whole, a broken one's first line.
343
343
  const aligned = (item) => item.kind === 'statement' ? item.cells : item.kind === 'broken' ? item.first : undefined;
344
344
  // Which statements share columns: each is given a group number. A comment
@@ -372,17 +372,20 @@ function layOutItems(written, settings) {
372
372
  previous = item;
373
373
  });
374
374
  // Each group's column widths, and the column its trailing comments start at.
375
- const widths = new Map();
375
+ let widths = new Map();
376
376
  const commentAt = new Map();
377
- items.forEach((item, index) => {
378
- const cells = aligned(item);
379
- if (cells === undefined)
380
- return;
381
- const id = group.get(index);
382
- const own = widths.get(id) ?? [];
383
- cells.forEach((cell, at) => (own[at] = Math.max(own[at] ?? 0, textWidth(cell.text))));
384
- widths.set(id, own);
385
- });
377
+ const measureColumns = () => {
378
+ widths = new Map();
379
+ items.forEach((item, index) => {
380
+ const cells = aligned(item);
381
+ if (cells === undefined)
382
+ return;
383
+ const id = group.get(index);
384
+ const own = widths.get(id) ?? [];
385
+ cells.forEach((cell, at) => (own[at] = Math.max(own[at] ?? 0, textWidth(cell.text))));
386
+ widths.set(id, own);
387
+ });
388
+ };
386
389
  // Each cell's text, and where on the line it starts.
387
390
  const placed = (cells, id) => {
388
391
  const own = widths.get(id);
@@ -407,6 +410,50 @@ function layOutItems(written, settings) {
407
410
  line += ' '.repeat(one.at - textWidth(line)) + one.text;
408
411
  return line;
409
412
  };
413
+ // Which statements break. A statement is measured as it would be printed,
414
+ // lined up with its neighbors, so a short statement padded out past
415
+ // `wrap-at` by a long neighbor's columns breaks too. Breaking one takes its
416
+ // later clauses out of the columns, which can only narrow them and shorten
417
+ // the rest, so they are broken one at a time, the longest on its own first:
418
+ // that is the one most likely to be widening a column the others are past
419
+ // only because of. A statement whose break is settled by its unaligned
420
+ // length alone, since lining it up can only lengthen it, breaks before the
421
+ // first measure: one past `clause-per-line-at`, or past `wrap-at` with no
422
+ // `clause-per-line-at` above it to reach.
423
+ const ownLength = (item) => textWidth(item.cells.map((cell) => cell.text).filter((text, at) => text !== '' || at === 0).join(gap));
424
+ const breakAt = (index, length) => {
425
+ const item = items[index];
426
+ if (item.kind !== 'statement')
427
+ return false;
428
+ const broken = breakStatement(item, settings, length);
429
+ items[index] = broken;
430
+ return broken !== item;
431
+ };
432
+ const perLine = settings['clause-per-line-at'];
433
+ const settled = (length) => typeof perLine !== 'number' || length > perLine;
434
+ items.forEach((item, index) => {
435
+ if (item.kind !== 'statement')
436
+ return;
437
+ const own = ownLength(item);
438
+ if (settled(own))
439
+ breakAt(index, own);
440
+ });
441
+ for (;;) {
442
+ measureColumns();
443
+ let longest;
444
+ items.forEach((item, index) => {
445
+ if (item.kind !== 'statement')
446
+ return;
447
+ const printed = textWidth(lineOf(item.cells, group.get(index)));
448
+ if (breakStatement(item, settings, printed) === item)
449
+ return;
450
+ const own = ownLength(item);
451
+ if (longest === undefined || own > longest.own)
452
+ longest = { index, own, printed };
453
+ });
454
+ if (longest === undefined || !breakAt(longest.index, longest.printed))
455
+ break;
456
+ }
410
457
  items.forEach((item, index) => {
411
458
  if (item.kind !== 'statement')
412
459
  return;
package/dist/page.js CHANGED
@@ -4,6 +4,7 @@ import { blame, parse } from './parser.js';
4
4
  import { renderTagged, themeOf } from './render.js';
5
5
  import { resolve } from './resolve.js';
6
6
  import { luminance } from './themes.js';
7
+ import { PAN_ZOOM } from './viewer.js';
7
8
  /**
8
9
  * `reladraw render --page`: one diagram in one self-contained HTML page, made
9
10
  * for looking at rather than embedding. The page is the diagram's own colors,
@@ -39,7 +40,9 @@ function page({ title, name, svg, tagged, findings, theme }) {
39
40
  findings: findings.map((finding) => ({ kind: finding.kind, message: finding.message, line: finding.line })),
40
41
  };
41
42
  // Replaced by functions, so a `$` in a title or a drawing is taken as written.
42
- return PAGE.replace('{{scheme}}', () => scheme)
43
+ // The zoom's code goes in first, so nothing a diagram says can be taken for its marker.
44
+ return PAGE.replace('{{panzoom}}', () => PAN_ZOOM)
45
+ .replace('{{scheme}}', () => scheme)
43
46
  .replace('{{title}}', () => escapeHtml(title))
44
47
  .replace('{{heading}}', () => escapeHtml(title))
45
48
  .replace('{{colors}}', () => [
@@ -95,7 +98,15 @@ const PAGE = `<!doctype html>
95
98
  button:disabled { opacity: 0.45; cursor: default; background: transparent; }
96
99
  button[aria-pressed="true"] { background: color-mix(in srgb, var(--accent) 25%, transparent); border-color: var(--accent); }
97
100
  button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
98
- #scale { min-width: 3.5em; text-align: center; color: var(--muted); font-variant-numeric: tabular-nums; font-size: 13px; }
101
+ .menu { position: relative; }
102
+ .menu-list {
103
+ position: absolute; right: 0; top: calc(100% + 4px); z-index: 5;
104
+ flex-direction: column; gap: 4px; padding: 6px;
105
+ background: var(--page); border: 1px solid color-mix(in srgb, var(--rule) 70%, transparent); border-radius: 6px;
106
+ }
107
+ .menu-list:not([hidden]) { display: flex; }
108
+ #scale .fitted { color: var(--accent); }
109
+ #scale { min-width: 6em; text-align: center; color: var(--muted); font-variant-numeric: tabular-nums; font-size: 13px; }
99
110
  main { position: relative; flex: 1; display: flex; min-height: 0; }
100
111
  #view { position: relative; flex: 1; overflow: hidden; touch-action: none; cursor: grab; }
101
112
  #view.dragging { cursor: grabbing; }
@@ -139,11 +150,17 @@ const PAGE = `<!doctype html>
139
150
  <span id="scale" aria-live="polite">100%</span>
140
151
  <button id="in" type="button" title="Zoom in (+)" aria-label="Zoom in">+</button>
141
152
  <button id="fit" type="button" title="The whole diagram in view (0)">Fit</button>
142
- <button id="actual" type="button" title="One pixel of the drawing to one of the screen (1)">Actual size</button>
153
+ <button id="actual" type="button" title="The drawing at its own scale (1)">100%</button>
143
154
  <span class="gap"></span>
144
155
  <button id="full" type="button" title="Full screen (f)">Full screen</button>
145
- <button id="save-svg" type="button">Save SVG</button>
146
- <button id="save-png" type="button">Save PNG</button>
156
+ <span class="menu">
157
+ <button id="export" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="export-list">Export ▾</button>
158
+ <span id="export-list" class="menu-list" hidden>
159
+ <button id="copy-svg" type="button">Copy SVG</button>
160
+ <button id="save-svg" type="button" title="Save the picture as an SVG file">Save SVG</button>
161
+ <button id="save-png" type="button" title="Save the picture as a PNG, at twice its size">Save PNG</button>
162
+ </span>
163
+ </span>
147
164
  <button id="toggle-findings" type="button" aria-controls="findings"></button>
148
165
  </div>
149
166
  </header>
@@ -167,133 +184,14 @@ const PAGE = `<!doctype html>
167
184
  var svg = stage.querySelector('svg');
168
185
  var tip = document.getElementById('tip');
169
186
  var scaleLabel = document.getElementById('scale');
170
- var box = svg.viewBox.baseVal;
171
- var natural = { w: box.width, h: box.height };
172
-
173
- // The drawing is resized rather than scaled with a transform, so the
174
- // browser draws it afresh at every size and it stays sharp.
175
- var at = { x: 0, y: 0, s: 1 };
176
- // Until the reader pans or zooms, a change of window size redraws the view
177
- // the way it was asked for: as it opened, or fitted.
178
- var fitted = 'open';
179
- var MIN = 0.05;
180
- var MAX = 20;
181
-
182
- function apply() {
183
- svg.setAttribute('width', natural.w * at.s);
184
- svg.setAttribute('height', natural.h * at.s);
185
- stage.style.transform = 'translate(' + at.x + 'px,' + at.y + 'px)';
186
- scaleLabel.textContent = Math.round(at.s * 100) + '%';
187
- }
188
-
189
- function size() {
190
- return { w: view.clientWidth, h: view.clientHeight };
191
- }
192
-
193
- function center(s) {
194
- var room = size();
195
- at = { s: s, x: (room.w - natural.w * s) / 2, y: (room.h - natural.h * s) / 2 };
196
- apply();
197
- }
198
-
199
- function fitScale() {
200
- var room = size();
201
- return Math.max(MIN, Math.min((room.w - 32) / natural.w, (room.h - 32) / natural.h));
202
- }
203
-
204
- function fit() {
205
- fitted = 'fit';
206
- center(fitScale());
207
- }
208
-
209
- // A diagram smaller than the window opens at its own size; a larger one
210
- // opens fitted.
211
- function open() {
212
- fitted = 'open';
213
- center(Math.min(1, fitScale()));
214
- }
215
-
216
- function refit() {
217
- if (fitted === 'fit') fit();
218
- else if (fitted === 'open') open();
219
- }
220
-
221
- // Zoom about a point of the view, which stays where it is on screen.
222
- function zoomAt(factor, px, py) {
223
- var s = Math.min(MAX, Math.max(MIN, at.s * factor));
224
- var k = s / at.s;
225
- at = { s: s, x: px - (px - at.x) * k, y: py - (py - at.y) * k };
226
- fitted = false;
227
- apply();
228
- }
229
-
230
- function zoomMiddle(factor) {
231
- var room = size();
232
- zoomAt(factor, room.w / 2, room.h / 2);
233
- }
234
-
235
- document.getElementById('in').onclick = function () { zoomMiddle(1.25); };
236
- document.getElementById('out').onclick = function () { zoomMiddle(0.8); };
237
- document.getElementById('fit').onclick = fit;
238
- document.getElementById('actual').onclick = function () {
239
- fitted = false;
240
- center(1);
241
- };
242
-
243
- view.addEventListener('wheel', function (event) {
244
- event.preventDefault();
245
- var rect = view.getBoundingClientRect();
246
- // A pinch on a trackpad arrives as a wheel with ctrl held, in small steps.
247
- var step = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
248
- zoomAt(Math.exp(-step * (event.ctrlKey ? 0.01 : 0.0015)), event.clientX - rect.left, event.clientY - rect.top);
249
- }, { passive: false });
250
-
251
- // Dragging pans; two fingers pinch.
252
- var pointers = new Map();
253
- var pinch = null;
254
- view.addEventListener('pointerdown', function (event) {
255
- if (event.button !== 0) return;
256
- if (event.target.closest('a')) return;
257
- view.setPointerCapture(event.pointerId);
258
- pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
259
- if (pointers.size === 2) {
260
- var two = Array.from(pointers.values());
261
- pinch = Math.hypot(two[0].x - two[1].x, two[0].y - two[1].y);
262
- }
263
- view.classList.add('dragging');
264
- });
265
- view.addEventListener('pointermove', function (event) {
266
- var last = pointers.get(event.pointerId);
267
- if (!last) return;
268
- var now = { x: event.clientX, y: event.clientY };
269
- pointers.set(event.pointerId, now);
270
- if (pointers.size === 2 && pinch) {
271
- var two = Array.from(pointers.values());
272
- var apart = Math.hypot(two[0].x - two[1].x, two[0].y - two[1].y);
273
- var rect = view.getBoundingClientRect();
274
- zoomAt(apart / pinch, (two[0].x + two[1].x) / 2 - rect.left, (two[0].y + two[1].y) / 2 - rect.top);
275
- pinch = apart;
276
- return;
277
- }
278
- at.x += now.x - last.x;
279
- at.y += now.y - last.y;
280
- fitted = false;
281
- apply();
282
- });
283
- function release(event) {
284
- pointers.delete(event.pointerId);
285
- if (pointers.size < 2) pinch = null;
286
- if (pointers.size === 0) view.classList.remove('dragging');
287
- }
288
- view.addEventListener('pointerup', release);
289
- view.addEventListener('pointercancel', release);
290
- view.addEventListener('dblclick', function (event) {
291
- var rect = view.getBoundingClientRect();
292
- zoomAt(event.shiftKey ? 0.5 : 2, event.clientX - rect.left, event.clientY - rect.top);
187
+ {{panzoom}}
188
+ var zoom = panZoom(view, stage, scaleLabel, {
189
+ out: document.getElementById('out'),
190
+ in: document.getElementById('in'),
191
+ fit: document.getElementById('fit'),
192
+ actual: document.getElementById('actual'),
293
193
  });
294
194
 
295
- window.addEventListener('resize', refit);
296
-
297
195
  /* ---- full screen ---- */
298
196
 
299
197
  var full = document.getElementById('full');
@@ -327,6 +225,39 @@ const PAGE = `<!doctype html>
327
225
  setTimeout(function () { button.textContent = was; }, 1200);
328
226
  }
329
227
 
228
+ // Copy SVG, Save SVG and Save PNG are under one Export button, as in the
229
+ // playground. The list stays open after a choice, so "Copied" can be read,
230
+ // and closes on a click elsewhere or Escape.
231
+ var exportButton = document.getElementById('export');
232
+ var exportList = document.getElementById('export-list');
233
+ function showExport(open) {
234
+ exportList.hidden = !open;
235
+ exportButton.setAttribute('aria-expanded', String(open));
236
+ }
237
+ exportButton.onclick = function () { showExport(exportList.hidden); };
238
+ document.addEventListener('click', function (event) {
239
+ if (!exportList.hidden && !event.target.closest('.menu')) showExport(false);
240
+ });
241
+
242
+ // The clipboard API needs a secure context; a page opened from disk may
243
+ // not count as one, so the old selection route is the fallback.
244
+ var copySvg = document.getElementById('copy-svg');
245
+ copySvg.onclick = function () {
246
+ function fallback() {
247
+ var area = document.createElement('textarea');
248
+ area.value = DATA.svg;
249
+ document.body.appendChild(area);
250
+ area.select();
251
+ var done = false;
252
+ try { done = document.execCommand('copy'); } catch (e) {}
253
+ area.remove();
254
+ flash(copySvg, done ? 'Copied' : 'Blocked');
255
+ }
256
+ if (navigator.clipboard && navigator.clipboard.writeText) {
257
+ navigator.clipboard.writeText(DATA.svg).then(function () { flash(copySvg, 'Copied'); }, fallback);
258
+ } else fallback();
259
+ };
260
+
330
261
  document.getElementById('save-svg').onclick = function () {
331
262
  saveBlob(new Blob([DATA.svg], { type: 'image/svg+xml' }), DATA.name + '.svg');
332
263
  };
@@ -339,9 +270,10 @@ const PAGE = `<!doctype html>
339
270
  savePng.onclick = function () {
340
271
  var image = new Image();
341
272
  image.onload = function () {
273
+ var box = svg.viewBox.baseVal;
342
274
  var canvas = document.createElement('canvas');
343
- canvas.width = natural.w * 2;
344
- canvas.height = natural.h * 2;
275
+ canvas.width = box.width * 2;
276
+ canvas.height = box.height * 2;
345
277
  canvas.getContext('2d').drawImage(image, 0, 0, canvas.width, canvas.height);
346
278
  canvas.toBlob(function (blob) {
347
279
  blob ? saveBlob(blob, DATA.name + '.png') : flash(savePng, 'Failed');
@@ -394,7 +326,6 @@ const PAGE = `<!doctype html>
394
326
  function showPanel(open) {
395
327
  panel.hidden = !open;
396
328
  toggle.setAttribute('aria-pressed', String(open));
397
- refit();
398
329
  }
399
330
  if (count === 0) {
400
331
  toggle.textContent = 'No findings';
@@ -437,10 +368,7 @@ const PAGE = `<!doctype html>
437
368
  });
438
369
  var inside = r.l >= room.left && r.r <= room.right && r.t >= room.top && r.b <= room.bottom;
439
370
  if (inside) return;
440
- at.x += (room.left + room.width / 2) - (r.l + r.r) / 2;
441
- at.y += (room.top + room.height / 2) - (r.t + r.b) / 2;
442
- fitted = false;
443
- apply();
371
+ zoom.pan((room.left + room.width / 2) - (r.l + r.r) / 2, (room.top + room.height / 2) - (r.t + r.b) / 2);
444
372
  }
445
373
 
446
374
  DATA.findings.forEach(function (finding) {
@@ -465,22 +393,18 @@ const PAGE = `<!doctype html>
465
393
  list.append(item);
466
394
  });
467
395
 
468
- /* ---- keys ---- */
396
+ /* ---- keys, beyond the zoom's own ---- */
469
397
 
470
398
  document.addEventListener('keydown', function (event) {
471
399
  if (event.ctrlKey || event.metaKey || event.altKey) return;
472
400
  if (event.target.closest && event.target.closest('input, textarea')) return;
473
- if (event.key === '+' || event.key === '=') zoomMiddle(1.25);
474
- else if (event.key === '-' || event.key === '_') zoomMiddle(0.8);
475
- else if (event.key === '0') fit();
476
- else if (event.key === '1') { fitted = false; center(1); }
477
- else if (event.key === 'f' && !full.hidden) toggleFull();
478
- else if (event.key === 'Escape') unlight();
401
+ if (event.key === 'f' && !full.hidden) toggleFull();
402
+ else if (event.key === 'Escape') { showExport(false); unlight(); }
479
403
  else return;
480
404
  event.preventDefault();
481
405
  });
482
406
 
483
- open();
407
+ zoom.show();
484
408
  })();
485
409
  </script>
486
410
  </body>
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The pan and zoom shared by the page `render --page` writes and the
3
+ * playground, so the two look at a diagram the same way: − and +, Fit, 100%,
4
+ * the zoom always shown and saying "Fit" while fitted, the wheel, a pinch, a
5
+ * drag, a double-click, and the keys + - 0 1.
6
+ *
7
+ * It is browser code kept as a string, since it runs in a page, not here: the
8
+ * page writes it into its script, and the playground's build writes it into
9
+ * its own. Calling `panZoom(view, stage, label, buttons)` returns the controls
10
+ * the page and the playground need beyond it.
11
+ *
12
+ * `view` clips and takes the pointer; `stage` holds the SVG and is moved
13
+ * inside it; `label` shows the zoom; `buttons` holds the elements for out, in,
14
+ * fit and 100%, any of which may be missing.
15
+ */
16
+ export declare const PAN_ZOOM: string;