reladraw 0.21.0 → 0.22.0

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.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.
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.0
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
 
@@ -1315,6 +1317,13 @@ 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.0**
1321
+
1322
+ - **`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.
1323
+ - **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.
1324
+ - **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.
1325
+ - **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.
1326
+
1318
1327
  **0.21.0**
1319
1328
 
1320
1329
  - **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;
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;
package/dist/viewer.js ADDED
@@ -0,0 +1,185 @@
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 const PAN_ZOOM = String.raw `
17
+ function panZoom(view, stage, label, buttons) {
18
+ var MIN = 0.05;
19
+ var MAX = 20;
20
+ var svg = null;
21
+ var natural = { w: 1, h: 1 };
22
+ // The drawing is resized rather than scaled with a transform, so the
23
+ // browser draws it afresh at every size and it stays sharp.
24
+ var at = { x: 0, y: 0, s: 1 };
25
+ // Until the reader pans or zooms, a change of size redraws the view the way
26
+ // it was asked for: as it opened, or fitted.
27
+ var fitted = 'open';
28
+
29
+ function apply() {
30
+ if (!svg) {
31
+ label.textContent = '';
32
+ return;
33
+ }
34
+ svg.setAttribute('width', natural.w * at.s);
35
+ svg.setAttribute('height', natural.h * at.s);
36
+ stage.style.transform = 'translate(' + at.x + 'px,' + at.y + 'px)';
37
+ // The zoom always shows. Fitted, it says so too, since 62% alone doesn't
38
+ // say whether that is the whole diagram or a zoom that happens to be 62%.
39
+ // A large diagram opens fitted; a small one opens at 100%, not fitted.
40
+ var isFit = fitted === 'fit' || (fitted === 'open' && at.s < 1);
41
+ label.innerHTML = (isFit ? '<span class="fitted">Fit</span> · ' : '') + Math.round(at.s * 100) + '%';
42
+ }
43
+
44
+ function size() {
45
+ return { w: view.clientWidth, h: view.clientHeight };
46
+ }
47
+
48
+ function center(s) {
49
+ var room = size();
50
+ at = { s: s, x: (room.w - natural.w * s) / 2, y: (room.h - natural.h * s) / 2 };
51
+ apply();
52
+ }
53
+
54
+ function fitScale() {
55
+ var room = size();
56
+ return Math.max(MIN, Math.min((room.w - 32) / natural.w, (room.h - 32) / natural.h));
57
+ }
58
+
59
+ function fit() {
60
+ fitted = 'fit';
61
+ center(fitScale());
62
+ }
63
+
64
+ function actual() {
65
+ fitted = false;
66
+ center(1);
67
+ }
68
+
69
+ // A diagram smaller than the view opens at its own size; a larger one
70
+ // opens fitted.
71
+ function open() {
72
+ fitted = 'open';
73
+ center(Math.min(1, fitScale()));
74
+ }
75
+
76
+ function refit() {
77
+ if (!svg) return;
78
+ if (fitted === 'fit') fit();
79
+ else if (fitted === 'open') open();
80
+ else apply();
81
+ }
82
+
83
+ // Zoom about a point of the view, which stays where it is on screen.
84
+ function zoomAt(factor, px, py) {
85
+ if (!svg) return;
86
+ var s = Math.min(MAX, Math.max(MIN, at.s * factor));
87
+ var k = s / at.s;
88
+ at = { s: s, x: px - (px - at.x) * k, y: py - (py - at.y) * k };
89
+ fitted = false;
90
+ apply();
91
+ }
92
+
93
+ function zoomMiddle(factor) {
94
+ var room = size();
95
+ zoomAt(factor, room.w / 2, room.h / 2);
96
+ }
97
+
98
+ function pan(dx, dy) {
99
+ at.x += dx;
100
+ at.y += dy;
101
+ fitted = false;
102
+ apply();
103
+ }
104
+
105
+ // A new drawing in the stage. The view stays as the reader left it: fitted
106
+ // stays fitted to the new drawing, and a zoom keeps its scale and place.
107
+ function show() {
108
+ svg = stage.querySelector('svg');
109
+ if (!svg) return apply();
110
+ var box = svg.viewBox.baseVal;
111
+ natural = box && box.width ? { w: box.width, h: box.height } : { w: parseFloat(svg.getAttribute('width')) || 1, h: parseFloat(svg.getAttribute('height')) || 1 };
112
+ refit();
113
+ }
114
+
115
+ if (buttons.out) buttons.out.onclick = function () { zoomMiddle(0.8); };
116
+ if (buttons.in) buttons.in.onclick = function () { zoomMiddle(1.25); };
117
+ if (buttons.fit) buttons.fit.onclick = fit;
118
+ if (buttons.actual) buttons.actual.onclick = actual;
119
+
120
+ view.addEventListener('wheel', function (event) {
121
+ event.preventDefault();
122
+ var rect = view.getBoundingClientRect();
123
+ // A pinch on a trackpad arrives as a wheel with ctrl held, in small steps.
124
+ var step = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
125
+ zoomAt(Math.exp(-step * (event.ctrlKey ? 0.01 : 0.0015)), event.clientX - rect.left, event.clientY - rect.top);
126
+ }, { passive: false });
127
+
128
+ // Dragging pans; two fingers pinch.
129
+ var pointers = new Map();
130
+ var pinch = null;
131
+ view.addEventListener('pointerdown', function (event) {
132
+ if (event.button !== 0) return;
133
+ if (event.target.closest('a')) return;
134
+ view.setPointerCapture(event.pointerId);
135
+ pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
136
+ if (pointers.size === 2) {
137
+ var two = Array.from(pointers.values());
138
+ pinch = Math.hypot(two[0].x - two[1].x, two[0].y - two[1].y);
139
+ }
140
+ view.classList.add('dragging');
141
+ });
142
+ view.addEventListener('pointermove', function (event) {
143
+ var last = pointers.get(event.pointerId);
144
+ if (!last) return;
145
+ var now = { x: event.clientX, y: event.clientY };
146
+ pointers.set(event.pointerId, now);
147
+ if (pointers.size === 2 && pinch) {
148
+ var two = Array.from(pointers.values());
149
+ var apart = Math.hypot(two[0].x - two[1].x, two[0].y - two[1].y);
150
+ var rect = view.getBoundingClientRect();
151
+ zoomAt(apart / pinch, (two[0].x + two[1].x) / 2 - rect.left, (two[0].y + two[1].y) / 2 - rect.top);
152
+ pinch = apart;
153
+ return;
154
+ }
155
+ pan(now.x - last.x, now.y - last.y);
156
+ });
157
+ function release(event) {
158
+ pointers.delete(event.pointerId);
159
+ if (pointers.size < 2) pinch = null;
160
+ if (pointers.size === 0) view.classList.remove('dragging');
161
+ }
162
+ view.addEventListener('pointerup', release);
163
+ view.addEventListener('pointercancel', release);
164
+ view.addEventListener('dblclick', function (event) {
165
+ var rect = view.getBoundingClientRect();
166
+ zoomAt(event.shiftKey ? 0.5 : 2, event.clientX - rect.left, event.clientY - rect.top);
167
+ });
168
+
169
+ // The view's own size, not the window's: a dragged divider changes it too.
170
+ new ResizeObserver(refit).observe(view);
171
+
172
+ document.addEventListener('keydown', function (event) {
173
+ if (event.ctrlKey || event.metaKey || event.altKey) return;
174
+ if (event.target.closest && event.target.closest('input, textarea, select, [contenteditable]')) return;
175
+ if (event.key === '+' || event.key === '=') zoomMiddle(1.25);
176
+ else if (event.key === '-' || event.key === '_') zoomMiddle(0.8);
177
+ else if (event.key === '0') fit();
178
+ else if (event.key === '1') actual();
179
+ else return;
180
+ event.preventDefault();
181
+ });
182
+
183
+ return { show: show, open: open, fit: fit, pan: pan };
184
+ }
185
+ `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reladraw",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "A diagram language where you say where things go.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  "main": "./dist/index.js",
10
10
  "types": "./dist/index.d.ts",
11
11
  "files": [
12
+ ".claude/skills",
12
13
  "dist",
13
14
  "NOTICE",
14
15
  "SYNTAX.md"