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/.claude/skills/reladraw/SKILL.md +209 -0
- package/.claude/skills/reladraw/reference/syntax.md +1608 -0
- package/README.md +7 -5
- package/SYNTAX.md +18 -3
- package/dist/cli.js +44 -3
- package/dist/format.js +67 -20
- package/dist/page.js +69 -145
- package/dist/viewer.d.ts +16 -0
- package/dist/viewer.js +185 -0
- package/package.json +2 -1
- package/dist/constrain.d.ts +0 -56
- package/dist/constrain.js +0 -95
- package/dist/search.d.ts +0 -104
- package/dist/search.js +0 -490
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
|
-
|
|
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
|
-
|
|
84
|
+
reladraw install-skill -g -a claude-code
|
|
85
85
|
```
|
|
86
86
|
|
|
87
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
284
|
-
* where its first line ends.
|
|
285
|
-
*
|
|
286
|
-
* holds the keyword and name, and the texts
|
|
287
|
-
* so, and shares columns with its neighbors
|
|
288
|
-
* clauses after it are laid out by
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
375
|
+
let widths = new Map();
|
|
376
376
|
const commentAt = new Map();
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
-
<
|
|
146
|
-
|
|
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
|
-
|
|
171
|
-
var
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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 =
|
|
344
|
-
canvas.height =
|
|
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
|
-
|
|
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 === '
|
|
474
|
-
else if (event.key === '
|
|
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
|
-
|
|
407
|
+
zoom.show();
|
|
484
408
|
})();
|
|
485
409
|
</script>
|
|
486
410
|
</body>
|
package/dist/viewer.d.ts
ADDED
|
@@ -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;
|