reladraw 0.27.0 → 0.28.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.
Files changed (47) hide show
  1. package/.claude/skills/reladraw/SKILL.md +7 -5
  2. package/.claude/skills/reladraw/reference/syntax.md +44 -5
  3. package/README.md +2 -2
  4. package/SYNTAX.md +44 -5
  5. package/dist/cli.js +15 -1
  6. package/dist/examples.d.ts +26 -0
  7. package/dist/examples.js +56 -0
  8. package/dist/page-data.d.ts +130 -0
  9. package/dist/page-data.js +226 -0
  10. package/dist/page.d.ts +46 -2
  11. package/dist/page.js +1239 -241
  12. package/dist/serve.d.ts +18 -0
  13. package/dist/serve.js +544 -0
  14. package/examples/arch.reladraw +95 -0
  15. package/examples/around.reladraw +101 -0
  16. package/examples/borders.reladraw +28 -0
  17. package/examples/bundles.reladraw +49 -0
  18. package/examples/circles.reladraw +37 -0
  19. package/examples/coincident.reladraw +66 -0
  20. package/examples/contents.reladraw +52 -0
  21. package/examples/corridors.reladraw +48 -0
  22. package/examples/deck.reladraw +26 -0
  23. package/examples/flowchart.reladraw +23 -0
  24. package/examples/frames.reladraw +79 -0
  25. package/examples/gap.reladraw +21 -0
  26. package/examples/gaps.reladraw +25 -0
  27. package/examples/icons.reladraw +35 -0
  28. package/examples/lanes.reladraw +54 -0
  29. package/examples/lines.reladraw +73 -0
  30. package/examples/linetexts.reladraw +48 -0
  31. package/examples/marks.reladraw +39 -0
  32. package/examples/names.reladraw +23 -0
  33. package/examples/outlines.reladraw +56 -0
  34. package/examples/overhang.reladraw +24 -0
  35. package/examples/overlays.reladraw +75 -0
  36. package/examples/parts.reladraw +31 -0
  37. package/examples/passes.reladraw +56 -0
  38. package/examples/points.reladraw +28 -0
  39. package/examples/regions.reladraw +14 -0
  40. package/examples/routing.reladraw +88 -0
  41. package/examples/shapes.reladraw +38 -0
  42. package/examples/snug.reladraw +8 -0
  43. package/examples/text.reladraw +69 -0
  44. package/examples/themes.reladraw +16 -0
  45. package/examples/urls.reladraw +35 -0
  46. package/examples/venn.reladraw +15 -0
  47. package/package.json +2 -1
package/dist/page.js CHANGED
@@ -1,11 +1,4 @@
1
- import { fileChecks, findings } from './checks.js';
2
- import { SourceError } from './errors.js';
3
- import { blame, parse } from './parser.js';
4
- import { linkLabel, linksOf } from './links.js';
5
- import { markdownHtml } from './markdown.js';
6
- import { iconSvg, renderTagged, themeOf } from './render.js';
7
- import { resolve } from './resolve.js';
8
- import { DEFAULT_THEME, luminance, THEME_NAMES, THEMES } from './themes.js';
1
+ import { dataOf, partsOf } from './page-data.js';
9
2
  import { PAN_ZOOM } from './viewer.js';
10
3
  /**
11
4
  * `reladraw render --page`: one diagram in one self-contained HTML page, made
@@ -18,227 +11,56 @@ import { PAN_ZOOM } from './viewer.js';
18
11
  * none, and the name everything saved from the page is given. The page is a
19
12
  * snapshot of the file at `at`, and says so under its title.
20
13
  */
21
- export function drawPage(source, file, options = {}, at = new Date()) {
22
- const name = file.replace(/\.[^.]+$/, '') || file;
23
- const drawn = `drawn from ${file}, ${stamp(at)}`;
24
- const run = (text) => {
25
- const layout = resolve(parse(text), options);
26
- const found = options.checks === false ? [] : findings(layout, options.checks ? options.checks(fileChecks(layout)) : fileChecks(layout));
27
- const title = layout.diagram['title'] ?? name;
28
- // The theme it was drawn in: the one --theme named, else the file's.
29
- const named = layout.diagram['theme'];
30
- const current = (options.theme && THEME_NAMES.find((one) => THEMES[one] === options.theme)) ??
31
- (named !== undefined && THEMES[named] !== undefined ? named : DEFAULT_THEME);
32
- // The same diagram drawn in every theme, for the page's theme picker. A
33
- // theme moves nothing, so only the colors differ between them. A page or
34
- // text color the file states still holds in each, as in the playground.
35
- // Guides start as the file or --guides says, and the View menu's switch
36
- // shows the drawing the other way. A diagram with no guides draws the
37
- // same both ways, so it carries one drawing.
38
- const guidesShown = options.guides ?? layout.diagram['guides'] === 'show';
39
- const guideCount = layout.nodes.filter((node) => node.guide).length +
40
- layout.edges.filter((edge) => edge.guide).length +
41
- layout.marks.filter((mark) => mark.guide).length;
42
- const drawAll = (guides) => {
43
- const themes = {};
44
- for (const one of THEME_NAMES) {
45
- const drawnIn = { ...options, guides, theme: options.theme && one === current ? options.theme : THEMES[one] };
46
- const theme = themeOf(layout, drawnIn);
47
- themes[one] = { ...renderTagged(layout, drawnIn), theme, colors: colorsOf(theme), scheme: schemeOf(theme) };
48
- }
49
- // A --theme that is not one of the named ones is drawn as given, and offered as such.
50
- if (themes[current] === undefined) {
51
- const drawnIn = { ...options, guides };
52
- const theme = themeOf(layout, drawnIn);
53
- themes[current] = { ...renderTagged(layout, drawnIn), theme, colors: colorsOf(theme), scheme: schemeOf(theme) };
54
- }
55
- return themes;
56
- };
57
- const themes = drawAll(guidesShown);
58
- const flipped = guideCount > 0 ? drawAll(!guidesShown) : null;
59
- return {
60
- html: page({
61
- title,
62
- name,
63
- drawn,
64
- source,
65
- findings: found,
66
- themes,
67
- flipped,
68
- guidesShown,
69
- guideCount,
70
- current,
71
- graph: graphOf(layout, themes),
72
- }),
73
- findings: found,
74
- };
75
- };
76
- try {
77
- return run(source);
78
- }
79
- catch (error) {
80
- throw error instanceof SourceError ? blame(source, error, run) : error;
81
- }
14
+ export function drawPage(source, file, options = {}, at = new Date(), editing) {
15
+ const parts = partsOf(source, file, options, at);
16
+ return { html: page(parts, editing ?? null), findings: parts.findings };
82
17
  }
83
- /** A description as the panel shows it: its Markdown as HTML, or nothing. */
84
- const describe = (attrs) => attrs['description'] === undefined ? {} : { description: markdownHtml(attrs['description']) };
85
- const BADGE_SIDE = 20;
86
- function graphOf(layout, themes) {
87
- const views = (links) => (links ?? []).map((link) => {
88
- const label = linkLabel(link, links);
89
- const icon = link.icon === undefined
90
- ? { letters: [...label.trim()][0]?.toUpperCase() ?? '?' }
91
- : 'picture' in link.icon
92
- ? layout.linkIcons[link.icon.picture]
93
- : link.icon;
94
- const badges = {};
95
- for (const [one, view] of Object.entries(themes))
96
- badges[one] = iconSvg(icon, BADGE_SIDE, view.theme);
97
- return { href: link.href, label, badges };
98
- });
99
- // A node with no links of its own has its container's, as a click on it in
100
- // the SVG follows its container's.
101
- const linksFor = (node) => {
102
- for (let at = node; at; at = at.parent) {
103
- const own = linksOf(at.attrs);
104
- if (own !== undefined)
105
- return own;
106
- }
107
- return undefined;
108
- };
109
- // A badge's picture is joined to its box's text by `badge:`, not by a
110
- // `join-with:` anyone wrote.
111
- const isBadge = (node) => node.joinWith === 'text' && node.attrs['join-with'] === undefined;
112
- const nodes = {};
113
- for (const node of layout.nodes) {
114
- const text = plainText(node.text);
115
- nodes[node.name] = {
116
- label: text === '' ? node.name.split('.').pop() : text,
117
- links: views(linksFor(node)),
118
- ...describe(node.attrs),
119
- box: [node.x, node.y, node.width, node.height].map((n) => Math.round(n * 10) / 10),
120
- ...(node.parent ? { parent: node.parent.name } : {}),
121
- children: node.children.filter((child) => !isBadge(child)).map((child) => child.name),
122
- styles: stylesOf(node.attrs),
123
- ...(node.guide ? { guide: true } : {}),
124
- ...(isBadge(node) ? { badge: true } : {}),
125
- ...(node.kind === 'none' && node.children.length === 0 ? { bare: true } : {}),
126
- };
127
- }
128
- const edges = layout.edges.map((edge) => {
129
- // The upstream end is the one a line runs from: the end without a mark
130
- // when only the other has one, and otherwise the end written first.
131
- const [, start = '', , end = ''] = edge.arrow.match(/^(.*?)(-{1,2})(.*)$/) ?? [];
132
- const backwards = start !== '' && end === '';
133
- const [up, down] = backwards ? [edge.to, edge.from] : [edge.from, edge.to];
134
- const text = edge.texts.map((one) => plainText(one.text)).filter((one) => one !== '').join(' · ');
135
- return {
136
- up: up.name,
137
- down: down.name,
138
- label: text,
139
- links: views(linksOf(edge.attrs)),
140
- ...describe(edge.attrs),
141
- styles: stylesOf(edge.attrs),
142
- ...(edge.guide ? { guide: true } : {}),
143
- };
144
- });
145
- return { nodes, edges };
146
- }
147
- /** The styles a `style:` names; the parser joins them on a space. */
148
- const stylesOf = (attrs) => (attrs['style'] === undefined ? [] : attrs['style'].split(' '));
149
- /** A text as a reader sees it: markup such as `[dim]` taken out, its lines run together. */
150
- function plainText(text) {
151
- return text.replace(/\[\/?[a-z][a-z0-9-]*\]/gi, '').replace(/\s*\n\s*/g, ' ').trim();
152
- }
153
- /** A local time as `2026-11-05 14:32`, which sorts and reads the same everywhere. */
154
- function stamp(at) {
155
- const two = (n) => String(n).padStart(2, '0');
156
- return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
157
- }
158
- const hex = (color) => /^#[0-9a-f]{6}$/i.test(color);
159
18
  /**
160
- * What may differ between one theme's drawing and another's: colors,
161
- * opacities, and the ids made from a drawing's hash and an arrowhead's color.
162
- * Split on, its matches are the odd pieces. Matching too much costs nothing;
163
- * a drawing whose other pieces differ is carried whole.
19
+ * The page with no file: the playground. It opens on the first example and
20
+ * draws whatever its visitor last had in that browser, with the source shown.
164
21
  */
165
- const VARIES = /(#[0-9a-f]{3,8}\b|rgba?\([^)]*\)|opacity="[^"]*"|opacity:[^;"]*|\br[0-9a-f]{8}\b|\b[0-9a-f]{6}\b)/gi;
166
- // The browser's own controls (scroll bars, focus rings) follow the page, so
167
- // a dark diagram doesn't get a light one's. A color written by hand in a
168
- // form that can't be measured leaves the choice to the browser.
169
- function schemeOf(theme) {
170
- return hex(theme.text) && hex(theme.background)
171
- ? luminance(theme.text) > luminance(theme.background)
172
- ? 'dark'
173
- : 'light'
174
- : 'light dark';
175
- }
176
- function colorsOf(theme) {
177
- return {
178
- '--page': theme.background,
179
- '--ink': theme.text,
180
- '--muted': theme.mutedText,
181
- '--rule': theme.boxStroke,
182
- '--accent': theme.primary.color,
183
- '--on-accent': theme.primary.on,
184
- };
22
+ export function playgroundPage(playground) {
23
+ const { examples, folder } = playground;
24
+ const parts = partsOf(examples[0]?.source ?? '', 'diagram.reladraw', {}, new Date());
25
+ const editing = folder !== undefined ? { kind: 'local', folder, examples } : { kind: 'hosted', examples };
26
+ return page(parts, editing, {
27
+ title: folder !== undefined ? `reladraw · ${folder}` : 'reladraw playground: write diagrams in the browser',
28
+ heading: 'reladraw',
29
+ drawn: folder !== undefined ? `local playground · ${folder} · not saved to a file` : 'a diagram language where you say where things go',
30
+ head: playground.head ?? '',
31
+ library: playground.library ?? '',
32
+ });
185
33
  }
186
- function page({ title, name, drawn, source, findings, themes, flipped, guidesShown, guideCount, current, graph }) {
34
+ function page(parts, editing, framing) {
35
+ const { themes, current } = parts;
187
36
  const shown = themes[current];
188
- // The theme list: first the diagram's own theme, which follows the file;
189
- // then every theme as the reader's choice, dark ones together and light
190
- // together, as the playground's picker has them, so stepping through the
191
- // list doesn't flash between black and white.
192
- const group = (light) => Object.keys(themes).filter((one) => (schemeOf(themes[one].theme) === 'light') === light);
193
- // The shown theme goes in whole; another is only the colors that differ
194
- // from it, when the rest of the drawing is the same, as it is since a theme
195
- // moves nothing. That keeps the page near its size with one theme.
196
- const pack = (other, base) => {
197
- const theirs = other.split(VARIES);
198
- const ours = base.split(VARIES);
199
- const same = theirs.length === ours.length && theirs.every((piece, i) => i % 2 === 1 || piece === ours[i]);
200
- return same ? theirs.filter((_, i) => i % 2 === 1) : other;
37
+ const data = { ...dataOf(parts), edit: editing };
38
+ const title = framing?.title ?? parts.title;
39
+ const noFile = editing !== null && editing.kind !== 'file';
40
+ const filled = {
41
+ panzoom: PAN_ZOOM,
42
+ scheme: shown.scheme,
43
+ title: escapeMarkup(title),
44
+ head: framing?.head ?? '',
45
+ heading: escapeMarkup(framing?.heading ?? parts.title),
46
+ drawn: escapeMarkup(framing?.drawn ?? parts.drawn),
47
+ links: noFile ? LINKS : '',
48
+ colors: [
49
+ ...Object.entries(shown.colors).map(([key, value]) => `${key}: ${value};`),
50
+ '--sel: #e0b25a; --up: #22b98c; --down: #e5484d;',
51
+ ].join(' '),
52
+ cheatsheet: editing ? CHEATSHEET : '',
53
+ dialogs: noFile ? DIALOGS : '',
54
+ drawing: shown.tagged,
55
+ // A literal </script> in the library's text would end its tag early.
56
+ library: framing?.library ? `<script>\n${framing.library.replace(/<\/script/gi, '<\\/script')}\n</script>` : '',
57
+ data: scriptJson(data),
201
58
  };
202
- // Each set of drawings, with guides one way, packed against its own drawing
203
- // in the page's first theme: guides add things, so the two sets differ in
204
- // more than colors and can't be packed against each other.
205
- const packAll = (set) => {
206
- const base = set[current];
207
- return Object.fromEntries(Object.entries(set).map(([one, view]) => [
208
- one,
209
- one === current
210
- ? { svg: view.svg, tagged: view.tagged, colors: view.colors, scheme: view.scheme }
211
- : { svg: pack(view.svg, base.svg), tagged: pack(view.tagged, base.tagged), colors: view.colors, scheme: view.scheme },
212
- ]));
213
- };
214
- const data = {
215
- name,
216
- source,
217
- current,
218
- varies: VARIES.source,
219
- themeGroups: { dark: group(false), light: group(true) },
220
- themes: packAll(themes),
221
- flipped: flipped && packAll(flipped),
222
- guidesShown,
223
- guideCount,
224
- findings: findings.map((finding) => ({ kind: finding.kind, message: finding.message, line: finding.line })),
225
- graph,
226
- };
227
- // Replaced by functions, so a `$` in a title or a drawing is taken as written.
228
- // The zoom's code goes in first, so nothing a diagram says can be taken for its marker.
229
- return PAGE.replace('{{panzoom}}', () => PAN_ZOOM)
230
- .replace('{{scheme}}', () => shown.scheme)
231
- .replace('{{title}}', () => escapeHtml(title))
232
- .replace('{{heading}}', () => escapeHtml(title))
233
- .replace('{{drawn}}', () => escapeHtml(drawn))
234
- .replace('{{colors}}', () => [
235
- ...Object.entries(shown.colors).map(([key, value]) => `${key}: ${value};`),
236
- '--sel: #e0b25a; --up: #22b98c; --down: #e5484d;',
237
- ].join(' '))
238
- .replace('{{drawing}}', () => shown.tagged)
239
- .replace('{{data}}', () => scriptJson(data));
59
+ // One pass, so nothing put in (a title, a drawing, the library) is read
60
+ // again as a marker, and a function, so a `$` in it is taken as written.
61
+ return PAGE.replace(/\{\{([a-z]+)\}\}/g, (marker, key) => filled[key] ?? marker);
240
62
  }
241
- function escapeHtml(text) {
63
+ function escapeMarkup(text) {
242
64
  // `{` too, so a title or file name can't be taken for a later marker.
243
65
  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/\{/g, '&#123;');
244
66
  }
@@ -255,6 +77,7 @@ const PAGE = `<!doctype html>
255
77
  <meta name="color-scheme" content="{{scheme}}">
256
78
  <meta name="generator" content="reladraw">
257
79
  <title>{{title}}</title>
80
+ {{head}}
258
81
  <style>
259
82
  :root { {{colors}} }
260
83
  * { box-sizing: border-box; }
@@ -455,10 +278,136 @@ const PAGE = `<!doctype html>
455
278
  }
456
279
  aside li button.chosen { border-color: var(--accent); }
457
280
  aside li.none { padding: 6px 8px; color: var(--muted); font-size: 13px; }
281
+ aside li.error { padding: 6px 8px; margin: 2px 0; border-left: 3px solid var(--down); font-size: 13px; }
282
+ aside li.error .kind { color: var(--down); }
283
+ #live {
284
+ padding: 5px 16px; font-size: 12.5px;
285
+ background: color-mix(in srgb, var(--down) 18%, var(--page)); color: var(--ink);
286
+ border-bottom: 1px solid color-mix(in srgb, var(--down) 45%, transparent);
287
+ }
288
+ #live[hidden] { display: none; }
289
+
290
+ /* The editor: a colored layer under a transparent textarea, every property
291
+ that can move a character the same on both, as in the playground. */
292
+ #editor {
293
+ width: 38%; min-width: 280px; max-width: 760px; display: flex; flex-direction: column;
294
+ border-right: 1px solid color-mix(in srgb, var(--rule) 45%, transparent);
295
+ background: color-mix(in srgb, var(--ink) 4%, var(--page));
296
+ }
297
+ #editor[hidden] { display: none; }
298
+ .src-head { display: flex; align-items: center; gap: 6px; padding: 6px 10px; font-size: 12.5px;
299
+ border-bottom: 1px solid color-mix(in srgb, var(--rule) 45%, transparent); }
300
+ .src-head .file { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
301
+ #unsaved { color: var(--sel); }
302
+ #unsaved[hidden] { display: none; }
303
+ .src-head .gap, #changed .gap { flex: 1; }
304
+ .src-head kbd { font: 11px system-ui, sans-serif; color: var(--muted); margin-left: 4px; }
305
+ #changed { display: flex; align-items: center; gap: 6px; padding: 6px 10px; font-size: 12.5px;
306
+ background: color-mix(in srgb, var(--sel) 18%, var(--page));
307
+ border-bottom: 1px solid color-mix(in srgb, var(--sel) 45%, transparent); }
308
+ #changed[hidden] { display: none; }
309
+ .code { position: relative; flex: 1; min-height: 0; }
310
+ .code > * {
311
+ position: absolute; inset: 0; margin: 0; border: 0; padding: 12px 14px;
312
+ font: 12.5px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, 'DejaVu Sans Mono', monospace;
313
+ tab-size: 2; white-space: pre-wrap; overflow-wrap: break-word; word-break: normal;
314
+ }
315
+ #paint { overflow: hidden; color: var(--ink); pointer-events: none; }
316
+ #source { resize: none; outline: 0; background: transparent; color: transparent; caret-color: var(--ink); overflow-y: auto; }
317
+ #source::selection { background: rgba(130, 170, 240, 0.28); color: transparent; }
318
+ :root { --syn-comment: #6a6a78; --syn-string: #9ec99a; --syn-keyword: #82aaf0; --syn-name: #b8c9e8;
319
+ --syn-relation: #c79bdc; --syn-attribute: #6fb3ac; --syn-value: #d3a05f; }
320
+ :root[data-scheme="light"] { --syn-comment: #8a8a96; --syn-string: #3f7a3a; --syn-keyword: #2f5fb8; --syn-name: #34495e;
321
+ --syn-relation: #8a3fa8; --syn-attribute: #217a72; --syn-value: #a0601a; }
322
+ .t-comment, .t-bracket, .t-markup { color: var(--syn-comment); }
323
+ .t-string { color: var(--syn-string); }
324
+ .t-keyword { color: var(--syn-keyword); }
325
+ .t-name { color: var(--syn-name); }
326
+ .t-relation, .t-arrow { color: var(--syn-relation); }
327
+ .t-attribute { color: var(--syn-attribute); }
328
+ .t-value, .t-color { color: var(--syn-value); }
329
+ #edit[aria-pressed="true"] { border-color: var(--sel); color: var(--sel); }
330
+ .menu-list.wide { min-width: 340px; }
331
+ .menu-list .row .when { color: var(--muted); font-variant-numeric: tabular-nums; width: 3.2em; flex: none; }
332
+ .menu-list .row .who { width: 4.5em; flex: none; }
333
+ .menu-list .row .who.you { color: #8fb3ff; }
334
+ .menu-list .row .who.agent { color: var(--up); }
335
+ .menu-list .row .who.disk, .menu-list .row .who.opened { color: var(--muted); }
336
+ .menu-list .row .about { flex: 1; text-align: left; white-space: normal; }
458
337
  .where { color: var(--muted); font-variant-numeric: tabular-nums; }
459
338
  .kind { color: var(--accent); }
460
339
  .note { display: block; color: var(--muted); font-size: 12px; margin-top: 2px; }
340
+ aside li.error button.copy { float: right; display: inline-block; width: auto; margin: 0 0 0 8px; padding: 0 6px; font-size: 11px;
341
+ border-color: color-mix(in srgb, var(--rule) 70%, transparent); }
342
+ aside li.error .goto { cursor: pointer; text-decoration: underline dotted; }
343
+
344
+ /* ---- a page with no file: the playground ---- */
345
+ .links { display: inline-flex; gap: 12px; margin-left: 10px; font-size: 12px; }
346
+ .heading .drawn { display: inline; }
347
+ .links a { color: var(--muted); text-decoration: none; border-bottom: 1px solid color-mix(in srgb, var(--rule) 60%, transparent); }
348
+ .links a:hover { color: var(--ink); }
349
+ /* The divider between the source and the picture: dragged, or moved with the arrows. */
350
+ #split { flex: none; width: 6px; margin: 0 -3px; z-index: 2; cursor: col-resize; touch-action: none; }
351
+ #split[hidden] { display: none; }
352
+ #split:hover, #split.dragging, #split:focus-visible { background: color-mix(in srgb, var(--accent) 45%, transparent); outline: 0; }
353
+ body.dragging { cursor: col-resize; user-select: none; }
354
+ /* The cheatsheet. Closed it is one line; open it takes height off the
355
+ source rather than off the diagram, since it answers what to type. */
356
+ #cheatsheet { flex: none; max-height: 46%; overflow: auto; border-top: 1px solid color-mix(in srgb, var(--rule) 45%, transparent); }
357
+ #cheatsheet summary { padding: 7px 12px; cursor: pointer; color: var(--muted); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; user-select: none; }
358
+ #cheatsheet summary:hover { color: var(--ink); }
359
+ #cheatsheet dl { margin: 0; padding: 0 12px 14px; display: grid; grid-template-columns: max-content 1fr; gap: 4px 12px; font-size: 12px; }
360
+ #cheatsheet dt { color: var(--muted); white-space: nowrap; }
361
+ #cheatsheet dd { margin: 0; line-height: 1.7; }
362
+ #cheatsheet code { font: 11.5px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; display: inline-block;
363
+ padding: 1px 5px; margin-right: 3px; border-radius: 3px; background: color-mix(in srgb, var(--ink) 9%, transparent); }
364
+ #cheatsheet b { color: var(--syn-relation); font-weight: 400; }
365
+ #cheatsheet i { color: var(--muted); font-style: normal; }
366
+ dialog { padding: 0; color: var(--ink); background: var(--page); overflow: hidden;
367
+ border: 1px solid color-mix(in srgb, var(--rule) 70%, transparent); border-radius: 6px; }
368
+ dialog::backdrop { background: rgba(0, 0, 0, .6); }
369
+ dialog .bar { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 16px;
370
+ border-bottom: 1px solid color-mix(in srgb, var(--rule) 45%, transparent); }
371
+ dialog h2 { margin: 0; font-size: 13px; font-weight: 600; }
372
+ dialog footer { display: flex; align-items: center; gap: 8px; padding: 8px 16px; font-size: 12px; color: var(--muted);
373
+ border-top: 1px solid color-mix(in srgb, var(--rule) 45%, transparent); }
374
+ dialog footer .gap { flex: 1; }
375
+ #examples { width: min(1100px, calc(100vw - 32px)); height: min(720px, calc(100vh - 32px)); max-width: none; max-height: none; }
376
+ #examples[open] { display: grid; grid-template-columns: 220px 1fr; grid-template-rows: auto auto minmax(0, 1fr) auto; }
377
+ #examples .bar, #examples footer { grid-column: 1 / -1; }
378
+ #catalog { grid-row: 2 / 4; margin: 0; padding: 6px 0; list-style: none; overflow-y: auto; outline: 0;
379
+ border-right: 1px solid color-mix(in srgb, var(--rule) 45%, transparent); }
380
+ #catalog li { padding: 6px 16px; font-size: 13px; cursor: pointer; border-left: 2px solid transparent; }
381
+ #catalog li:hover { background: color-mix(in srgb, var(--ink) 8%, transparent); }
382
+ #catalog li[aria-selected="true"] { background: color-mix(in srgb, var(--ink) 13%, transparent); border-left-color: var(--accent); }
383
+ #catalog:focus-visible li[aria-selected="true"] { outline: 1px solid var(--muted); outline-offset: -1px; }
384
+ #about { margin: 0; padding: 10px 16px; font-size: 13px; border-bottom: 1px solid color-mix(in srgb, var(--rule) 45%, transparent); }
385
+ #preview { min-width: 0; min-height: 0; overflow: auto; padding: 16px; display: flex; }
386
+ #preview svg { max-width: 100%; max-height: 100%; height: auto; margin: auto; }
387
+ #format-settings, #save-as { width: min(640px, calc(100vw - 32px)); max-height: calc(100vh - 32px); }
388
+ #format-settings[open], #save-as[open] { display: flex; flex-direction: column; }
389
+ dialog .about { margin: 0; padding: 10px 16px; font-size: 12px; color: var(--muted); }
390
+ #format-fields { overflow-y: auto; padding: 4px 16px; }
391
+ #format-fields label { display: grid; grid-template-columns: 190px 1fr; gap: 2px 12px; align-items: center; padding: 8px 0; font-size: 13px;
392
+ border-bottom: 1px solid color-mix(in srgb, var(--rule) 30%, transparent); }
393
+ #format-fields label span { grid-column: 2; color: var(--muted); font-size: 11px; }
394
+ #format-fields select, #format-fields input, #save-as input { font: 12px ui-monospace, Menlo, Consolas, monospace; color: var(--ink); background: var(--page);
395
+ border: 1px solid color-mix(in srgb, var(--rule) 70%, transparent); border-radius: 4px; padding: 4px 6px; min-width: 0; }
396
+ #save-as label { display: flex; align-items: center; gap: 8px; padding: 0 16px 12px; font-size: 13px; }
397
+ #save-as input { flex: 1; font-size: 13px; }
398
+ #format-problem, #save-problem { margin: 0; padding: 0 16px; color: var(--down); font-size: 12px; }
399
+ #format-problem:not(:empty), #save-problem:not(:empty) { padding: 8px 16px; }
400
+ @media (max-width: 760px) {
401
+ #examples[open] { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 2fr) auto minmax(0, 3fr) auto; }
402
+ #catalog { grid-row: auto; border-right: 0; border-bottom: 1px solid color-mix(in srgb, var(--rule) 45%, transparent); }
403
+ #format-fields label { grid-template-columns: 1fr; }
404
+ #format-fields label span { grid-column: 1; }
405
+ dialog footer .keys { display: none; }
406
+ }
461
407
  @media (max-width: 700px) {
408
+ #split { display: none; }
409
+ #editor { width: auto !important; max-width: none; min-width: 0; height: 45%; border-right: 0;
410
+ border-bottom: 1px solid color-mix(in srgb, var(--rule) 45%, transparent); }
462
411
  main { flex-direction: column; }
463
412
  #side { width: auto; max-height: 45%; border-left: 0; border-top: 1px solid color-mix(in srgb, var(--rule) 45%, transparent); }
464
413
  }
@@ -468,9 +417,12 @@ const PAGE = `<!doctype html>
468
417
  <header>
469
418
  <div class="heading">
470
419
  <h1>{{heading}}</h1>
471
- <p class="drawn">{{drawn}}</p>
420
+ <p class="drawn">{{drawn}}</p>{{links}}
472
421
  </div>
473
422
  <div class="tools">
423
+ <button id="browse" type="button" title="Open one of the examples in the editor" aria-haspopup="dialog" hidden>Examples</button>
424
+ <button id="edit"type="button" title="Show or hide the source (e)" aria-pressed="false" hidden>Edit<kbd class="tag">e</kbd></button>
425
+ <span class="gap" id="edit-gap" hidden></span>
474
426
  <button id="out" type="button" title="Zoom out (-)" aria-label="Zoom out">−</button>
475
427
  <span id="scale" aria-live="polite">100%</span>
476
428
  <button id="in" type="button" title="Zoom in (+)" aria-label="Zoom in">+</button>
@@ -478,8 +430,12 @@ const PAGE = `<!doctype html>
478
430
  <button id="actual" type="button" title="The drawing at its own scale (1)">100%</button>
479
431
  <span class="gap"></span>
480
432
  <button id="full" type="button" title="Full screen (f)"><span class="label">Full screen</span><kbd class="tag">f</kbd></button>
433
+ <span class="menu" id="versions-menu" hidden>
434
+ <button id="versions-button" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="versions-list">Versions ▾<kbd class="tag">o</kbd></button>
435
+ <span id="versions-list" class="menu-list wide" role="menu" hidden></span>
436
+ </span>
481
437
  <span class="menu">
482
- <button id="save" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="save-list">Save ▾<kbd class="tag">s</kbd></button>
438
+ <button id="save" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="save-list">Export ▾<kbd class="tag">x</kbd></button>
483
439
  <span id="save-list" class="menu-list" role="menu" hidden>
484
440
  <button id="save-page" class="row" type="button" role="menuitem" data-key="p" title="Save this page as one HTML file, to keep or send"><span class="name">This page</span><kbd>p</kbd></button>
485
441
  <button id="save-source" class="row" type="button" role="menuitem" data-key="r" title="Save the .reladraw file this page was drawn from"><span class="name">.reladraw source</span><kbd>r</kbd></button>
@@ -506,7 +462,24 @@ const PAGE = `<!doctype html>
506
462
  </div>
507
463
  </header>
508
464
  <div id="strip" hidden><span class="crumbs"></span><span class="hint"></span></div>
465
+ <div id="live" role="status" hidden></div>
509
466
  <main>
467
+ <section id="editor" aria-label="Source" hidden>
468
+ <div id="changed" role="alert" hidden><span class="what"></span><span class="gap"></span><button id="load-disk" type="button">Load it</button><button id="keep-mine" type="button">Keep mine</button></div>
469
+ <div class="src-head">
470
+ <span class="file"></span><span id="unsaved" title="Not saved to the file" hidden>●</span><span class="gap"></span>
471
+ <button id="open-file" type="button" title="Open a .reladraw file into the source" hidden>Open…</button>
472
+ <input id="file-input" type="file" accept=".reladraw,text/plain" hidden>
473
+ <button id="format" type="button" title="Lay the source out again, as reladraw format does, with the nearest .reladraw-format (Shift+Alt+F)"><span class="name">Format</span></button>
474
+ <button id="format-open" type="button" aria-haspopup="dialog" title="How Format lays the source out" hidden>Format settings…</button>
475
+ <button id="write" type="button" title="Write the source to the file (⌘S or Ctrl+S)"><span class="name">Save</span><kbd>⌘S</kbd></button>
476
+ </div>
477
+ <div class="code">
478
+ <pre id="paint" aria-hidden="true"></pre>
479
+ <textarea id="source" spellcheck="false" autocapitalize="off" autocomplete="off" aria-label="Source"></textarea>
480
+ </div>{{cheatsheet}}
481
+ </section>
482
+ <div id="split" role="separator" aria-orientation="vertical" aria-label="Resize the source and the diagram" tabindex="0" title="Drag to resize; double-click to reset" hidden></div>
510
483
  <div id="view">
511
484
  <div id="stage">
512
485
  {{drawing}}
@@ -522,13 +495,18 @@ const PAGE = `<!doctype html>
522
495
  <ol id="finding-list"></ol>
523
496
  </aside>
524
497
  </div>
525
- </main>
498
+ </main>{{dialogs}}
499
+ {{library}}
526
500
  <script>
527
501
  (function () {
502
+ var DATA = {{data}};
503
+ // How the source is edited, or null on a snapshot, which has no editor.
504
+ var EDIT = DATA.edit;
528
505
  // The page as written, taken before anything below changes it, so This
529
506
  // page saves what reladraw wrote rather than the current zoom and highlights.
530
- var written = '<!doctype html>\\n' + document.documentElement.outerHTML + '\\n';
531
- var DATA = {{data}};
507
+ // A page with an editor makes a fresh snapshot instead (below).
508
+ var written = EDIT ? '' : '<!doctype html>\\n' + document.documentElement.outerHTML + '\\n';
509
+ document.documentElement.setAttribute('data-scheme', DATA.themes[DATA.current].scheme === 'light' ? 'light' : 'dark');
532
510
  var view = document.getElementById('view');
533
511
  var stage = document.getElementById('stage');
534
512
  var svg = stage.querySelector('svg');
@@ -638,23 +616,30 @@ const PAGE = `<!doctype html>
638
616
  return Promise.resolve(blob);
639
617
  }
640
618
 
641
- // Every way out of the page is under one Save button: the page, its
619
+ // Every way out of the page is under one Export button: the page, its
642
620
  // source, and the picture. The list stays open after a choice, so "Copied"
643
- // can be read. It is one of the menus (below), opened by a click or by m s.
621
+ // can be read. It is one of the menus (below), opened by a click or by m x.
622
+ // Save is the served page's writing of the file, so this is Export everywhere.
644
623
  var saveButton = document.getElementById('save');
645
624
  var saveList = document.getElementById('save-list');
646
625
 
647
626
  var savePage = document.getElementById('save-page');
648
627
  savePage.onclick = function () {
649
628
  saveFile(savePage, DATA.name + '.html', 'Web page', 'text/html', function () {
650
- return ready(new Blob([written], { type: 'text/html' }));
629
+ if (!EDIT) return ready(new Blob([written], { type: 'text/html' }));
630
+ // A page with an editor saves a snapshot of the diagram as last drawn,
631
+ // as render --page writes it: this page itself would be the editor,
632
+ // still looking for its server or holding the whole library.
633
+ return libReady.then(function (lib) {
634
+ return new Blob([lib.drawPage(DATA.source, DATA.name + '.reladraw').html], { type: 'text/html' });
635
+ });
651
636
  });
652
637
  };
653
638
 
654
639
  var saveSource = document.getElementById('save-source');
655
640
  saveSource.onclick = function () {
656
641
  saveFile(saveSource, DATA.name + '.reladraw', 'reladraw source', 'text/plain', function () {
657
- return ready(new Blob([DATA.source], { type: 'text/plain' }));
642
+ return ready(new Blob([EDIT ? sourceBox.value : DATA.source], { type: 'text/plain' }));
658
643
  });
659
644
  };
660
645
 
@@ -809,7 +794,6 @@ const PAGE = `<!doctype html>
809
794
  }
810
795
  findingsRow.onclick = flipFindings;
811
796
  if (count === 0) {
812
- list.append(element('li', 'none', 'No findings.'));
813
797
  panel.hidden = true;
814
798
  } else {
815
799
  // Open from the start, unless the panel is to stay out of the way.
@@ -850,7 +834,41 @@ const PAGE = `<!doctype html>
850
834
  zoom.pan((room.left + room.width / 2) - (r.l + r.r) / 2, (room.top + room.height / 2) - (r.t + r.b) / 2);
851
835
  }
852
836
 
853
- DATA.findings.forEach(function (finding) {
837
+ // The list, made again when a served page redraws. An error the file has
838
+ // stopped parsing with heads it, over the findings of the last drawing that
839
+ // parsed, which is still the one shown.
840
+ function listFindings(error) {
841
+ list.textContent = '';
842
+ count = DATA.findings.length + (error ? 1 : 0);
843
+ findingsRow.querySelector('.name').textContent = 'Findings (' + count + ')';
844
+ guidesRow.querySelector('.name').textContent = 'Guides (' + DATA.guideCount + ')';
845
+ if (error) {
846
+ var wrong = element('li', 'error');
847
+ var head = element('span', 'where', error.line ? 'line ' + error.line + ' ' : '');
848
+ if (EDIT) {
849
+ if (error.line) {
850
+ head.classList.add('goto');
851
+ head.title = 'Select the line in the source';
852
+ head.onclick = function () { goToLine(error.line); };
853
+ }
854
+ // For pasting into an issue, or a chat with an agent.
855
+ var copy = element('button', 'copy', 'Copy');
856
+ copy.type = 'button';
857
+ copy.title = 'Copy the error';
858
+ copy.onclick = function () {
859
+ toClipboard((error.line ? 'line ' + error.line + ': ' : '') + error.message)
860
+ .then(function () { flash(copy, 'Copied'); }, function () { flash(copy, 'Blocked'); });
861
+ };
862
+ wrong.append(copy);
863
+ }
864
+ wrong.append(head, element('span', 'kind', 'error'), document.createElement('br'),
865
+ document.createTextNode(error.message), element('span', 'note', 'The picture is the last version that drew.'));
866
+ list.append(wrong);
867
+ }
868
+ if (count === 0) list.append(element('li', 'none', 'No findings.'));
869
+ DATA.findings.forEach(addFinding);
870
+ }
871
+ function addFinding(finding) {
854
872
  var item = document.createElement('li');
855
873
  var button = document.createElement('button');
856
874
  button.type = 'button';
@@ -867,10 +885,15 @@ const PAGE = `<!doctype html>
867
885
  note.textContent = 'Nothing drawn comes from that line.';
868
886
  button.append(note);
869
887
  }
870
- button.onclick = function () { light(finding, button); };
888
+ // With the source shown, the line it names is selected there too.
889
+ button.onclick = function () {
890
+ light(finding, button);
891
+ if (EDIT && !editorPane.hidden) goToLine(finding.line);
892
+ };
871
893
  item.append(button);
872
894
  list.append(item);
873
- });
895
+ }
896
+ listFindings(null);
874
897
 
875
898
  /* ---- selecting a box or a line ---- */
876
899
 
@@ -1081,6 +1104,7 @@ const PAGE = `<!doctype html>
1081
1104
  var next = shown();
1082
1105
  Object.keys(next.colors).forEach(function (key) { document.documentElement.style.setProperty(key, next.colors[key]); });
1083
1106
  schemeMeta.setAttribute('content', next.scheme);
1107
+ document.documentElement.setAttribute('data-scheme', next.scheme === 'light' ? 'light' : 'dark');
1084
1108
  unlight();
1085
1109
  hovered = null;
1086
1110
  tip.style.display = 'none';
@@ -1180,6 +1204,8 @@ const PAGE = `<!doctype html>
1180
1204
  var tools = document.querySelector('.tools');
1181
1205
  var viewButton = document.getElementById('view-button');
1182
1206
  var viewList = document.getElementById('view-list');
1207
+ var versionsButton = document.getElementById('versions-button');
1208
+ var versionsList = document.getElementById('versions-list');
1183
1209
  var strip = document.getElementById('strip');
1184
1210
  var level = null;
1185
1211
  var cursor = 0;
@@ -1188,13 +1214,15 @@ const PAGE = `<!doctype html>
1188
1214
  var STRIP = {
1189
1215
  bar: ['<b>Menu</b>', 'letter to open \\u00B7 \\u232B back \\u00B7 Esc out'],
1190
1216
  view: ['Menu \\u203A <b>View</b>', 'letter to flip \\u00B7 \\u2191\\u2193 Enter \\u00B7 \\u232B back \\u00B7 Esc out'],
1191
- save: ['Menu \\u203A <b>Save</b>', 'letter to save \\u00B7 \\u2191\\u2193 Enter \\u00B7 \\u232B back \\u00B7 Esc out'],
1217
+ save: ['Menu \\u203A <b>Export</b>', 'letter to save \\u00B7 \\u2191\\u2193 Enter \\u00B7 \\u232B back \\u00B7 Esc out'],
1218
+ versions: ['Menu \\u203A <b>Versions</b>', '\\u2191\\u2193 Enter to put one in the editor \\u00B7 \\u232B back \\u00B7 Esc out'],
1192
1219
  theme: ['Menu \\u203A View \\u203A <b>Theme</b>', '\\u2191\\u2193 to try \\u00B7 Enter keep \\u00B7 \\u232B back \\u00B7 Esc out'],
1193
1220
  };
1194
1221
 
1195
1222
  function rows() {
1196
1223
  if (level === 'view') return Array.prototype.slice.call(viewList.querySelectorAll(':scope > .row'));
1197
1224
  if (level === 'save') return Array.prototype.slice.call(saveList.querySelectorAll(':scope > .row'));
1225
+ if (level === 'versions') return Array.prototype.slice.call(versionsList.querySelectorAll(':scope > .row'));
1198
1226
  if (level === 'theme') return themeItems.map(function (item) { return item.row; });
1199
1227
  return [];
1200
1228
  }
@@ -1225,6 +1253,9 @@ const PAGE = `<!doctype html>
1225
1253
  saveList.hidden = level !== 'save';
1226
1254
  saveButton.classList.toggle('open', level === 'save');
1227
1255
  saveButton.setAttribute('aria-expanded', String(level === 'save'));
1256
+ versionsList.hidden = level !== 'versions';
1257
+ versionsButton.classList.toggle('open', level === 'versions');
1258
+ versionsButton.setAttribute('aria-expanded', String(level === 'versions'));
1228
1259
  viewList.hidden = level !== 'view' && level !== 'theme';
1229
1260
  viewButton.classList.toggle('open', level === 'view' || level === 'theme');
1230
1261
  viewButton.setAttribute('aria-expanded', String(level === 'view' || level === 'theme'));
@@ -1241,7 +1272,7 @@ const PAGE = `<!doctype html>
1241
1272
 
1242
1273
  function back() {
1243
1274
  if (level === 'theme') setLevel('view');
1244
- else if (level === 'view' || level === 'save') setLevel('bar');
1275
+ else if (level === 'view' || level === 'save' || level === 'versions') setLevel('bar');
1245
1276
  else setLevel(null);
1246
1277
  }
1247
1278
 
@@ -1409,9 +1440,15 @@ const PAGE = `<!doctype html>
1409
1440
  return all.slice(0, count);
1410
1441
  }
1411
1442
 
1443
+ // The styles the diagram uses, which a filter's style: names; made again
1444
+ // when an edited page redraws.
1412
1445
  var styleNames = {};
1413
- Object.keys(graph.nodes).forEach(function (name) { graph.nodes[name].styles.forEach(function (one) { styleNames[one] = true; }); });
1414
- graph.edges.forEach(function (edge) { edge.styles.forEach(function (one) { styleNames[one] = true; }); });
1446
+ function collectStyles() {
1447
+ styleNames = {};
1448
+ Object.keys(graph.nodes).forEach(function (name) { graph.nodes[name].styles.forEach(function (one) { styleNames[one] = true; }); });
1449
+ graph.edges.forEach(function (edge) { edge.styles.forEach(function (one) { styleNames[one] = true; }); });
1450
+ }
1451
+ collectStyles();
1415
1452
 
1416
1453
  // The filter: every word must match. Returns the matches, and a message
1417
1454
  // when a word can match nothing at all, naming what it accepts.
@@ -1655,6 +1692,7 @@ const PAGE = `<!doctype html>
1655
1692
  }
1656
1693
 
1657
1694
  saveButton.onclick = function () { setLevel(level === 'save' ? null : 'save'); };
1695
+ versionsButton.onclick = function () { setLevel(level === 'versions' ? null : 'versions'); };
1658
1696
  viewButton.onclick = function () { setLevel(level === 'view' || level === 'theme' ? null : 'view'); };
1659
1697
  themeRow.onclick = function () { setLevel(level === 'theme' ? 'view' : 'theme'); };
1660
1698
  document.addEventListener('click', function (event) {
@@ -1689,6 +1727,7 @@ const PAGE = `<!doctype html>
1689
1727
  if (!hintKey(event)) return;
1690
1728
  } else if (level === null) {
1691
1729
  if (key === 'm' || key === 'M') setLevel('bar');
1730
+ else if (key === 'e' && EDIT) toggleEditor();
1692
1731
  else if (key === 's') startHints('all');
1693
1732
  else if (key === '/') startHints('all', true);
1694
1733
  else if (key === 'n') startHints('neighbors');
@@ -1711,9 +1750,11 @@ const PAGE = `<!doctype html>
1711
1750
  var letter = key.toLowerCase();
1712
1751
  if (level === 'bar') {
1713
1752
  if (letter === 'v') setLevel('view');
1714
- else if (letter === 's') setLevel('save');
1753
+ else if (letter === 'x') setLevel('save');
1754
+ else if (letter === 'o' && tiedFile()) setLevel('versions');
1755
+ else if (letter === 'e' && EDIT) { setLevel(null); toggleEditor(); }
1715
1756
  else if (letter === 'f' && !full.hidden) { setLevel(null); toggleFull(); }
1716
- } else if (level === 'view' || level === 'save') {
1757
+ } else if (level === 'view' || level === 'save' || level === 'versions') {
1717
1758
  rows().forEach(function (row, i) {
1718
1759
  if (row.getAttribute('data-key') !== letter) return;
1719
1760
  cursor = i;
@@ -1737,6 +1778,793 @@ const PAGE = `<!doctype html>
1737
1778
  event.preventDefault();
1738
1779
  });
1739
1780
 
1781
+ /* ---- editing: a page tied to a file, and a page with no file ---- */
1782
+
1783
+ // A page with an editor draws in the browser: with the library the server
1784
+ // that served it also serves, or, on the hosted playground, with the
1785
+ // library inlined in the page. Each drawing keeps the view, the theme and
1786
+ // whatever is still there to be selected. A text that doesn't draw leaves
1787
+ // the last picture that did, with the error at the head of the findings.
1788
+ //
1789
+ // Tied to a file, the page follows it: each time the file changes on disk,
1790
+ // the server sends its text, and Save writes it. With no file, the text is
1791
+ // kept in this browser, the examples and Open are offered, and Save
1792
+ // downloads it; served with no file, Save names a file in the folder, and
1793
+ // from then on the page is tied to that file as if it had been opened with it.
1794
+ var heading = document.querySelector('header h1');
1795
+ var drawnLine = document.querySelector('header .drawn');
1796
+ var liveNote = document.getElementById('live');
1797
+
1798
+ function tiedFile() { return EDIT && EDIT.kind === 'file' ? EDIT.file : null; }
1799
+
1800
+ function adopt(next, title) {
1801
+ next.edit = EDIT;
1802
+ DATA = next;
1803
+ graph = DATA.graph;
1804
+ collectStyles();
1805
+ shownTheme = chosenName && DATA.themes[chosenName] ? chosenName : DATA.current;
1806
+ // A page with no file is headed reladraw, whatever the diagram is called.
1807
+ if (!EDIT || EDIT.kind === 'file') {
1808
+ heading.textContent = title;
1809
+ document.title = title;
1810
+ }
1811
+ themeItems[0].row.querySelector('.name').textContent = "Diagram's theme (" + DATA.current + ')';
1812
+ if (selection && !(selection.kind === 'node' ? graph.nodes[selection.key] : graph.edges[selection.key])) selection = null;
1813
+ if (level === 'pick' || level === 'filter') setLevel(null);
1814
+ listFindings(null);
1815
+ redraw();
1816
+ }
1817
+
1818
+ var libReady = !EDIT ? Promise.resolve(null)
1819
+ : EDIT.kind === 'hosted' ? Promise.resolve(globalThis.reladraw)
1820
+ : Promise.all(['/lib/index.js', '/lib/page.js', '/lib/page-data.js'].map(function (path) { return import(path); }))
1821
+ .then(function (all) { return Object.assign({}, all[0], all[1], all[2]); });
1822
+ var lib = null;
1823
+
1824
+ function clock(at) {
1825
+ return ('0' + at.getHours()).slice(-2) + ':' + ('0' + at.getMinutes()).slice(-2);
1826
+ }
1827
+
1828
+ function toClipboard(text) {
1829
+ if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(text);
1830
+ return Promise.reject(new Error('no clipboard'));
1831
+ }
1832
+
1833
+ var editorPane = document.getElementById('editor');
1834
+ var editButton = document.getElementById('edit');
1835
+ var splitBar = document.getElementById('split');
1836
+ var sourceBox = document.getElementById('source');
1837
+ var paintBox = document.getElementById('paint');
1838
+ var unsavedMark = document.getElementById('unsaved');
1839
+ var writeButton = document.getElementById('write');
1840
+ var formatButton = document.getElementById('format');
1841
+ var openButton = document.getElementById('open-file');
1842
+ var formatOpen = document.getElementById('format-open');
1843
+ var browseButton = document.getElementById('browse');
1844
+
1845
+ // \`quiet\` shows it without taking the keys, as when the page opens.
1846
+ function toggleEditor(show, quiet) {
1847
+ if (!EDIT) return;
1848
+ var on = show === undefined ? editorPane.hidden : show;
1849
+ editorPane.hidden = !on;
1850
+ splitBar.hidden = !on;
1851
+ editButton.setAttribute('aria-pressed', String(on));
1852
+ if (on) { paintSource(); if (!quiet) sourceBox.focus(); }
1853
+ else if (document.activeElement === sourceBox) sourceBox.blur();
1854
+ }
1855
+ editButton.onclick = function () { toggleEditor(); };
1856
+
1857
+ function escapeCode(text) { return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
1858
+ // The scanner covers each line end to end, so this rebuilds the text exactly
1859
+ // and only wraps some of it in spans; a lost character would slide the rest
1860
+ // of the line out from under the cursor.
1861
+ function paintSource() {
1862
+ var text = sourceBox.value;
1863
+ var out = '';
1864
+ try {
1865
+ if (!lib) throw new Error('not loaded');
1866
+ text.split(/\\r?\\n/).forEach(function (line) {
1867
+ lib.highlightLine(line).forEach(function (span) {
1868
+ var piece = escapeCode(line.slice(span.start, span.end));
1869
+ out += span.kind === 'plain' ? piece : '<span class="t-' + span.kind + '">' + piece + '</span>';
1870
+ });
1871
+ out += '\\n';
1872
+ });
1873
+ } catch (e) {
1874
+ // Coloring is decoration; the editor has to keep working without it.
1875
+ out = escapeCode(text) + '\\n';
1876
+ }
1877
+ paintBox.innerHTML = out;
1878
+ // A textarea's scroll bar comes out of its own width, so the colored
1879
+ // layer is given the width the text actually gets.
1880
+ paintBox.style.width = sourceBox.clientWidth + 'px';
1881
+ paintBox.scrollTop = sourceBox.scrollTop;
1882
+ }
1883
+ sourceBox.addEventListener('scroll', function () { paintBox.scrollTop = sourceBox.scrollTop; });
1884
+ window.addEventListener('resize', function () { if (EDIT && !editorPane.hidden) paintSource(); });
1885
+
1886
+ // Selects a line of the source, as a finding or an error naming it does.
1887
+ function goToLine(number) {
1888
+ if (!EDIT) return;
1889
+ toggleEditor(true, true);
1890
+ var lines = sourceBox.value.split('\\n');
1891
+ var start = 0;
1892
+ for (var i = 0; i < number - 1 && i < lines.length; i++) start += lines[i].length + 1;
1893
+ var end = start + (lines[number - 1] || '').length;
1894
+ sourceBox.focus();
1895
+ sourceBox.setSelectionRange(start, end);
1896
+ // Chrome doesn't scroll a selection made from outside the field into
1897
+ // view; leaving and coming back is what makes it scroll.
1898
+ sourceBox.blur();
1899
+ sourceBox.focus();
1900
+ }
1901
+
1902
+ /* ---- drawing what is typed ---- */
1903
+
1904
+ // The name the diagram is drawn as, which a saved file is given.
1905
+ var fileName = 'diagram.reladraw';
1906
+ var drawnText = DATA.source;
1907
+ var cost = 0;
1908
+ var typing = null;
1909
+
1910
+ function draw(text) {
1911
+ if (text === drawnText || !lib) return;
1912
+ drawnText = text;
1913
+ var started = performance.now();
1914
+ var made;
1915
+ try {
1916
+ made = lib.pageData(text, fileName, {});
1917
+ } catch (error) {
1918
+ cost = performance.now() - started;
1919
+ listFindings({ message: error.message, line: error.line });
1920
+ if (panelOn && panel.hidden) showPanel(true);
1921
+ return;
1922
+ }
1923
+ cost = performance.now() - started;
1924
+ adopt(made.data, made.title);
1925
+ }
1926
+
1927
+ // A diagram quick to draw is drawn on every keystroke; a slow one once the
1928
+ // typing pauses, so the keys aren't held up behind it.
1929
+ function drawSoon() {
1930
+ clearTimeout(typing);
1931
+ if (cost < 50) draw(sourceBox.value);
1932
+ else typing = setTimeout(function () { draw(sourceBox.value); }, 150);
1933
+ }
1934
+
1935
+ sourceBox.addEventListener('input', function () {
1936
+ paintSource();
1937
+ edited();
1938
+ drawSoon();
1939
+ });
1940
+ sourceBox.addEventListener('keydown', function (event) {
1941
+ if (event.key === 'Escape') { sourceBox.blur(); event.preventDefault(); }
1942
+ });
1943
+
1944
+ // Text put in whole: a version, an example, a file opened, the file as it is on disk.
1945
+ function load(text) {
1946
+ sourceBox.value = text;
1947
+ paintSource();
1948
+ edited();
1949
+ draw(text);
1950
+ }
1951
+
1952
+ // Text put in as one edit the browser can undo, with the cursor kept on its line.
1953
+ function replaceSource(text) {
1954
+ var before = sourceBox.value;
1955
+ var line = before.slice(0, sourceBox.selectionStart).split('\\n').length;
1956
+ var scroll = sourceBox.scrollTop;
1957
+ sourceBox.focus();
1958
+ sourceBox.setSelectionRange(0, before.length);
1959
+ var undoable = false;
1960
+ try { undoable = document.execCommand('insertText', false, text); } catch (e) {}
1961
+ if (!undoable || sourceBox.value !== text) {
1962
+ sourceBox.value = text;
1963
+ sourceBox.dispatchEvent(new Event('input'));
1964
+ }
1965
+ var lines = text.split('\\n');
1966
+ var at = 0;
1967
+ for (var i = 0; i < line - 1 && i < lines.length; i++) at += lines[i].length + 1;
1968
+ sourceBox.setSelectionRange(at, at);
1969
+ sourceBox.scrollTop = scroll;
1970
+ }
1971
+
1972
+ /* ---- the divider between the source and the picture ---- */
1973
+
1974
+ // Kept as a share of the width rather than pixels, so a remembered split
1975
+ // means the same in a wider or narrower window.
1976
+ var SPLIT = 'reladraw.split';
1977
+ var mainBox = document.querySelector('main');
1978
+ function divide(share, keep) {
1979
+ if (share === null) {
1980
+ editorPane.style.removeProperty('width');
1981
+ editorPane.style.removeProperty('max-width');
1982
+ } else {
1983
+ share = Math.max(0.15, Math.min(0.8, share));
1984
+ editorPane.style.width = (share * 100).toFixed(2) + '%';
1985
+ editorPane.style.maxWidth = 'none';
1986
+ splitBar.setAttribute('aria-valuenow', String(Math.round(share * 100)));
1987
+ }
1988
+ paintSource();
1989
+ if (!keep) return;
1990
+ try {
1991
+ if (share === null) localStorage.removeItem(SPLIT);
1992
+ else localStorage.setItem(SPLIT, String(share));
1993
+ } catch (e) {}
1994
+ }
1995
+ function shareAt(x) {
1996
+ var box = mainBox.getBoundingClientRect();
1997
+ return (x - box.left) / box.width;
1998
+ }
1999
+ var splitting = false;
2000
+ splitBar.addEventListener('pointerdown', function (event) {
2001
+ if (event.button !== 0) return;
2002
+ splitting = true;
2003
+ try { splitBar.setPointerCapture(event.pointerId); } catch (e) {}
2004
+ splitBar.classList.add('dragging');
2005
+ document.body.classList.add('dragging');
2006
+ event.preventDefault();
2007
+ });
2008
+ splitBar.addEventListener('pointermove', function (event) { if (splitting) divide(shareAt(event.clientX), false); });
2009
+ function letGo(event) {
2010
+ if (!splitting) return;
2011
+ splitting = false;
2012
+ splitBar.classList.remove('dragging');
2013
+ document.body.classList.remove('dragging');
2014
+ divide(shareAt(event.clientX), true);
2015
+ zoom.show();
2016
+ }
2017
+ splitBar.addEventListener('pointerup', letGo);
2018
+ splitBar.addEventListener('pointercancel', letGo);
2019
+ splitBar.addEventListener('dblclick', function () { divide(null, true); });
2020
+ splitBar.addEventListener('keydown', function (event) {
2021
+ var step = { ArrowLeft: -0.02, ArrowRight: 0.02 }[event.key];
2022
+ if (!step) return;
2023
+ divide(shareAt(splitBar.getBoundingClientRect().left) + step, true);
2024
+ event.preventDefault();
2025
+ event.stopPropagation();
2026
+ });
2027
+
2028
+ /* ---- formatting ---- */
2029
+
2030
+ // Tied to a file, the settings are the nearest .reladraw-format, as
2031
+ // reladraw format reads them; with no file, the ones set in this browser.
2032
+ function settingsText() {
2033
+ var file = tiedFile();
2034
+ if (file) return fetch('/format-settings?file=' + encodeURIComponent(file)).then(function (r) { return r.text(); });
2035
+ return Promise.resolve(formatText());
2036
+ }
2037
+ function formatSource() {
2038
+ Promise.all([libReady, settingsText()]).then(function (got) {
2039
+ var settings;
2040
+ try {
2041
+ settings = lib.readFormatSettings(got[1]);
2042
+ } catch (error) {
2043
+ if (tiedFile()) throw error;
2044
+ openFormatSettings();
2045
+ return;
2046
+ }
2047
+ var laid = lib.format(sourceBox.value, settings);
2048
+ if (laid === sourceBox.value) { flash(formatButton, 'Already formatted'); return; }
2049
+ replaceSource(laid);
2050
+ }).catch(function (error) { flash(formatButton, 'Can\\u2019t format: ' + error.message.split('\\n')[0]); });
2051
+ }
2052
+ formatButton.onclick = formatSource;
2053
+
2054
+ // Save on the keyboard works wherever the focus is, and never saves the
2055
+ // page; Shift+Alt+F formats, as in VS Code, by the key's code, since on a
2056
+ // Mac Alt turns F into another character.
2057
+ window.addEventListener('keydown', function (event) {
2058
+ if (!EDIT) return;
2059
+ if ((event.metaKey || event.ctrlKey) && !event.altKey && (event.key === 's' || event.key === 'S')) {
2060
+ event.preventDefault();
2061
+ event.stopImmediatePropagation();
2062
+ saveText();
2063
+ } else if (event.shiftKey && event.altKey && !event.ctrlKey && !event.metaKey && event.code === 'KeyF') {
2064
+ event.preventDefault();
2065
+ event.stopImmediatePropagation();
2066
+ formatSource();
2067
+ }
2068
+ }, true);
2069
+
2070
+ function saveText() {
2071
+ if (tiedFile()) write(false);
2072
+ else if (EDIT.kind === 'local') openSaveAs();
2073
+ else saveDownload();
2074
+ }
2075
+ writeButton.onclick = saveText;
2076
+
2077
+ /* ---- a page tied to a file ---- */
2078
+
2079
+ // What the file holds, as last heard from the server; and what the
2080
+ // editor's text was loaded or saved from, which a save checks the file
2081
+ // against, so a change on disk the person kept their text over is asked
2082
+ // about rather than written over.
2083
+ var disk = null;
2084
+ var base = null;
2085
+ var versions = [];
2086
+ var down = false;
2087
+ var changedStrip = document.getElementById('changed');
2088
+
2089
+ function markUnsaved() {
2090
+ if (!EDIT) return;
2091
+ if (tiedFile()) {
2092
+ unsavedMark.hidden = sourceBox.value === disk;
2093
+ unsavedMark.title = 'Not saved to the file';
2094
+ } else {
2095
+ unsavedMark.hidden = !unsavedHere();
2096
+ unsavedMark.title = 'Not saved to a file';
2097
+ }
2098
+ }
2099
+
2100
+ // Ties the page to a file whose text is \`text\`: from a served page's start,
2101
+ // or from the first Save of a page with no file, after which the page is
2102
+ // what it would have been opened with the file.
2103
+ function tie(file, text) {
2104
+ var fresh = !!EDIT && EDIT.kind !== 'file';
2105
+ EDIT = DATA.edit = { kind: 'file', file: file };
2106
+ fileName = file.split('/').pop();
2107
+ disk = text;
2108
+ base = text;
2109
+ browseButton.hidden = true;
2110
+ openButton.hidden = true;
2111
+ formatOpen.hidden = true;
2112
+ document.getElementById('versions-menu').hidden = false;
2113
+ writeButton.querySelector('.name').textContent = 'Save';
2114
+ writeButton.title = 'Write the source to the file (\\u2318S or Ctrl+S)';
2115
+ formatButton.title = 'Lay the source out again, as reladraw format does, with the nearest .reladraw-format (Shift+Alt+F)';
2116
+ editorPane.querySelector('.file').textContent = file;
2117
+ drawnLine.textContent = file + ' \\u00b7 live, read ' + clock(new Date());
2118
+ var links = document.querySelector('header .links');
2119
+ if (links) links.remove();
2120
+ if (fresh) {
2121
+ // Drawn again under the file's name, and headed with the diagram's title.
2122
+ drawnText = null;
2123
+ draw(sourceBox.value);
2124
+ }
2125
+ markUnsaved();
2126
+ follow(file);
2127
+ }
2128
+
2129
+ function who(version) {
2130
+ if (version.who === 'you') return 'you';
2131
+ if (version.who === 'agent') return 'agent' + (version.note ? ': \\u201c' + version.note + '\\u201d' : '');
2132
+ return 'changed on disk';
2133
+ }
2134
+
2135
+ // A change on disk loads at once, unless the editor holds text that isn't
2136
+ // in the file: then the text stays and a strip offers the file instead.
2137
+ function heard(message) {
2138
+ var clean = sourceBox.value === disk && base === disk;
2139
+ disk = message.text;
2140
+ versions = message.versions || [];
2141
+ listVersions();
2142
+ if (clean || sourceBox.value === disk) {
2143
+ base = disk;
2144
+ changedStrip.hidden = true;
2145
+ load(disk);
2146
+ } else {
2147
+ var last = versions[versions.length - 1];
2148
+ changedStrip.querySelector('.what').textContent =
2149
+ fileName + ' changed on disk at ' + clock(new Date(message.at)) + (last ? ' (' + who(last) + ')' : '');
2150
+ changedStrip.hidden = false;
2151
+ if (editorPane.hidden) toggleEditor(true);
2152
+ }
2153
+ markUnsaved();
2154
+ }
2155
+ document.getElementById('load-disk').onclick = function () {
2156
+ base = disk;
2157
+ changedStrip.hidden = true;
2158
+ load(disk);
2159
+ };
2160
+ document.getElementById('keep-mine').onclick = function () { changedStrip.hidden = true; };
2161
+
2162
+ function write(replace) {
2163
+ var file = tiedFile();
2164
+ var text = sourceBox.value;
2165
+ return fetch('/save?file=' + encodeURIComponent(file), {
2166
+ method: 'POST',
2167
+ body: JSON.stringify({ text: text, base: base, replace: !!replace }),
2168
+ }).then(function (answer) {
2169
+ if (answer.status === 409) {
2170
+ return answer.json().then(function (now) {
2171
+ var what = fileName + ' changed on disk at ' + clock(new Date(now.at)) +
2172
+ (now.who === 'agent' ? ' (agent' + (now.note ? ': \\u201c' + now.note + '\\u201d' : '') + ')' : '') +
2173
+ ' after you started editing. Saving puts your text in its place; the version on disk stays in Versions.\\n\\nReplace the file on disk?';
2174
+ if (confirm(what)) return write(true);
2175
+ });
2176
+ }
2177
+ if (!answer.ok) throw new Error('the server said ' + answer.status);
2178
+ disk = text;
2179
+ base = text;
2180
+ changedStrip.hidden = true;
2181
+ markUnsaved();
2182
+ flash(writeButton, 'Saved');
2183
+ }).catch(function (error) {
2184
+ flash(writeButton, 'Not saved');
2185
+ liveNote.textContent = 'Couldn\\u2019t save ' + file + ': ' + error.message;
2186
+ liveNote.hidden = false;
2187
+ });
2188
+ }
2189
+
2190
+ // Newest first. Picking one puts it in the editor as unsaved text.
2191
+ function listVersions() {
2192
+ versionsList.textContent = '';
2193
+ versions.slice().reverse().forEach(function (version, i) {
2194
+ var row = element('button', 'row');
2195
+ row.type = 'button';
2196
+ row.setAttribute('role', 'menuitem');
2197
+ var label = version.who === 'opened' ? 'opened' : version.who === 'disk' ? 'on disk' : version.who;
2198
+ var about = version.who === 'opened' ? 'as the server first read it'
2199
+ : version.who === 'disk' ? 'changed on disk'
2200
+ : version.who === 'agent' ? (version.note || 'changed by an agent')
2201
+ : 'saved from this page';
2202
+ row.append(element('span', 'when', clock(new Date(version.at))), element('span', 'who ' + version.who, label),
2203
+ element('span', 'about', about + (i === 0 ? ' (in the file now)' : '')));
2204
+ row.onclick = function () {
2205
+ setLevel(null);
2206
+ toggleEditor(true);
2207
+ load(version.text);
2208
+ };
2209
+ versionsList.append(row);
2210
+ });
2211
+ if (!versions.length) versionsList.append(element('span', 'none', 'No versions yet.'));
2212
+ }
2213
+
2214
+ function follow(file) {
2215
+ var events = new EventSource('/events?file=' + encodeURIComponent(file));
2216
+ events.addEventListener('source', function (event) {
2217
+ var message = JSON.parse(event.data);
2218
+ if (down) {
2219
+ down = false;
2220
+ liveNote.hidden = true;
2221
+ writeButton.disabled = false;
2222
+ }
2223
+ drawnLine.textContent = file + ' \\u00b7 live, ' + (message.changed ? 'changed ' : 'read ') + clock(new Date(message.at));
2224
+ heard(message);
2225
+ });
2226
+ events.onerror = function () {
2227
+ if (down) return;
2228
+ down = true;
2229
+ writeButton.disabled = true;
2230
+ drawnLine.textContent = file + ' \\u00b7 not live';
2231
+ liveNote.textContent = 'The server has stopped, so this page can\\u2019t save or follow ' + file +
2232
+ '. Start it again with reladraw serve ' + file + ' in the same folder, and this page picks the file up again. Export \\u25BE still saves what you typed.';
2233
+ liveNote.hidden = false;
2234
+ };
2235
+ }
2236
+
2237
+ window.addEventListener('beforeunload', function (event) {
2238
+ if (tiedFile() && sourceBox.value !== disk) { event.preventDefault(); event.returnValue = ''; }
2239
+ });
2240
+
2241
+ /* ---- a page with no file: the text kept in this browser ---- */
2242
+
2243
+ // The same names the playground has always kept them under, so a visitor
2244
+ // finds their diagram where they left it.
2245
+ var STORE = 'reladraw.source';
2246
+ var KEPT_TEXT = 'reladraw.kept';
2247
+ var KEPT_NAME = 'reladraw.name';
2248
+ function stored(key) { try { return localStorage.getItem(key); } catch (e) { return null; } }
2249
+ function store(key, value) { try { localStorage.setItem(key, value); } catch (e) {} }
2250
+
2251
+ // The text as last saved or opened, so opening another file asks first only
2252
+ // when there is something to lose; and the example the editor holds
2253
+ // unchanged, or null once it is edited, since an example left alone is one
2254
+ // click away and nothing to lose.
2255
+ var keptText = null;
2256
+ var current = null;
2257
+ var stowing = null;
2258
+ var EXAMPLES = EDIT && EDIT.examples ? EDIT.examples : [];
2259
+
2260
+ function named(text) {
2261
+ for (var i = 0; i < EXAMPLES.length; i++) if (EXAMPLES[i].source === text) return EXAMPLES[i].name;
2262
+ return null;
2263
+ }
2264
+ function unsavedHere() { return sourceBox.value !== keptText && current === null; }
2265
+
2266
+ function edited() {
2267
+ if (EDIT && EDIT.kind !== 'file') {
2268
+ current = named(sourceBox.value);
2269
+ // The stored copy trails the typing: it isn't read until the page is opened again.
2270
+ clearTimeout(stowing);
2271
+ stowing = setTimeout(function () { store(STORE, sourceBox.value); }, 400);
2272
+ }
2273
+ markUnsaved();
2274
+ }
2275
+ function keep(text, name) {
2276
+ keptText = text;
2277
+ fileName = name;
2278
+ store(KEPT_TEXT, text);
2279
+ store(KEPT_NAME, name);
2280
+ if (EDIT.kind === 'hosted') editorPane.querySelector('.file').textContent = name;
2281
+ markUnsaved();
2282
+ }
2283
+
2284
+ // A different diagram opens as a fresh page would, not at the last one's zoom.
2285
+ function openText(text) {
2286
+ load(text);
2287
+ zoom.open();
2288
+ sourceBox.focus();
2289
+ sourceBox.setSelectionRange(0, 0);
2290
+ sourceBox.scrollTop = 0;
2291
+ }
2292
+
2293
+ var fileInput = document.getElementById('file-input');
2294
+ openButton.onclick = function () {
2295
+ if (unsavedHere() && !confirm('Your edits have not been saved to a file. Open another file in their place?')) return;
2296
+ fileInput.value = '';
2297
+ fileInput.click();
2298
+ };
2299
+ fileInput.onchange = function () {
2300
+ var picked = fileInput.files[0];
2301
+ if (!picked) return;
2302
+ picked.text().then(function (text) {
2303
+ keep(text, picked.name);
2304
+ openText(text);
2305
+ });
2306
+ };
2307
+
2308
+ // Chrome and Edge ask for the name and the folder; elsewhere the file goes
2309
+ // where the browser puts downloads, under the suggested name.
2310
+ function saveDownload() {
2311
+ var text = sourceBox.value;
2312
+ function download() {
2313
+ saveBlob(new Blob([text], { type: 'text/plain' }), fileName);
2314
+ keep(text, fileName);
2315
+ }
2316
+ if (!window.showSaveFilePicker) return download();
2317
+ window.showSaveFilePicker({ suggestedName: fileName, types: [{ description: 'reladraw source', accept: { 'text/plain': ['.reladraw'] } }] })
2318
+ .then(function (handle) {
2319
+ return handle.createWritable().then(function (out) {
2320
+ return out.write(text).then(function () { return out.close(); }).then(function () {
2321
+ keep(text, handle.name);
2322
+ flash(writeButton, 'Saved');
2323
+ });
2324
+ });
2325
+ })
2326
+ .catch(function (e) {
2327
+ if (e && e.name === 'AbortError') return;
2328
+ // Refused outright, as inside a cross-origin frame.
2329
+ download();
2330
+ });
2331
+ }
2332
+
2333
+ // Served with no file, the first Save names a file in the folder, typed
2334
+ // rather than browsed for, and asks before replacing one that is there.
2335
+ var saveAs = document.getElementById('save-as');
2336
+ function openSaveAs() {
2337
+ var path = document.getElementById('save-path');
2338
+ document.getElementById('save-problem').textContent = '';
2339
+ if (!path.value) path.value = fileName;
2340
+ saveAs.showModal();
2341
+ path.select();
2342
+ }
2343
+ function create(path, replace) {
2344
+ var text = sourceBox.value;
2345
+ var problem = document.getElementById('save-problem');
2346
+ return fetch('/create?file=' + encodeURIComponent(path), {
2347
+ method: 'POST',
2348
+ body: JSON.stringify({ text: text, replace: !!replace }),
2349
+ }).then(function (answer) {
2350
+ return answer.json().then(function (told) {
2351
+ if (answer.status === 409 && told.exists) {
2352
+ if (confirm(told.file + ' is there already. Replace it with this diagram?')) return create(path, true);
2353
+ return;
2354
+ }
2355
+ if (!answer.ok) { problem.textContent = told.error || 'The server said ' + answer.status; return; }
2356
+ saveAs.close();
2357
+ keep(text, told.file.split('/').pop());
2358
+ history.replaceState(null, '', '/' + told.file.split('/').map(encodeURIComponent).join('/'));
2359
+ tie(told.file, text);
2360
+ flash(writeButton, 'Saved');
2361
+ });
2362
+ }).catch(function (error) {
2363
+ problem.textContent = 'Couldn\\u2019t save: ' + error.message + '. Is reladraw serve still running?';
2364
+ });
2365
+ }
2366
+ if (saveAs) {
2367
+ document.getElementById('save-form').addEventListener('submit', function (event) {
2368
+ event.preventDefault();
2369
+ var path = document.getElementById('save-path').value.trim();
2370
+ if (!path) return;
2371
+ if (!/\\.reladraw$/.test(path)) path += '.reladraw';
2372
+ create(path, false);
2373
+ });
2374
+ }
2375
+
2376
+ /* ---- the examples ---- */
2377
+
2378
+ var examplesBox = document.getElementById('examples');
2379
+ var catalog = document.getElementById('catalog');
2380
+ var preview = document.getElementById('preview');
2381
+ var aboutLine = document.getElementById('about');
2382
+ var chosenExample = 0;
2383
+ var previews = {};
2384
+
2385
+ function pickExample(i) {
2386
+ chosenExample = Math.max(0, Math.min(EXAMPLES.length - 1, i));
2387
+ each(catalog.children, function (item, j) { item.setAttribute('aria-selected', String(j === chosenExample)); });
2388
+ var item = catalog.children[chosenExample];
2389
+ catalog.setAttribute('aria-activedescendant', item.id);
2390
+ item.scrollIntoView({ block: 'nearest' });
2391
+ var example = EXAMPLES[chosenExample];
2392
+ if (!(example.name in previews)) {
2393
+ try { previews[example.name] = lib ? lib.compile(example.source) : ''; } catch (e) { previews[example.name] = ''; }
2394
+ }
2395
+ aboutLine.textContent = example.about || '';
2396
+ preview.innerHTML = previews[example.name];
2397
+ }
2398
+ function openExample() {
2399
+ var example = EXAMPLES[chosenExample];
2400
+ examplesBox.close();
2401
+ if (unsavedHere() && !confirm('Your edits have not been saved to a file. Open ' + example.name + ' in their place?')) return;
2402
+ openText(example.source);
2403
+ }
2404
+ // A click on the backdrop lands on the dialog itself, outside its box.
2405
+ function closeOnBackdrop(box) {
2406
+ box.addEventListener('click', function (event) {
2407
+ if (event.target !== box) return;
2408
+ var r = box.getBoundingClientRect();
2409
+ if (event.clientX < r.left || event.clientX > r.right || event.clientY < r.top || event.clientY > r.bottom) box.close();
2410
+ });
2411
+ each(box.querySelectorAll('[data-close]'), function (button) { button.onclick = function () { box.close(); }; });
2412
+ }
2413
+ if (examplesBox) {
2414
+ EXAMPLES.forEach(function (example, i) {
2415
+ var item = element('li', '', example.name);
2416
+ item.id = 'example-' + i;
2417
+ item.setAttribute('role', 'option');
2418
+ item.onclick = function () { pickExample(i); };
2419
+ item.ondblclick = openExample;
2420
+ catalog.append(item);
2421
+ });
2422
+ browseButton.onclick = function () {
2423
+ var at = 0;
2424
+ EXAMPLES.forEach(function (example, i) { if (example.name === current) at = i; });
2425
+ examplesBox.showModal();
2426
+ pickExample(at);
2427
+ catalog.focus();
2428
+ };
2429
+ catalog.addEventListener('keydown', function (event) {
2430
+ var step = { ArrowDown: 1, ArrowUp: -1, PageDown: 5, PageUp: -5 }[event.key];
2431
+ if (step) pickExample(chosenExample + step);
2432
+ else if (event.key === 'Home') pickExample(0);
2433
+ else if (event.key === 'End') pickExample(EXAMPLES.length - 1);
2434
+ else if (event.key === 'Enter') openExample();
2435
+ else return;
2436
+ event.preventDefault();
2437
+ });
2438
+ document.getElementById('choose').onclick = openExample;
2439
+ closeOnBackdrop(examplesBox);
2440
+ // Every drawing names its arrowheads alike, and a page takes the first of
2441
+ // a name it finds: a preview left in the closed dialog would take the
2442
+ // diagram's arrowheads with it.
2443
+ examplesBox.addEventListener('close', function () { preview.innerHTML = ''; });
2444
+ closeOnBackdrop(saveAs);
2445
+ }
2446
+
2447
+ /* ---- format settings, for a page with no file ---- */
2448
+
2449
+ // The settings as \`name: value\` lines, the way a .reladraw-format holds
2450
+ // them, so the panel, the stored copy and the copied file are one text and
2451
+ // the library's own reader refuses what it would refuse in a file.
2452
+ var FORMAT = 'reladraw.format';
2453
+ var formatPanel = document.getElementById('format-settings');
2454
+ var formatValues = {};
2455
+ (stored(FORMAT) || '').split('\\n').forEach(function (line) {
2456
+ var colon = line.indexOf(':');
2457
+ if (colon > 0) formatValues[line.slice(0, colon).trim()] = line.slice(colon + 1).trim();
2458
+ });
2459
+ function formatText() {
2460
+ if (!lib) return '';
2461
+ return lib.SETTINGS.map(function (setting) {
2462
+ var value = formatValues[setting.name];
2463
+ return setting.name + ': ' + (value === undefined || value === '' ? setting.written : value);
2464
+ }).join('\\n');
2465
+ }
2466
+ function checkSettings() {
2467
+ var problem = document.getElementById('format-problem');
2468
+ try { lib.readFormatSettings(formatText()); problem.textContent = ''; } catch (e) { problem.textContent = e.message; }
2469
+ }
2470
+ // A setting whose values are all words gets a list; one that takes a
2471
+ // number or a list gets a field, written as it would be in the file.
2472
+ function buildFormatFields() {
2473
+ var fields = document.getElementById('format-fields');
2474
+ fields.textContent = '';
2475
+ lib.SETTINGS.forEach(function (setting) {
2476
+ var label = element('label', '');
2477
+ label.append(element('code', '', setting.name));
2478
+ var words = setting.values.every(function (value) { return /^[a-z-]+$/.test(value[0]); });
2479
+ var control;
2480
+ if (words) {
2481
+ control = element('select', '');
2482
+ setting.values.forEach(function (value) {
2483
+ var option = element('option', '', value[0]);
2484
+ option.value = value[0];
2485
+ control.append(option);
2486
+ });
2487
+ } else {
2488
+ control = element('input', '');
2489
+ control.type = 'text';
2490
+ control.spellcheck = false;
2491
+ control.placeholder = setting.written;
2492
+ }
2493
+ control.value = formatValues[setting.name] || setting.written;
2494
+ control.addEventListener(words ? 'change' : 'input', function () {
2495
+ formatValues[setting.name] = control.value.trim();
2496
+ checkSettings();
2497
+ store(FORMAT, formatText());
2498
+ });
2499
+ label.append(control, element('span', '', setting.about.replace(/\\n/g, ' ') + ' ' +
2500
+ setting.values.map(function (value) { return value[0] + ': ' + value[1].replace(/\\n/g, ' '); }).join('; ') + '.'));
2501
+ fields.append(label);
2502
+ });
2503
+ checkSettings();
2504
+ }
2505
+ function openFormatSettings() {
2506
+ libReady.then(function () {
2507
+ buildFormatFields();
2508
+ formatPanel.showModal();
2509
+ });
2510
+ }
2511
+ if (formatPanel) {
2512
+ formatOpen.onclick = openFormatSettings;
2513
+ closeOnBackdrop(formatPanel);
2514
+ document.getElementById('format-reset').onclick = function () {
2515
+ formatValues = {};
2516
+ store(FORMAT, formatText());
2517
+ buildFormatFields();
2518
+ };
2519
+ var formatCopy = document.getElementById('format-copy');
2520
+ formatCopy.onclick = function () {
2521
+ var text = '// .reladraw-format: how reladraw format lays out the diagrams in this folder and below\\n' + formatText() + '\\n';
2522
+ toClipboard(text).then(function () { flash(formatCopy, 'Copied'); }, function () { flash(formatCopy, 'Blocked'); });
2523
+ };
2524
+ }
2525
+
2526
+ /* ---- the start ---- */
2527
+
2528
+ if (EDIT) {
2529
+ editButton.hidden = false;
2530
+ document.getElementById('edit-gap').hidden = false;
2531
+ try {
2532
+ var share = parseFloat(localStorage.getItem(SPLIT));
2533
+ if (share > 0 && share < 1) divide(share, false);
2534
+ } catch (e) {}
2535
+ if (EDIT.kind === 'file') {
2536
+ sourceBox.value = DATA.source;
2537
+ tie(EDIT.file, DATA.source);
2538
+ } else {
2539
+ browseButton.hidden = false;
2540
+ openButton.hidden = false;
2541
+ formatOpen.hidden = false;
2542
+ writeButton.querySelector('.name').textContent = 'Save\\u2026';
2543
+ writeButton.title = EDIT.kind === 'local'
2544
+ ? 'Save the source as a file in ' + EDIT.folder + ' (\\u2318S or Ctrl+S)'
2545
+ : 'Save the source as a .reladraw file (\\u2318S or Ctrl+S)';
2546
+ formatButton.title = 'Lay the source out again (Shift+Alt+F)';
2547
+ if (saveAs) saveAs.querySelector('.folder').textContent = EDIT.folder || '';
2548
+ keptText = stored(KEPT_TEXT);
2549
+ fileName = stored(KEPT_NAME) || fileName;
2550
+ editorPane.querySelector('.file').textContent = EDIT.kind === 'local' ? 'not saved to a file' : fileName;
2551
+ var opening = stored(STORE);
2552
+ sourceBox.value = opening !== null ? opening : DATA.source;
2553
+ current = named(sourceBox.value);
2554
+ // The playground opens with the source shown; a file's page with it hidden.
2555
+ toggleEditor(true, true);
2556
+ markUnsaved();
2557
+ }
2558
+ libReady.then(function (loaded) {
2559
+ lib = loaded;
2560
+ paintSource();
2561
+ draw(sourceBox.value);
2562
+ }, function (error) {
2563
+ liveNote.textContent = 'The page couldn\\u2019t load the library it draws with: ' + error.message;
2564
+ liveNote.hidden = false;
2565
+ });
2566
+ }
2567
+
1740
2568
  layoutSide();
1741
2569
  zoom.show();
1742
2570
  })();
@@ -1744,3 +2572,173 @@ const PAGE = `<!doctype html>
1744
2572
  </body>
1745
2573
  </html>
1746
2574
  `;
2575
+ /** What a page with an editor shows under its source: the language on one screen. */
2576
+ const CHEATSHEET = `
2577
+ <details id="cheatsheet">
2578
+ <summary>Cheatsheet</summary>
2579
+ <dl>
2580
+ <dt>Statements</dt>
2581
+ <dd>
2582
+ <code>node <i>name</i> <i>["text"]</i> <i>[placements]</i> <i>[key: value …]</i></code><br />
2583
+ <code>edge <i>a</i> -&gt; <i>b</i> <i>["text"]</i> <i>[key: value …]</i></code>
2584
+ &nbsp;<i>(<code>&lt;-</code> to write the target first, <code>&lt;-&gt;</code> for both ends)</i><br />
2585
+ <code>point <i>name</i> <i>where</i></code> &nbsp;<code>region <i>name</i> <i>a</i> top-left to <i>b</i> bottom-right</code><br />
2586
+ <code>bundle <i>name</i> via: <i>…</i></code> &nbsp;<i>(a path lines share)</i>
2587
+ &nbsp;<code><b>guide</b> node|edge|point|region …</code> <i>(reference only; turn on Guides in View to see them)</i><br />
2588
+ <code>style <i>name</i> <i>key: value …</i></code><br />
2589
+ <code>diagram theme: nord background: #1e2229 text: (color: #e0e0e0)</code><br />
2590
+ <code>default node|leaf|container|edge <i>key: value …</i></code>
2591
+ &nbsp;<i>(a style every one of that kind wears; the more specific wins)</i><br />
2592
+ <i>The picture is drawn exactly as written: nothing is moved to make room, and lines go only
2593
+ where they are told. Anything that overlaps is drawn as written; the findings beside the diagram report it, as <code>reladraw check</code> does.</i>
2594
+ </dd>
2595
+
2596
+ <dt>Labels</dt>
2597
+ <dd>
2598
+ <code>node parser</code> — no text, so the node takes its name as its text.<br />
2599
+ <code>node parser ""</code> — deliberately blank.<br />
2600
+ <code>"two / lines"</code> — <code> / </code> with spaces either side breaks a line.
2601
+ </dd>
2602
+
2603
+ <dt>Containment</dt>
2604
+ <dd>
2605
+ <code>node server.docker "Docker"</code> — a dotted name is a child. Declare the parent first. With no text of its own, it reads "docker".<br />
2606
+ <code>layout: column</code> or <code>row</code> — the children in written order, with
2607
+ <code>(gap: tight, widths: match, align-children: left)</code>.
2608
+ Without a layout, one child is unplaced and the rest are placed against it.<br />
2609
+ <code>layout: deck</code> — a stack of cards, the first child in front.<br />
2610
+ <code>gather-children: left</code> — where the children sit in spare room.
2611
+ <code>shape: none</code> on a container draws no box.
2612
+ </dd>
2613
+
2614
+ <dt>Placement</dt>
2615
+ <dd>
2616
+ <code><b>above</b> X</code> <code><b>below</b> X</code>
2617
+ <code><b>left of</b> X</code> <code><b>right of</b> X</code>
2618
+ <code><b>above-left of</b> X</code> … <code><b>below-right of</b> X</code><br />
2619
+ <code><b>even with</b> X</code> — share a center line;
2620
+ <code><b>top</b>|<b>bottom</b>|<b>left</b>|<b>right</b> <b>even with</b> X</code> shares that edge.<br />
2621
+ <code>right of A <b>and</b> B</code> — against the box that just bounds them both.<br />
2622
+ <code><b>inside</b> X top-right</code> <code><b>outside</b> X right</code>
2623
+ <code><b>on</b> X bottom-left</code> — against a part of X. The parts are
2624
+ <code>text</code>, the sides <code>top</code> <code>bottom</code> <code>left</code> <code>right</code>,
2625
+ and <code>top-left</code> <code>top-center</code> <code>top-right</code>
2626
+ <code>left-center</code> <code>center</code> <code>right-center</code>
2627
+ <code>bottom-left</code> <code>bottom-center</code> <code>bottom-right</code>.<br />
2628
+ <code><b>on</b> between A and B</code> <code>below down from A and left from B</code> — at a point.<br />
2629
+ <i>One node is unplaced; every other is placed against something. One direction on its own
2630
+ also centers on its target. Each axis is said once.</i>
2631
+ </dd>
2632
+
2633
+ <dt>Gaps</dt>
2634
+ <dd>
2635
+ <code>right of X <b>(gap: wide)</b></code> or <code><b>2</b> right of X</code>, on the placement.
2636
+ One of <code>none</code> <code>tight</code> <code>normal</code> <code>wide</code> (0, 2, 4, 8 units),
2637
+ or a number of units; a box is about 4 units tall. A gap is <i>exact</i>.
2638
+ </dd>
2639
+
2640
+ <dt>Sizes</dt>
2641
+ <dd>
2642
+ <code>width: 30</code> <code>height: menu</code> <code>height: 0.5 menu height</code>
2643
+ <code>height: a top to c bottom</code> <code>min-width: 10</code>
2644
+ </dd>
2645
+
2646
+ <dt>Appearance</dt>
2647
+ <dd>
2648
+ <code>style: <i>name</i></code>
2649
+ <code>fill: #191728</code>
2650
+ <code>border: none</code>
2651
+ <code>corners: sharp</code> <i>(a box; rounded unless told)</i>
2652
+ <code>line: #d2904e</code> <i>(an edge)</i>
2653
+ <code>overlap: allow</code>
2654
+ <code>url: "https://example.com"</code>
2655
+ <code>description: "What it does"</code> <i>(shown on the page; never drawn)</i>
2656
+ </dd>
2657
+
2658
+ <dt>Lines</dt>
2659
+ <dd>
2660
+ <code>line: (path: square, corners: rounded)</code> — everything about an edge's line.
2661
+ <code>path:</code> takes <code>straight</code> (the default) <code>square</code> <code>curved</code>;
2662
+ <code>corners:</code> <code>sharp</code> <code>rounded</code>;
2663
+ <code>crossing:</code> <code>gap</code> <code>arc</code> <code>square</code> <code>none</code>;
2664
+ <code>pattern:</code> <code>solid</code> <code>dashed</code> <code>dotted</code> <code>dash-dot</code>;
2665
+ <code>thickness:</code> <code>thin</code> <code>normal</code> <code>thick</code>, a multiple, or <code>3px</code>;
2666
+ <code>turn: 1/3</code> <code>step-out: tight</code> <code>curve: subtle</code>.
2667
+ <i><code>default edge line: (path: square)</code> sets every edge.</i>
2668
+ </dd>
2669
+
2670
+ <dt>Body</dt>
2671
+ <dd>
2672
+ <code>shape: rectangle|document|circle|none</code> — the outline the node is drawn with;
2673
+ <code>none</code> is text with no box, which is what an annotation is.<br />
2674
+ <code>icon: <i>name</i></code> — the node <i>is</i> that picture, with no box.<br />
2675
+ <code>badge: <i>name</i></code> — the picture is part of the label, beside the text, and the node keeps its box.<br />
2676
+ <code>layout: deck cards: "text" <i>["text" …]</i></code> — a stack of cards, front first, one per text.<br />
2677
+ Pictures: <code>disk</code> <code>desktop</code> <code>laptop</code>
2678
+ <code>package</code> <code>cubes</code> <code>cube</code> <code>database</code>.<br />
2679
+ Your own: <code>icon <i>name</i> """&lt;svg …&gt;…&lt;/svg&gt;"""</code> declares one,
2680
+ and <code>icon: <i>name</i></code> or <code>badge: <i>name</i></code> uses it.
2681
+ </dd>
2682
+
2683
+ <dt>Text</dt>
2684
+ <dd>
2685
+ Everything a text says about itself goes in brackets after it:
2686
+ <code>(color: theme-muted)</code>
2687
+ <code>(size: small|normal|large)</code>
2688
+ <code>(wrap: 24)</code> <i>(characters, folds the text)</i>.
2689
+ A title says which side it sits against: <code>"Server" (inside top, align: left)</code>,
2690
+ <code>(on top)</code>, <code>(outside top)</code>, <code>(center)</code>.
2691
+ In a style, which has no text of its own, the bracket hangs off the key:
2692
+ <code>text: (color: theme-muted)</code>.<br />
2693
+ <code>" / "</code> in any text is a line break; <code>\\/</code> is a literal slash.<br />
2694
+ <code>"name / <b>[dim]</b>quieter<b>[/dim]</b>"</code> draws those words in the
2695
+ text color of <code>style dim</code>; <code>\\[</code> is a literal bracket.
2696
+ </dd>
2697
+
2698
+ <dt>Links</dt>
2699
+ <dd>
2700
+ A line is straight between its two boxes, through anything in between, unless told otherwise.<br />
2701
+ <code>from: right</code> <code>to: left</code> name a side;
2702
+ <code>to: bottom-center</code> or <code>to: left 3/4 down</code> a point on it.<br />
2703
+ <code>via: c top, between a and b</code> — the places the line passes, in order:
2704
+ just outside a side, a point, a box's center, <code>through <i>region</i> horizontal</code>.<br />
2705
+ <code>"text" (at: start)</code> <code>(at: 1/4)</code> <code>(above edge)</code>
2706
+ <code>(on: longest level)</code> — where a line's text sits; the middle by default.
2707
+ </dd>
2708
+
2709
+ <dt>Comments</dt>
2710
+ <dd><code>// to the end of the line</code>, anywhere, including after a statement.</dd>
2711
+ </dl>
2712
+ </details>
2713
+ `;
2714
+ /** Where a page with no file sends someone who wants more than the cheatsheet. */
2715
+ const LINKS = `<nav class="links"><a href="https://github.com/reladraw/reladraw">GitHub</a><a href="https://github.com/reladraw/reladraw/blob/main/SYNTAX.md">Syntax</a></nav>`;
2716
+ /**
2717
+ * A page with no file's dialogs: the examples, the format settings, which a
2718
+ * file's page takes from its nearest .reladraw-format instead, and, served,
2719
+ * the name of the file a first Save writes.
2720
+ */
2721
+ const DIALOGS = `
2722
+ <dialog id="examples" aria-label="Examples">
2723
+ <div class="bar"><h2>Examples</h2><button type="button" data-close>Close</button></div>
2724
+ <ul id="catalog" role="listbox" tabindex="0" aria-label="Examples"></ul>
2725
+ <p id="about" aria-live="polite"></p>
2726
+ <div id="preview"></div>
2727
+ <footer><span class="keys"><kbd>↑</kbd> <kbd>↓</kbd> to preview · <kbd>Enter</kbd> to open · <kbd>Esc</kbd> to close</span><span class="gap"></span><button type="button" id="choose">Open in editor</button></footer>
2728
+ </dialog>
2729
+ <dialog id="format-settings" aria-label="Format settings">
2730
+ <div class="bar"><h2>Format settings</h2><button type="button" data-close>Close</button></div>
2731
+ <p class="about">How Format lays the source out. These are remembered in this browser only; for a project, put them in a <code>.reladraw-format</code> file beside its diagrams.</p>
2732
+ <form id="format-fields"></form>
2733
+ <p id="format-problem" role="alert"></p>
2734
+ <footer><button type="button" id="format-reset">Reset to defaults</button><span class="gap"></span><button type="button" id="format-copy" title="Copy these settings as the text of a .reladraw-format file">Copy as .reladraw-format</button></footer>
2735
+ </dialog>
2736
+ <dialog id="save-as" aria-label="Save as a file">
2737
+ <form method="dialog" id="save-form">
2738
+ <div class="bar"><h2>Save as a file in <span class="folder"></span></h2></div>
2739
+ <p class="about">A path inside that folder, such as <code>diagram.reladraw</code> or <code>docs/flow.reladraw</code>. If the file is there already, you'll be asked before it's replaced. From then on this page is tied to the file.</p>
2740
+ <label>Path <input id="save-path" type="text" spellcheck="false" autocomplete="off" required></label>
2741
+ <p id="save-problem" role="alert"></p>
2742
+ <footer><span class="gap"></span><button type="button" data-close>Cancel</button><button type="submit" id="save-go">Save</button></footer>
2743
+ </form>
2744
+ </dialog>`;