reladraw 0.26.1 → 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 (50) hide show
  1. package/.claude/skills/reladraw/SKILL.md +7 -5
  2. package/.claude/skills/reladraw/reference/syntax.md +55 -7
  3. package/README.md +2 -2
  4. package/SYNTAX.md +55 -7
  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 +1992 -252
  12. package/dist/serve.d.ts +18 -0
  13. package/dist/serve.js +544 -0
  14. package/dist/viewer.js +1 -1
  15. package/examples/arch.reladraw +95 -0
  16. package/examples/around.reladraw +101 -0
  17. package/examples/borders.reladraw +28 -0
  18. package/examples/bundles.reladraw +49 -0
  19. package/examples/circles.reladraw +37 -0
  20. package/examples/coincident.reladraw +66 -0
  21. package/examples/contents.reladraw +52 -0
  22. package/examples/corridors.reladraw +48 -0
  23. package/examples/deck.reladraw +26 -0
  24. package/examples/flowchart.reladraw +23 -0
  25. package/examples/frames.reladraw +79 -0
  26. package/examples/gap.reladraw +21 -0
  27. package/examples/gaps.reladraw +25 -0
  28. package/examples/icons.reladraw +35 -0
  29. package/examples/lanes.reladraw +54 -0
  30. package/examples/lines.reladraw +73 -0
  31. package/examples/linetexts.reladraw +48 -0
  32. package/examples/marks.reladraw +39 -0
  33. package/examples/names.reladraw +23 -0
  34. package/examples/outlines.reladraw +56 -0
  35. package/examples/overhang.reladraw +24 -0
  36. package/examples/overlays.reladraw +75 -0
  37. package/examples/parts.reladraw +31 -0
  38. package/examples/passes.reladraw +56 -0
  39. package/examples/points.reladraw +28 -0
  40. package/examples/regions.reladraw +14 -0
  41. package/examples/routing.reladraw +88 -0
  42. package/examples/shapes.reladraw +38 -0
  43. package/examples/snug.reladraw +8 -0
  44. package/examples/text.reladraw +69 -0
  45. package/examples/themes.reladraw +16 -0
  46. package/examples/urls.reladraw +35 -0
  47. package/examples/venn.reladraw +15 -0
  48. package/package.json +2 -1
  49. package/dist/search.d.ts +0 -104
  50. package/dist/search.js +0 -490
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,173 +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
- const themes = {};
36
- for (const one of THEME_NAMES) {
37
- const drawnIn = { ...options, theme: options.theme && one === current ? options.theme : THEMES[one] };
38
- const theme = themeOf(layout, drawnIn);
39
- themes[one] = { ...renderTagged(layout, drawnIn), theme, colors: colorsOf(theme), scheme: schemeOf(theme) };
40
- }
41
- // A --theme that is not one of the named ones is drawn as given, and offered as such.
42
- if (themes[current] === undefined) {
43
- const theme = themeOf(layout, options);
44
- themes[current] = { ...renderTagged(layout, options), theme, colors: colorsOf(theme), scheme: schemeOf(theme) };
45
- }
46
- return {
47
- html: page({ title, name, drawn, source, findings: found, themes, current, graph: graphOf(layout, themes) }),
48
- findings: found,
49
- };
50
- };
51
- try {
52
- return run(source);
53
- }
54
- catch (error) {
55
- throw error instanceof SourceError ? blame(source, error, run) : error;
56
- }
57
- }
58
- /** A description as the panel shows it: its Markdown as HTML, or nothing. */
59
- const describe = (attrs) => attrs['description'] === undefined ? {} : { description: markdownHtml(attrs['description']) };
60
- const BADGE_SIDE = 20;
61
- function graphOf(layout, themes) {
62
- const views = (links) => (links ?? []).map((link) => {
63
- const label = linkLabel(link, links);
64
- const icon = link.icon === undefined
65
- ? { letters: [...label.trim()][0]?.toUpperCase() ?? '?' }
66
- : 'picture' in link.icon
67
- ? layout.linkIcons[link.icon.picture]
68
- : link.icon;
69
- const badges = {};
70
- for (const [one, view] of Object.entries(themes))
71
- badges[one] = iconSvg(icon, BADGE_SIDE, view.theme);
72
- return { href: link.href, label, badges };
73
- });
74
- // A node with no links of its own has its container's, as a click on it in
75
- // the SVG follows its container's.
76
- const linksFor = (node) => {
77
- for (let at = node; at; at = at.parent) {
78
- const own = linksOf(at.attrs);
79
- if (own !== undefined)
80
- return own;
81
- }
82
- return undefined;
83
- };
84
- const nodes = {};
85
- for (const node of layout.nodes) {
86
- const text = plainText(node.text);
87
- nodes[node.name] = { label: text === '' ? node.name.split('.').pop() : text, links: views(linksFor(node)), ...describe(node.attrs) };
88
- }
89
- const edges = layout.edges.map((edge) => {
90
- // The upstream end is the one a line runs from: the end without a mark
91
- // when only the other has one, and otherwise the end written first.
92
- const [, start = '', , end = ''] = edge.arrow.match(/^(.*?)(-{1,2})(.*)$/) ?? [];
93
- const backwards = start !== '' && end === '';
94
- const [up, down] = backwards ? [edge.to, edge.from] : [edge.from, edge.to];
95
- const text = edge.texts.map((one) => plainText(one.text)).filter((one) => one !== '').join(' · ');
96
- return { up: up.name, down: down.name, label: text, links: views(linksOf(edge.attrs)), ...describe(edge.attrs) };
97
- });
98
- return { nodes, edges };
99
- }
100
- /** A text as a reader sees it: markup such as `[dim]` taken out, its lines run together. */
101
- function plainText(text) {
102
- return text.replace(/\[\/?[a-z][a-z0-9-]*\]/gi, '').replace(/\s*\n\s*/g, ' ').trim();
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 };
103
17
  }
104
- /** A local time as `2026-11-05 14:32`, which sorts and reads the same everywhere. */
105
- function stamp(at) {
106
- const two = (n) => String(n).padStart(2, '0');
107
- return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
108
- }
109
- const hex = (color) => /^#[0-9a-f]{6}$/i.test(color);
110
18
  /**
111
- * What may differ between one theme's drawing and another's: colors,
112
- * opacities, and the ids made from a drawing's hash and an arrowhead's color.
113
- * Split on, its matches are the odd pieces. Matching too much costs nothing;
114
- * 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.
115
21
  */
116
- const VARIES = /(#[0-9a-f]{3,8}\b|rgba?\([^)]*\)|opacity="[^"]*"|opacity:[^;"]*|\br[0-9a-f]{8}\b|\b[0-9a-f]{6}\b)/gi;
117
- // The browser's own controls (scroll bars, focus rings) follow the page, so
118
- // a dark diagram doesn't get a light one's. A color written by hand in a
119
- // form that can't be measured leaves the choice to the browser.
120
- function schemeOf(theme) {
121
- return hex(theme.text) && hex(theme.background)
122
- ? luminance(theme.text) > luminance(theme.background)
123
- ? 'dark'
124
- : 'light'
125
- : 'light dark';
126
- }
127
- function colorsOf(theme) {
128
- return {
129
- '--page': theme.background,
130
- '--ink': theme.text,
131
- '--muted': theme.mutedText,
132
- '--rule': theme.boxStroke,
133
- '--accent': theme.primary.color,
134
- '--on-accent': theme.primary.on,
135
- };
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
+ });
136
33
  }
137
- function page({ title, name, drawn, source, findings, themes, current, graph }) {
34
+ function page(parts, editing, framing) {
35
+ const { themes, current } = parts;
138
36
  const shown = themes[current];
139
- // First the diagram's own theme, which follows the file; then every theme
140
- // as the reader's choice, dark ones together and light together, as the
141
- // playground's picker has them, so stepping through the list doesn't flash
142
- // between black and white.
143
- const group = (light) => Object.keys(themes)
144
- .filter((one) => (schemeOf(themes[one].theme) === 'light') === light)
145
- .map((one) => `<option value="${escapeHtml(one)}">${escapeHtml(one)}</option>`)
146
- .join('');
147
- const picker = `<option value="" selected>Diagram's theme (${escapeHtml(current)})</option>` +
148
- `<optgroup label="Dark">${group(false)}</optgroup><optgroup label="Light">${group(true)}</optgroup>`;
149
- // The shown theme goes in whole; another is only the colors that differ
150
- // from it, when the rest of the drawing is the same, as it is since a theme
151
- // moves nothing. That keeps the page near its size with one theme.
152
- const pack = (other, base) => {
153
- const theirs = other.split(VARIES);
154
- const ours = base.split(VARIES);
155
- const same = theirs.length === ours.length && theirs.every((piece, i) => i % 2 === 1 || piece === ours[i]);
156
- return same ? theirs.filter((_, i) => i % 2 === 1) : other;
157
- };
158
- const data = {
159
- name,
160
- source,
161
- current,
162
- varies: VARIES.source,
163
- themes: Object.fromEntries(Object.entries(themes).map(([one, view]) => [
164
- one,
165
- one === current
166
- ? { svg: view.svg, tagged: view.tagged, colors: view.colors, scheme: view.scheme }
167
- : { svg: pack(view.svg, shown.svg), tagged: pack(view.tagged, shown.tagged), colors: view.colors, scheme: view.scheme },
168
- ])),
169
- findings: findings.map((finding) => ({ kind: finding.kind, message: finding.message, line: finding.line })),
170
- graph,
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),
171
58
  };
172
- // Replaced by functions, so a `$` in a title or a drawing is taken as written.
173
- // The zoom's code goes in first, so nothing a diagram says can be taken for its marker.
174
- return PAGE.replace('{{panzoom}}', () => PAN_ZOOM)
175
- .replace('{{scheme}}', () => shown.scheme)
176
- .replace('{{title}}', () => escapeHtml(title))
177
- .replace('{{heading}}', () => escapeHtml(title))
178
- .replace('{{drawn}}', () => escapeHtml(drawn))
179
- .replace('{{colors}}', () => [
180
- ...Object.entries(shown.colors).map(([key, value]) => `${key}: ${value};`),
181
- '--sel: #e0b25a; --up: #22b98c; --down: #e5484d;',
182
- ].join(' '))
183
- .replace('{{themes}}', () => picker)
184
- .replace('{{drawing}}', () => shown.tagged)
185
- .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);
186
62
  }
187
- function escapeHtml(text) {
63
+ function escapeMarkup(text) {
188
64
  // `{` too, so a title or file name can't be taken for a later marker.
189
65
  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/\{/g, '&#123;');
190
66
  }
@@ -201,6 +77,7 @@ const PAGE = `<!doctype html>
201
77
  <meta name="color-scheme" content="{{scheme}}">
202
78
  <meta name="generator" content="reladraw">
203
79
  <title>{{title}}</title>
80
+ {{head}}
204
81
  <style>
205
82
  :root { {{colors}} }
206
83
  * { box-sizing: border-box; }
@@ -278,9 +155,56 @@ const PAGE = `<!doctype html>
278
155
  padding: 3px 6px; cursor: pointer;
279
156
  }
280
157
  select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
281
- .check { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; cursor: pointer; white-space: nowrap; }
282
- .check input { margin: 0; accent-color: var(--accent); cursor: inherit; }
283
- .check:has(input:disabled) { opacity: 0.45; cursor: default; }
158
+
159
+ /* ---- the menus, and the keys that reach them ----
160
+ What the keys act on is ringed in amber, and the amber moves down with
161
+ them: the bar after m, then the open menu and the row the arrows are on,
162
+ then the theme list. The strip under the bar says where the keys are and
163
+ how to get out. */
164
+ .menu > button { position: relative; }
165
+ kbd {
166
+ font: 11px ui-monospace, Menlo, Consolas, monospace; padding: 0 5px; border-radius: 3px;
167
+ border: 1px solid color-mix(in srgb, var(--rule) 70%, transparent); color: var(--muted);
168
+ }
169
+ kbd.tag {
170
+ display: none; position: absolute; top: -8px; right: -7px; z-index: 6;
171
+ padding: 0 4px; border: 0; font-weight: 700; background: var(--sel); color: #1a1406;
172
+ }
173
+ #full { position: relative; }
174
+ .tools.keyed { outline: 2px solid var(--sel); outline-offset: 4px; border-radius: 6px; }
175
+ .tools.keyed kbd.tag { display: inline; }
176
+ .menu > button.open { border-color: var(--sel); background: color-mix(in srgb, var(--sel) 22%, transparent); }
177
+ .menu-list { min-width: 15rem; }
178
+ .menu-list .row {
179
+ display: flex; align-items: center; gap: 8px; width: 100%; text-align: left;
180
+ border-color: transparent; padding: 5px 8px;
181
+ }
182
+ .menu-list .row .name { flex: 1; }
183
+ .menu-list .row .value { color: var(--muted); }
184
+ .menu-list .row.cur { background: color-mix(in srgb, var(--ink) 12%, transparent); box-shadow: inset 3px 0 0 var(--sel); }
185
+ /* Its rows, not the list, so the theme list inside it stays solid. */
186
+ .menu-list.behind > .row, .menu-list.behind > hr { opacity: 0.5; }
187
+ .menu-list.sub { right: calc(100% + 6px); top: 0; max-height: 70vh; overflow: auto; gap: 0; }
188
+ .menu-list .group { padding: 6px 8px 2px; font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
189
+ .menu-list .mark { width: 12px; flex: none; color: var(--up); }
190
+ /* A switch: green and to the right when on, gray and to the left when off,
191
+ with the word beside it, so the state never rests on color alone. */
192
+ .sw { flex: none; position: relative; width: 30px; height: 16px; border-radius: 9px; background: color-mix(in srgb, var(--ink) 25%, transparent); }
193
+ .sw::after { content: ''; position: absolute; top: 2px; left: 2px; width: 12px; height: 12px; border-radius: 50%; background: color-mix(in srgb, var(--ink) 55%, transparent); }
194
+ .switch[aria-checked="true"] .sw { background: #2f9e5a; }
195
+ .switch[aria-checked="true"] .sw::after { left: 16px; background: #fff; }
196
+ .switch .word { width: 2em; text-align: right; font-size: 12px; color: var(--muted); }
197
+ .switch[aria-checked="true"] .word { color: var(--up); }
198
+ .switch[aria-checked="false"] .name { color: var(--muted); }
199
+ .menu-list .row:disabled { opacity: 0.45; }
200
+ #strip {
201
+ display: flex; justify-content: space-between; gap: 16px; padding: 3px 16px; font-size: 12px;
202
+ background: color-mix(in srgb, var(--sel) 16%, var(--page)); color: var(--ink);
203
+ border-bottom: 1px solid color-mix(in srgb, var(--sel) 45%, transparent);
204
+ }
205
+ #strip[hidden] { display: none; }
206
+ #strip .crumbs b { color: var(--sel); }
207
+ #strip .hint { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
284
208
 
285
209
  /* ---- what is selected ---- */
286
210
  #selected { padding: 10px 16px 12px; overflow: auto; flex: 0 1 auto; min-height: 0; }
@@ -328,6 +252,21 @@ const PAGE = `<!doctype html>
328
252
  #view g[data-edge].up :is(line, path[fill="none"], polyline):not(.target) { stroke: var(--up); stroke-dasharray: 8 5; animation: run .9s linear infinite; }
329
253
  #view g[data-edge].down :is(line, path[fill="none"], polyline):not(.target) { stroke: var(--down); stroke-dasharray: 8 5; animation: run .9s linear infinite; }
330
254
  #view g[data-edge] .target { stroke: #000 !important; stroke-opacity: 0; }
255
+ #view svg.hinting g[data-name]:not(.hint-on) { opacity: .25 !important; }
256
+ #view svg.hinting g[data-name].hint-on { opacity: 1 !important; }
257
+ #hints { position: absolute; inset: 0; pointer-events: none; z-index: 4; }
258
+ .hintkey { font: 700 12px/16px ui-monospace, Menlo, Consolas, monospace; padding: 0 4px; border-radius: 3px;
259
+ background: var(--sel); color: #1a1406; box-shadow: 0 0 0 1px rgba(0, 0, 0, .55); white-space: nowrap; }
260
+ #hints .hintkey { position: absolute; left: 0; top: 0; }
261
+ .hintkey .done { opacity: .45; }
262
+ .hintkey.gone { visibility: hidden; }
263
+ #selected a.link .hintkey { margin-right: 2px; }
264
+ #link-pop { position: absolute; z-index: 5; display: flex; flex-direction: column; gap: 2px; padding: 6px 8px; font-size: 13px;
265
+ background: var(--page); border: 1px solid var(--sel); border-radius: 6px; box-shadow: 0 4px 16px rgba(0, 0, 0, .35); }
266
+ #link-pop a.link { display: flex; align-items: center; gap: 6px; color: var(--ink); text-decoration: none; }
267
+ #link-pop a.link svg { flex: none; }
268
+ #strip .query { color: var(--ink); }
269
+ #strip .query i { font-style: normal; color: var(--sel); }
331
270
  @keyframes run { to { stroke-dashoffset: -26; } }
332
271
  @media (prefers-reduced-motion: reduce) { #view g[data-edge] :is(line, path, polyline) { animation: none !important; } }
333
272
  aside h2 { margin: 0; padding: 10px 16px 6px; font-size: 13px; font-weight: 600; }
@@ -338,10 +277,137 @@ const PAGE = `<!doctype html>
338
277
  padding: 6px 8px; margin: 2px 0;
339
278
  }
340
279
  aside li button.chosen { border-color: var(--accent); }
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; }
341
337
  .where { color: var(--muted); font-variant-numeric: tabular-nums; }
342
338
  .kind { color: var(--accent); }
343
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
+ }
344
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); }
345
411
  main { flex-direction: column; }
346
412
  #side { width: auto; max-height: 45%; border-left: 0; border-top: 1px solid color-mix(in srgb, var(--rule) 45%, transparent); }
347
413
  }
@@ -351,35 +417,69 @@ const PAGE = `<!doctype html>
351
417
  <header>
352
418
  <div class="heading">
353
419
  <h1>{{heading}}</h1>
354
- <p class="drawn">{{drawn}}</p>
420
+ <p class="drawn">{{drawn}}</p>{{links}}
355
421
  </div>
356
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>
357
426
  <button id="out" type="button" title="Zoom out (-)" aria-label="Zoom out">−</button>
358
427
  <span id="scale" aria-live="polite">100%</span>
359
428
  <button id="in" type="button" title="Zoom in (+)" aria-label="Zoom in">+</button>
360
429
  <button id="fit" type="button" title="The whole diagram in view (0)">Fit</button>
361
430
  <button id="actual" type="button" title="The drawing at its own scale (1)">100%</button>
362
431
  <span class="gap"></span>
363
- <button id="full" type="button" title="Full screen (f)">Full screen</button>
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>
364
437
  <span class="menu">
365
- <button id="save" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="save-list">Save ▾</button>
366
- <span id="save-list" class="menu-list" hidden>
367
- <button id="save-page" type="button" title="Save this page as one HTML file, to keep or send">This page</button>
368
- <button id="save-source" type="button" title="Save the .reladraw file this page was drawn from">.reladraw source</button>
438
+ <button id="save" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="save-list">Export ▾<kbd class="tag">x</kbd></button>
439
+ <span id="save-list" class="menu-list" role="menu" hidden>
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>
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>
369
442
  <hr>
370
- <button id="save-svg" type="button" title="Save the picture as an SVG file">SVG</button>
371
- <button id="save-png" type="button" title="Save the picture as a PNG, at twice its size">PNG</button>
372
- <button id="copy-svg" type="button" title="Copy the picture's SVG to the clipboard">Copy SVG</button>
443
+ <button id="save-svg" class="row" type="button" role="menuitem" data-key="s" title="Save the picture as an SVG file"><span class="name">SVG</span><kbd>s</kbd></button>
444
+ <button id="save-png" class="row" type="button" role="menuitem" data-key="n" title="Save the picture as a PNG, at twice its size"><span class="name">PNG</span><kbd>n</kbd></button>
445
+ <button id="copy-svg" class="row" type="button" role="menuitem" data-key="c" title="Copy the picture's SVG to the clipboard"><span class="name">Copy SVG</span><kbd>c</kbd></button>
446
+ </span>
447
+ </span>
448
+ <span class="menu">
449
+ <button id="view-button" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="view-list">View ▾<kbd class="tag">v</kbd></button>
450
+ <span id="view-list" class="menu-list" role="menu" hidden>
451
+ <button id="theme-row" class="row" type="button" role="menuitem" data-key="t" aria-haspopup="true" title="Draw the diagram in another theme"><span class="name">Theme <span class="value"></span> ▸</span><kbd>t</kbd></button>
452
+ <hr>
453
+ <button id="panel-row" class="row switch" type="button" role="menuitemcheckbox" data-key="p" title="Show or hide the side panel"><span class="sw"></span><span class="name">Side panel</span><span class="word"></span><kbd>p</kbd></button>
454
+ <button id="auto-row" class="row switch" type="button" role="menuitemcheckbox" data-key="a" title="Show the side panel only while something is selected or the findings are open"><span class="sw"></span><span class="name">Auto-hide panel</span><span class="word"></span><kbd>a</kbd></button>
455
+ <hr>
456
+ <button id="findings-row" class="row switch" type="button" role="menuitemcheckbox" data-key="f" title="Show or hide the findings in the side panel"><span class="sw"></span><span class="name">Findings</span><span class="word"></span><kbd>f</kbd></button>
457
+ <hr>
458
+ <button id="guides-row" class="row switch" type="button" role="menuitemcheckbox" data-key="g" title="Draw the guides: everything written with guide in front"><span class="sw"></span><span class="name">Guides</span><span class="word"></span><kbd>g</kbd></button>
459
+ <span id="theme-list" class="menu-list sub" role="menu" hidden></span>
373
460
  </span>
374
461
  </span>
375
- <select id="theme" aria-label="Theme" title="Draw the diagram in another theme">{{themes}}</select>
376
- <span class="gap"></span>
377
- <button id="toggle-panel" type="button" aria-controls="side" title="Show or hide the side panel (p)">Panel</button>
378
- <label class="check" title="Show the side panel only while something is selected or the findings are open"><input id="auto-hide" type="checkbox"> Auto-hide</label>
379
- <button id="toggle-findings" type="button" aria-controls="findings"></button>
380
462
  </div>
381
463
  </header>
464
+ <div id="strip" hidden><span class="crumbs"></span><span class="hint"></span></div>
465
+ <div id="live" role="status" hidden></div>
382
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>
383
483
  <div id="view">
384
484
  <div id="stage">
385
485
  {{drawing}}
@@ -395,13 +495,18 @@ const PAGE = `<!doctype html>
395
495
  <ol id="finding-list"></ol>
396
496
  </aside>
397
497
  </div>
398
- </main>
498
+ </main>{{dialogs}}
499
+ {{library}}
399
500
  <script>
400
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;
401
505
  // The page as written, taken before anything below changes it, so This
402
506
  // page saves what reladraw wrote rather than the current zoom and highlights.
403
- var written = '<!doctype html>\\n' + document.documentElement.outerHTML + '\\n';
404
- 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');
405
510
  var view = document.getElementById('view');
406
511
  var stage = document.getElementById('stage');
407
512
  var svg = stage.querySelector('svg');
@@ -411,7 +516,10 @@ const PAGE = `<!doctype html>
411
516
  // Another theme is carried as the colors that differ from the first, put
412
517
  // back in place the first time it is shown.
413
518
  var VARIES = new RegExp(DATA.varies, 'gi');
414
- var first = DATA.themes[DATA.current];
519
+ // Whether guides are drawn, which the View menu's switch changes. The
520
+ // drawings with guides the other way are a second set, packed the same way
521
+ // against its own first theme.
522
+ var guidesShown = DATA.guidesShown;
415
523
  function unpack(packed, base) {
416
524
  if (typeof packed === 'string') return packed;
417
525
  var pieces = base.split(VARIES);
@@ -419,8 +527,10 @@ const PAGE = `<!doctype html>
419
527
  return pieces.join('');
420
528
  }
421
529
  function themed(name) {
422
- var view = DATA.themes[name];
530
+ var set = guidesShown === DATA.guidesShown || !DATA.flipped ? DATA.themes : DATA.flipped;
531
+ var view = set[name];
423
532
  if (!view.unpacked) {
533
+ var first = set[DATA.current];
424
534
  view.svg = unpack(view.svg, first.svg);
425
535
  view.tagged = unpack(view.tagged, first.tagged);
426
536
  view.unpacked = true;
@@ -448,7 +558,7 @@ const PAGE = `<!doctype html>
448
558
  }
449
559
  full.onclick = toggleFull;
450
560
  document.addEventListener('fullscreenchange', function () {
451
- full.textContent = document.fullscreenElement ? 'Exit full screen' : 'Full screen';
561
+ full.querySelector('.label').textContent = document.fullscreenElement ? 'Exit full screen' : 'Full screen';
452
562
  });
453
563
 
454
564
  /* ---- saving ---- */
@@ -466,9 +576,10 @@ const PAGE = `<!doctype html>
466
576
  }
467
577
 
468
578
  function flash(button, text) {
469
- var was = button.textContent;
470
- button.textContent = text;
471
- setTimeout(function () { button.textContent = was; }, 1200);
579
+ var label = button.querySelector('.name') || button;
580
+ var was = label.textContent;
581
+ label.textContent = text;
582
+ setTimeout(function () { label.textContent = was; }, 1200);
472
583
  }
473
584
 
474
585
  // Chrome and Edge ask where to save and under what name, as the
@@ -505,31 +616,30 @@ const PAGE = `<!doctype html>
505
616
  return Promise.resolve(blob);
506
617
  }
507
618
 
508
- // 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
509
620
  // source, and the picture. The list stays open after a choice, so "Copied"
510
- // can be read, and closes on a click elsewhere or Escape.
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.
511
623
  var saveButton = document.getElementById('save');
512
624
  var saveList = document.getElementById('save-list');
513
- function showSave(open) {
514
- saveList.hidden = !open;
515
- saveButton.setAttribute('aria-expanded', String(open));
516
- }
517
- saveButton.onclick = function () { showSave(saveList.hidden); };
518
- document.addEventListener('click', function (event) {
519
- if (!saveList.hidden && !event.target.closest('.menu')) showSave(false);
520
- });
521
625
 
522
626
  var savePage = document.getElementById('save-page');
523
627
  savePage.onclick = function () {
524
628
  saveFile(savePage, DATA.name + '.html', 'Web page', 'text/html', function () {
525
- 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
+ });
526
636
  });
527
637
  };
528
638
 
529
639
  var saveSource = document.getElementById('save-source');
530
640
  saveSource.onclick = function () {
531
641
  saveFile(saveSource, DATA.name + '.reladraw', 'reladraw source', 'text/plain', function () {
532
- return ready(new Blob([DATA.source], { type: 'text/plain' }));
642
+ return ready(new Blob([EDIT ? sourceBox.value : DATA.source], { type: 'text/plain' }));
533
643
  });
534
644
  };
535
645
 
@@ -620,17 +730,26 @@ const PAGE = `<!doctype html>
620
730
 
621
731
  var panel = document.getElementById('findings');
622
732
  var list = document.getElementById('finding-list');
623
- var toggle = document.getElementById('toggle-findings');
624
733
  var count = DATA.findings.length;
625
734
 
735
+ // A row of the View menu that is on or off: its switch, and the word beside it.
736
+ function setSwitch(row, on) {
737
+ row.setAttribute('aria-checked', String(on));
738
+ row.querySelector('.word').textContent = on ? 'on' : 'off';
739
+ }
740
+ var panelRow = document.getElementById('panel-row');
741
+ var autoRow = document.getElementById('auto-row');
742
+ var findingsRow = document.getElementById('findings-row');
743
+ var guidesRow = document.getElementById('guides-row');
744
+ findingsRow.querySelector('.name').textContent = 'Findings (' + count + ')';
745
+ guidesRow.querySelector('.name').textContent = 'Guides (' + DATA.guideCount + ')';
746
+
626
747
  /* ---- the side panel: shown, hidden, or shown only when wanted ---- */
627
748
 
628
749
  // Hidden, it stays hidden whatever is selected. Auto-hide shows it only
629
750
  // while something is selected or the findings are open. Both choices are
630
751
  // remembered in this browser for every reladraw page.
631
752
  var side = document.getElementById('side');
632
- var panelButton = document.getElementById('toggle-panel');
633
- var autoHideBox = document.getElementById('auto-hide');
634
753
  var KEPT = 'reladraw-page-panel';
635
754
  var panelOn = true;
636
755
  var autoHide = false;
@@ -645,42 +764,40 @@ const PAGE = `<!doctype html>
645
764
  side.hidden = !(panelOn && (!autoHide || selection || !panel.hidden));
646
765
  // A finding lights what it is about only while the finding can be seen.
647
766
  if ((panel.hidden || side.hidden) && lit && lit.length) unlight();
648
- panelButton.setAttribute('aria-pressed', String(panelOn));
649
- if (!toggle.disabled) toggle.setAttribute('aria-pressed', String(panelOn && !panel.hidden));
650
- autoHideBox.checked = autoHide;
651
- autoHideBox.disabled = !panelOn;
767
+ setSwitch(panelRow, panelOn);
768
+ setSwitch(autoRow, autoHide);
769
+ autoRow.disabled = !panelOn;
770
+ setSwitch(findingsRow, panelOn && !panel.hidden);
652
771
  }
653
772
  function setPanel(on) {
654
773
  panelOn = on;
655
774
  keepPanel();
656
775
  layoutSide();
657
776
  }
658
- panelButton.onclick = function () { setPanel(!panelOn); };
659
- autoHideBox.onchange = function () {
660
- autoHide = autoHideBox.checked;
777
+ function setAutoHide(on) {
778
+ if (!panelOn) return;
779
+ autoHide = on;
661
780
  keepPanel();
662
781
  layoutSide();
663
- };
782
+ }
783
+ panelRow.onclick = function () { setPanel(!panelOn); };
784
+ autoRow.onclick = function () { setAutoHide(!autoHide); };
664
785
 
665
786
  function showPanel(open) {
666
787
  panel.hidden = !open;
667
- toggle.setAttribute('aria-pressed', String(open));
668
788
  layoutSide();
669
789
  }
790
+ // Asking for the findings shows the panel they are in.
791
+ function flipFindings() {
792
+ if (!panelOn) { panelOn = true; keepPanel(); showPanel(true); }
793
+ else showPanel(panel.hidden);
794
+ }
795
+ findingsRow.onclick = flipFindings;
670
796
  if (count === 0) {
671
- toggle.textContent = 'No findings';
672
- toggle.disabled = true;
673
797
  panel.hidden = true;
674
798
  } else {
675
- toggle.textContent = count === 1 ? '1 finding' : count + ' findings';
676
- // Asking for the findings shows the panel they are in.
677
- toggle.onclick = function () {
678
- if (!panelOn) { panelOn = true; keepPanel(); showPanel(true); }
679
- else showPanel(panel.hidden);
680
- };
681
799
  // Open from the start, unless the panel is to stay out of the way.
682
800
  panel.hidden = autoHide;
683
- toggle.setAttribute('aria-pressed', String(!autoHide));
684
801
  }
685
802
 
686
803
  var lit = [];
@@ -717,7 +834,41 @@ const PAGE = `<!doctype html>
717
834
  zoom.pan((room.left + room.width / 2) - (r.l + r.r) / 2, (room.top + room.height / 2) - (r.t + r.b) / 2);
718
835
  }
719
836
 
720
- 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) {
721
872
  var item = document.createElement('li');
722
873
  var button = document.createElement('button');
723
874
  button.type = 'button';
@@ -734,10 +885,15 @@ const PAGE = `<!doctype html>
734
885
  note.textContent = 'Nothing drawn comes from that line.';
735
886
  button.append(note);
736
887
  }
737
- 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
+ };
738
893
  item.append(button);
739
894
  list.append(item);
740
- });
895
+ }
896
+ listFindings(null);
741
897
 
742
898
  /* ---- selecting a box or a line ---- */
743
899
 
@@ -938,24 +1094,17 @@ const PAGE = `<!doctype html>
938
1094
  if (link && svg.contains(link)) event.preventDefault();
939
1095
  });
940
1096
 
941
- /* ---- the theme picker ---- */
1097
+ /* ---- drawing again: another theme, or guides the other way ---- */
942
1098
 
943
- // "Diagram's theme" follows the file, so an edit to its theme shows on the
944
- // next render. Any other choice is the reader's, and holds on every reladraw
945
- // page in this browser, whatever its file says, until the diagram's theme
946
- // is chosen again.
947
- var picker = document.getElementById('theme');
948
1099
  var schemeMeta = document.querySelector('meta[name="color-scheme"]');
949
- var CHOSEN_THEME = 'reladraw-page-theme';
950
1100
 
951
- // The same drawing in other colors: nothing moves, so the view, the
952
- // selection and the panel stay as they were.
953
- function showTheme(name) {
954
- if (!DATA.themes[name] || name === shownTheme) return;
955
- shownTheme = name;
1101
+ // Neither a theme nor guides move anything, so the view, the selection and
1102
+ // the panel stay as they were.
1103
+ function redraw() {
956
1104
  var next = shown();
957
1105
  Object.keys(next.colors).forEach(function (key) { document.documentElement.style.setProperty(key, next.colors[key]); });
958
1106
  schemeMeta.setAttribute('content', next.scheme);
1107
+ document.documentElement.setAttribute('data-scheme', next.scheme === 'light' ? 'light' : 'dark');
959
1108
  unlight();
960
1109
  hovered = null;
961
1110
  tip.style.display = 'none';
@@ -966,35 +1115,1456 @@ const PAGE = `<!doctype html>
966
1115
  select(selection);
967
1116
  }
968
1117
 
969
- picker.onchange = function () {
1118
+ function showTheme(name) {
1119
+ if (!DATA.themes[name] || name === shownTheme) return;
1120
+ shownTheme = name;
1121
+ redraw();
1122
+ }
1123
+
1124
+ // Guides start as the file or --guides says, and a flip lasts for this page
1125
+ // only: guides show how one diagram was built, not how a reader likes to
1126
+ // read. With none in the diagram the switch still works, and draws nothing new.
1127
+ function setGuides(on) {
1128
+ if (on === guidesShown) return;
1129
+ guidesShown = on;
1130
+ setSwitch(guidesRow, on);
1131
+ if (DATA.flipped) redraw();
1132
+ }
1133
+ setSwitch(guidesRow, guidesShown);
1134
+ guidesRow.onclick = function () { setGuides(!guidesShown); };
1135
+
1136
+ /* ---- the theme list ---- */
1137
+
1138
+ // "Diagram's theme" follows the file, so an edit to its theme shows on the
1139
+ // next render. Any other choice is the reader's, and holds on every reladraw
1140
+ // page in this browser, whatever its file says, until the diagram's theme
1141
+ // is chosen again.
1142
+ var CHOSEN_THEME = 'reladraw-page-theme';
1143
+ var chosenName = '';
1144
+ var themeRow = document.getElementById('theme-row');
1145
+ var themeList = document.getElementById('theme-list');
1146
+ var themeItems = [];
1147
+
1148
+ function addThemeItem(value, label) {
1149
+ var row = element('button', 'row');
1150
+ row.type = 'button';
1151
+ row.setAttribute('role', 'menuitemradio');
1152
+ row.append(element('span', 'mark'), element('span', 'name', label));
1153
+ row.onclick = function () { keepTheme(value); };
1154
+ themeItems.push({ value: value, row: row });
1155
+ themeList.append(row);
1156
+ }
1157
+ addThemeItem('', "Diagram's theme (" + DATA.current + ')');
1158
+ [['Dark', DATA.themeGroups.dark], ['Light', DATA.themeGroups.light]].forEach(function (group) {
1159
+ if (group[1].length === 0) return;
1160
+ themeList.append(element('span', 'group', group[0]));
1161
+ group[1].forEach(function (name) { addThemeItem(name, name); });
1162
+ });
1163
+
1164
+ function showChosen() {
1165
+ themeRow.querySelector('.value').textContent = '· ' + (chosenName || "Diagram's theme");
1166
+ themeItems.forEach(function (item) {
1167
+ var on = item.value === chosenName;
1168
+ item.row.setAttribute('aria-checked', String(on));
1169
+ item.row.querySelector('.mark').textContent = on ? '\\u25CF' : '';
1170
+ });
1171
+ }
1172
+
1173
+ function keepTheme(value) {
1174
+ chosenName = value;
970
1175
  try {
971
- if (picker.value) localStorage.setItem(CHOSEN_THEME, picker.value);
1176
+ if (value) localStorage.setItem(CHOSEN_THEME, value);
972
1177
  else localStorage.removeItem(CHOSEN_THEME);
973
1178
  } catch (e) {}
974
- showTheme(picker.value || DATA.current);
975
- };
1179
+ showTheme(value || DATA.current);
1180
+ showChosen();
1181
+ if (level === 'theme') setLevel('view', true);
1182
+ }
976
1183
 
977
1184
  // A theme chosen on an earlier page, if this page has it.
978
1185
  try {
979
- var chosenTheme = localStorage.getItem(CHOSEN_THEME);
980
- if (chosenTheme && DATA.themes[chosenTheme]) {
981
- picker.value = chosenTheme;
982
- showTheme(chosenTheme);
1186
+ var kept = localStorage.getItem(CHOSEN_THEME);
1187
+ if (kept && DATA.themes[kept]) {
1188
+ chosenName = kept;
1189
+ showTheme(kept);
983
1190
  }
984
1191
  } catch (e) {}
1192
+ showChosen();
1193
+
1194
+ /* ---- the menus, by pointer and by key ---- */
1195
+
1196
+ // The keys work in levels: the diagram; m, the bar, each control wearing
1197
+ // its letter; then a menu, View or Save; then, from View, the theme list.
1198
+ // A letter means what the level it is pressed in says, so no letter is used
1199
+ // up however deep the menus go. Keys that aren't letters mean the same at
1200
+ // every level: Backspace steps back one level, and beneath the menus lets
1201
+ // go of a grab, then a lit finding, then the selection; Escape goes straight
1202
+ // back to the plain diagram from anywhere; Enter does what the row the
1203
+ // arrows are on does. Opening a menu with a click puts the keys there too.
1204
+ var tools = document.querySelector('.tools');
1205
+ var viewButton = document.getElementById('view-button');
1206
+ var viewList = document.getElementById('view-list');
1207
+ var versionsButton = document.getElementById('versions-button');
1208
+ var versionsList = document.getElementById('versions-list');
1209
+ var strip = document.getElementById('strip');
1210
+ var level = null;
1211
+ var cursor = 0;
1212
+ var themeBefore = null;
1213
+
1214
+ var STRIP = {
1215
+ bar: ['<b>Menu</b>', 'letter to open \\u00B7 \\u232B back \\u00B7 Esc out'],
1216
+ view: ['Menu \\u203A <b>View</b>', 'letter to flip \\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'],
1219
+ theme: ['Menu \\u203A View \\u203A <b>Theme</b>', '\\u2191\\u2193 to try \\u00B7 Enter keep \\u00B7 \\u232B back \\u00B7 Esc out'],
1220
+ };
1221
+
1222
+ function rows() {
1223
+ if (level === 'view') return Array.prototype.slice.call(viewList.querySelectorAll(':scope > .row'));
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'));
1226
+ if (level === 'theme') return themeItems.map(function (item) { return item.row; });
1227
+ return [];
1228
+ }
1229
+
1230
+ function markCursor() {
1231
+ each(document.querySelectorAll('.menu-list .row.cur'), function (row) { row.classList.remove('cur'); });
1232
+ var all = rows();
1233
+ if (!all.length) return;
1234
+ cursor = Math.max(0, Math.min(cursor, all.length - 1));
1235
+ all[cursor].classList.add('cur');
1236
+ if (level === 'theme') all[cursor].scrollIntoView({ block: 'nearest' });
1237
+ }
1238
+
1239
+ // \`kept\`: leaving the theme list keeps the theme moved to, as Enter or a
1240
+ // click does; any other way out goes back to the theme shown before.
1241
+ function setLevel(next, kept) {
1242
+ if (level === 'theme' && next !== 'theme' && !kept && themeBefore !== null) showTheme(themeBefore);
1243
+ if (next === 'theme' && level !== 'theme') {
1244
+ themeBefore = shownTheme;
1245
+ cursor = 0;
1246
+ themeItems.forEach(function (item, i) { if (item.value === chosenName) cursor = i; });
1247
+ } else if (next !== level) {
1248
+ // Back from the theme list, the arrows are on Theme, the first row.
1249
+ cursor = 0;
1250
+ }
1251
+ level = next;
1252
+ tools.classList.toggle('keyed', level === 'bar');
1253
+ saveList.hidden = level !== 'save';
1254
+ saveButton.classList.toggle('open', level === 'save');
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'));
1259
+ viewList.hidden = level !== 'view' && level !== 'theme';
1260
+ viewButton.classList.toggle('open', level === 'view' || level === 'theme');
1261
+ viewButton.setAttribute('aria-expanded', String(level === 'view' || level === 'theme'));
1262
+ themeList.hidden = level !== 'theme';
1263
+ viewList.classList.toggle('behind', level === 'theme');
1264
+ strip.hidden = level === null;
1265
+ if (level && STRIP[level]) {
1266
+ strip.querySelector('.crumbs').innerHTML = STRIP[level][0];
1267
+ strip.querySelector('.hint').textContent = STRIP[level][1];
1268
+ }
1269
+ if (level !== 'pick' && level !== 'filter') clearHints();
1270
+ markCursor();
1271
+ }
1272
+
1273
+ function back() {
1274
+ if (level === 'theme') setLevel('view');
1275
+ else if (level === 'view' || level === 'save' || level === 'versions') setLevel('bar');
1276
+ else setLevel(null);
1277
+ }
1278
+
1279
+ // Escape: the plain diagram, in one press, whatever was open or lit.
1280
+ function plain() {
1281
+ setLevel(null);
1282
+ zoom.grab(false);
1283
+ unlight();
1284
+ select(null);
1285
+ }
1286
+
1287
+ /* ---- picking from the keyboard: letters on things ---- */
1288
+
1289
+ // s puts a letter on everything, / starts a filter that narrows what gets
1290
+ // one, and with something selected n letters what one edge away from it,
1291
+ // i what is inside it and the box it sits in, and l its links. Typing a
1292
+ // letter picks. These are two more levels: 'pick', where letters pick, and
1293
+ // 'filter', where everything typed goes into the filter until Enter. A
1294
+ // filter's plain words match a thing's text; is:node, is:edge,
1295
+ // is:container and style:name narrow by kind and style.
1296
+ var hint = null;
1297
+ var hintLayer = element('div', '');
1298
+ hintLayer.id = 'hints';
1299
+ view.append(hintLayer);
1300
+ var popup = null;
1301
+ var frame = 0;
1302
+ var noticeTimer = 0;
1303
+ var MODE_WORD = { all: 'Select', neighbors: 'Neighbors', inside: 'Inside', links: 'Links' };
1304
+ var KINDS = ['node', 'edge', 'container'];
1305
+
1306
+ function shownThing(name) {
1307
+ var node = graph.nodes[name];
1308
+ return node && !node.badge && (!node.guide || guidesShown);
1309
+ }
1310
+ function shownEdge(edge) { return !edge.guide || guidesShown; }
1311
+ function nameOf(target) {
1312
+ if (target.kind === 'node') return graph.nodes[target.key].label;
1313
+ var edge = graph.edges[target.key];
1314
+ return edge.label || graph.nodes[edge.up].label + ' \\u2192 ' + graph.nodes[edge.down].label;
1315
+ }
1316
+
1317
+ // Where a thing's letter goes, in the drawing's own units: a box's top-left
1318
+ // corner, or the middle of its top edge when a child's letter already holds
1319
+ // the corner; bare text's, just left of it; a line's, somewhere along it
1320
+ // clear of its text.
1321
+ var anchors = { svg: null, at: {} };
1322
+ function anchorOf(target) {
1323
+ if (anchors.svg !== svg) anchors = { svg: svg, at: {} };
1324
+ var id = target.kind + ' ' + target.key;
1325
+ if (anchors.at[id]) return anchors.at[id];
1326
+ var at = null;
1327
+ if (target.kind === 'node') {
1328
+ var node = graph.nodes[target.key];
1329
+ var box = node.box;
1330
+ var crowded = node.children.some(function (child) {
1331
+ var other = graph.nodes[child].box;
1332
+ return Math.abs(other[0] - box[0]) < 8 && Math.abs(other[1] - box[1]) < 8;
1333
+ });
1334
+ if (node.bare) at = { x: box[0], y: box[1] + box[3] / 2, el: svg, side: 'left' };
1335
+ else at = { x: crowded ? box[0] + box[2] / 2 : box[0] + 2, y: box[1] + 2, el: svg };
1336
+ } else {
1337
+ var group = edgeGroups(target.key)[0];
1338
+ var stroke = group && group.querySelector('line:not(.target), path[fill="none"]:not(.target), polyline:not(.target)');
1339
+ if (stroke && stroke.getTotalLength) {
1340
+ var texts = Array.prototype.map.call(group.querySelectorAll('text'), function (text) { return text.getBBox(); });
1341
+ var length = stroke.getTotalLength();
1342
+ var clear = function (point) {
1343
+ return texts.every(function (box) {
1344
+ return point.x < box.x - 12 || point.x > box.x + box.width + 12 || point.y < box.y - 12 || point.y > box.y + box.height + 12;
1345
+ });
1346
+ };
1347
+ var point = null;
1348
+ [0.3, 0.7, 0.2, 0.8, 0.5, 0.12, 0.88].some(function (along) {
1349
+ var tried = stroke.getPointAtLength(length * along);
1350
+ if (!point) point = tried;
1351
+ if (clear(tried)) { point = tried; return true; }
1352
+ return false;
1353
+ });
1354
+ at = { x: point.x, y: point.y, el: stroke };
1355
+ }
1356
+ }
1357
+ anchors.at[id] = at;
1358
+ return at;
1359
+ }
1360
+ // The same, in pixels from the view's top-left corner, or null.
1361
+ function onScreen(target) {
1362
+ var at = anchorOf(target);
1363
+ if (!at) return null;
1364
+ var matrix = at.el.getScreenCTM();
1365
+ if (!matrix) return null;
1366
+ var point = new DOMPoint(at.x, at.y).matrixTransform(matrix);
1367
+ var room = view.getBoundingClientRect();
1368
+ return { x: point.x - room.left, y: point.y - room.top, side: at.side, inView: point.x >= room.left && point.x <= room.right && point.y >= room.top && point.y <= room.bottom };
1369
+ }
1370
+
1371
+ // Things in view come first, so the one-letter labels go to what can be
1372
+ // seen; then top to bottom and left to right. Boxes before lines.
1373
+ function ordered(targets) {
1374
+ // Links keep the order the source writes them in, as the panel lists them.
1375
+ if (targets.length && targets[0].kind === 'link') return targets;
1376
+ var placed = targets.map(function (target, i) { return { target: target, at: onScreen(target) || { x: 0, y: 0, inView: false }, i: i }; });
1377
+ placed.sort(function (a, b) {
1378
+ if (a.target.kind !== b.target.kind) return a.target.kind === 'node' ? -1 : 1;
1379
+ if (a.at.inView !== b.at.inView) return a.at.inView ? -1 : 1;
1380
+ return Math.round(a.at.y / 24) - Math.round(b.at.y / 24) || a.at.x - b.at.x;
1381
+ });
1382
+ return placed.map(function (one) { return one.target; });
1383
+ }
1384
+
1385
+ function allTargets() {
1386
+ var out = [];
1387
+ Object.keys(graph.nodes).forEach(function (name) { if (shownThing(name)) out.push({ kind: 'node', key: name }); });
1388
+ graph.edges.forEach(function (edge, index) { if (shownEdge(edge)) out.push({ kind: 'edge', key: index }); });
1389
+ return out;
1390
+ }
1391
+ function neighborTargets() {
1392
+ var nodes = [], edges = [];
1393
+ function addNode(name) {
1394
+ if (shownThing(name) && !(selection.kind === 'node' && name === selection.key) && nodes.indexOf(name) < 0) nodes.push(name);
1395
+ }
1396
+ if (selection.kind === 'edge') {
1397
+ var edge = graph.edges[selection.key];
1398
+ addNode(edge.up);
1399
+ addNode(edge.down);
1400
+ } else {
1401
+ graph.edges.forEach(function (edge, index) {
1402
+ if (!shownEdge(edge) || (edge.up !== selection.key && edge.down !== selection.key)) return;
1403
+ edges.push(index);
1404
+ addNode(edge.up === selection.key ? edge.down : edge.up);
1405
+ });
1406
+ }
1407
+ return nodes.map(function (name) { return { kind: 'node', key: name }; })
1408
+ .concat(edges.map(function (index) { return { kind: 'edge', key: index }; }));
1409
+ }
1410
+ function insideTargets() {
1411
+ var node = graph.nodes[selection.key];
1412
+ var out = node.children.filter(shownThing);
1413
+ if (node.parent && shownThing(node.parent)) out.push(node.parent);
1414
+ return out.map(function (name) { return { kind: 'node', key: name }; });
1415
+ }
1416
+ function linkTargets() {
1417
+ var thing = selection.kind === 'node' ? graph.nodes[selection.key] : graph.edges[selection.key];
1418
+ return thing.links.map(function (link, index) { return { kind: 'link', key: index, link: link }; });
1419
+ }
1420
+
1421
+ // As many one-letter labels as there are letters; past that, letters from
1422
+ // the end of the alphabet start two-letter ones, as few as will do.
1423
+ function codes(count) {
1424
+ var letters = 'abcdefghijklmnopqrstuvwxyz';
1425
+ if (count <= 26) return letters.slice(0, count).split('');
1426
+ for (var prefixes = 1; prefixes <= 26; prefixes++) {
1427
+ if (26 - prefixes + 26 * prefixes < count) continue;
1428
+ var out = letters.slice(0, 26 - prefixes).split('');
1429
+ for (var p = 0; out.length < count; p++) {
1430
+ for (var q = 0; q < 26 && out.length < count; q++) out.push(letters[25 - p] + letters[q]);
1431
+ }
1432
+ return out;
1433
+ }
1434
+ var all = [''];
1435
+ while (all.length < count) {
1436
+ var longer = [];
1437
+ all.forEach(function (start) { for (var i = 0; i < 26; i++) longer.push(start + letters[i]); });
1438
+ all = longer;
1439
+ }
1440
+ return all.slice(0, count);
1441
+ }
1442
+
1443
+ // The styles the diagram uses, which a filter's style: names; made again
1444
+ // when an edited page redraws.
1445
+ var styleNames = {};
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();
1452
+
1453
+ // The filter: every word must match. Returns the matches, and a message
1454
+ // when a word can match nothing at all, naming what it accepts.
1455
+ function filtered(targets, query) {
1456
+ var words = query.toLowerCase().split(' ').filter(function (word) { return word !== ''; });
1457
+ var message = '';
1458
+ var tests = words.map(function (word) {
1459
+ var colon = word.indexOf(':');
1460
+ var key = colon > 0 ? word.slice(0, colon) : '';
1461
+ var value = colon > 0 ? word.slice(colon + 1) : '';
1462
+ if (key === 'is') {
1463
+ var kinds = KINDS.filter(function (kind) { return kind.indexOf(value) === 0; });
1464
+ if (kinds.length === 0) message = 'is: takes node, edge or container';
1465
+ return function (target, thing) {
1466
+ if (value === '') return true;
1467
+ return kinds.some(function (kind) {
1468
+ if (kind === 'edge') return target.kind === 'edge';
1469
+ if (kind === 'node') return target.kind === 'node';
1470
+ return target.kind === 'node' && thing.children.length > 0;
1471
+ });
1472
+ };
1473
+ }
1474
+ if (key === 'style') {
1475
+ var names = Object.keys(styleNames).filter(function (name) { return name.toLowerCase().indexOf(value) === 0; });
1476
+ if (names.length === 0) message = 'no style named "' + value + '"';
1477
+ return function (target, thing) {
1478
+ return value === '' || (thing.styles || []).some(function (name) { return names.indexOf(name) >= 0; });
1479
+ };
1480
+ }
1481
+ if (key !== '' && /^[a-z]+$/.test(key)) {
1482
+ message = key + ': is not a filter; the filters are is: and style:';
1483
+ return function () { return false; };
1484
+ }
1485
+ return function (target, thing) { return (thing.label || '').toLowerCase().indexOf(word) >= 0; };
1486
+ });
1487
+ var matches = targets.filter(function (target) {
1488
+ var thing = target.kind === 'node' ? graph.nodes[target.key] : target.kind === 'edge' ? graph.edges[target.key] : target.link;
1489
+ return tests.every(function (test) { return test(target, thing); });
1490
+ });
1491
+ return { matches: matches, message: message };
1492
+ }
1493
+
1494
+ function startHints(mode, filtering) {
1495
+ var base;
1496
+ if (mode === 'all') base = allTargets();
1497
+ else if (!selection) { notice('Select something first: s, then a letter'); return; }
1498
+ else if (mode === 'inside' && selection.kind === 'edge') { notice('A line has nothing inside it'); return; }
1499
+ else base = mode === 'neighbors' ? neighborTargets() : mode === 'inside' ? insideTargets() : linkTargets();
1500
+ if (base.length === 0) {
1501
+ notice(mode === 'links' ? nameOf(selection) + ' has no links' : mode === 'inside' ? nameOf(selection) + ' has nothing inside it and sits in nothing' : nameOf(selection) + ' has no lines');
1502
+ return;
1503
+ }
1504
+ hint = { mode: mode, base: base, query: '', typed: '', filtering: !!filtering, direct: !!filtering, shown: [], message: '' };
1505
+ setLevel(filtering ? 'filter' : 'pick');
1506
+ refresh();
1507
+ }
1508
+
1509
+ function clearHints() {
1510
+ if (!hint && !popup) return;
1511
+ hint = null;
1512
+ hintLayer.textContent = '';
1513
+ if (popup) { popup.remove(); popup = null; }
1514
+ each(document.querySelectorAll('#selected .hintkey'), function (key) { key.remove(); });
1515
+ if (svg) {
1516
+ svg.classList.remove('hinting');
1517
+ each(svg.querySelectorAll('.hint-on'), function (group) { group.classList.remove('hint-on'); });
1518
+ }
1519
+ cancelAnimationFrame(frame);
1520
+ }
1521
+
1522
+ function groupsOf(target) {
1523
+ return target.kind === 'node' ? nodeGroups(target.key) : target.kind === 'edge' ? edgeGroups(target.key) : [];
1524
+ }
1525
+
1526
+ // Work out what wears a letter, and draw the letters and the strip.
1527
+ function refresh() {
1528
+ var result = filtered(hint.base, hint.query);
1529
+ hint.message = result.message;
1530
+ var targets = ordered(result.matches);
1531
+ var labels = codes(targets.length);
1532
+ hint.shown = targets.map(function (target, i) { return { target: target, code: labels[i] }; });
1533
+ hint.typed = '';
1534
+ // Dim what can't be picked, unless everything can. Links aren't in the
1535
+ // drawing, so picking one dims nothing.
1536
+ each(svg.querySelectorAll('.hint-on'), function (group) { group.classList.remove('hint-on'); });
1537
+ var narrowed = hint.mode !== 'links' && (hint.mode !== 'all' || hint.shown.length < hint.base.length);
1538
+ svg.classList.toggle('hinting', narrowed);
1539
+ hint.shown.forEach(function (one) { each(groupsOf(one.target), function (group) { group.classList.add('hint-on'); }); });
1540
+ if (selection) each(groupsOf(selection), function (group) { group.classList.add('hint-on'); });
1541
+ drawLabels();
1542
+ showStrip();
1543
+ }
1544
+
1545
+ function keyElement(code) {
1546
+ var key = element('span', 'hintkey');
1547
+ key.setAttribute('data-code', code);
1548
+ key.textContent = code;
1549
+ return key;
1550
+ }
1551
+
1552
+ function drawLabels() {
1553
+ hintLayer.textContent = '';
1554
+ if (popup) { popup.remove(); popup = null; }
1555
+ each(document.querySelectorAll('#selected .hintkey'), function (key) { key.remove(); });
1556
+ if (hint.mode === 'links') {
1557
+ if (!side.hidden) {
1558
+ // On the panel's own rows, where the links already are.
1559
+ var rows = selectedPanel.querySelectorAll('a.link');
1560
+ hint.shown.forEach(function (one) { var row = rows[one.target.key]; if (row) row.prepend(keyElement(one.code)); });
1561
+ } else {
1562
+ // The panel is hidden, so the links come up beside the selection.
1563
+ popup = element('div', '');
1564
+ popup.id = 'link-pop';
1565
+ hint.shown.forEach(function (one) {
1566
+ var row = element('a', 'link');
1567
+ row.href = one.target.link.href;
1568
+ row.target = '_blank';
1569
+ row.rel = 'noopener';
1570
+ row.append(keyElement(one.code));
1571
+ row.insertAdjacentHTML('beforeend', one.target.link.badges[shownTheme]);
1572
+ row.append(element('span', 'label', one.target.link.label));
1573
+ popup.append(row);
1574
+ });
1575
+ view.append(popup);
1576
+ }
1577
+ } else {
1578
+ hint.shown.forEach(function (one) {
1579
+ var key = keyElement(one.code);
1580
+ one.el = key;
1581
+ hintLayer.append(key);
1582
+ });
1583
+ }
1584
+ place();
1585
+ }
1586
+
1587
+ // Letters follow the drawing as it pans and zooms.
1588
+ function place() {
1589
+ cancelAnimationFrame(frame);
1590
+ if (!hint) return;
1591
+ hint.shown.forEach(function (one) {
1592
+ if (!one.el) return;
1593
+ var at = onScreen(one.target);
1594
+ one.el.style.display = at ? '' : 'none';
1595
+ if (at) one.el.style.transform = 'translate(' + Math.round(at.x) + 'px,' + Math.round(at.y) + 'px) ' + (at.side === 'left' ? 'translate(calc(-100% - 4px), -50%)' : 'translate(-50%, -50%)');
1596
+ });
1597
+ if (popup && selection) {
1598
+ var at = onScreen(selection);
1599
+ if (at) {
1600
+ popup.style.left = Math.round(Math.min(Math.max(at.x, 8), view.clientWidth - popup.offsetWidth - 8)) + 'px';
1601
+ popup.style.top = Math.round(Math.min(Math.max(at.y - popup.offsetHeight - 10, 8), view.clientHeight - popup.offsetHeight - 8)) + 'px';
1602
+ }
1603
+ }
1604
+ frame = requestAnimationFrame(place);
1605
+ }
1606
+
1607
+ function escapeText(text) {
1608
+ var holder = element('span', '', text);
1609
+ return holder.innerHTML;
1610
+ }
1611
+
1612
+ function showStrip() {
1613
+ var crumbs = '<b>' + MODE_WORD[hint.mode] + '</b>';
1614
+ if (hint.mode !== 'all') crumbs += ' ' + (hint.mode === 'inside' ? '' : 'of ') + escapeText(nameOf(selection));
1615
+ if (hint.filtering || hint.query) crumbs += ' \\u203A <span class="query">/' + escapeText(hint.query) + (hint.filtering ? '<i>\\u258C</i>' : '') + '</span>';
1616
+ var count = hint.shown.length;
1617
+ var said;
1618
+ if (hint.filtering) {
1619
+ said = (hint.message || (count === 1 ? '1 match \\u00B7 Enter picks it' : count + ' match' + (count === 1 ? '' : 'es') + (count ? ' \\u00B7 Enter to pick' : ''))) + ' \\u00B7 \\u232B \\u00B7 Esc out';
1620
+ } else {
1621
+ said = (hint.mode === 'links' ? 'letter to open' : 'letter to pick') + (hint.mode === 'links' ? '' : ' \\u00B7 / filter') + ' \\u00B7 \\u232B back \\u00B7 Esc out \\u00B7 ' + count;
1622
+ }
1623
+ strip.hidden = false;
1624
+ strip.querySelector('.crumbs').innerHTML = crumbs;
1625
+ strip.querySelector('.hint').textContent = said;
1626
+ }
1627
+
1628
+ function notice(text) {
1629
+ clearTimeout(noticeTimer);
1630
+ if (level) setLevel(null);
1631
+ strip.hidden = false;
1632
+ strip.querySelector('.crumbs').textContent = '';
1633
+ strip.querySelector('.hint').textContent = text;
1634
+ noticeTimer = setTimeout(function () { if (!level) strip.hidden = true; }, 1800);
1635
+ }
1636
+
1637
+ // A picked thing is selected, and brought into view if it was out of it.
1638
+ function act(target) {
1639
+ if (target.kind === 'link') {
1640
+ setLevel(null);
1641
+ window.open(target.link.href, '_blank', 'noopener');
1642
+ return;
1643
+ }
1644
+ setLevel(null);
1645
+ select(target);
1646
+ var at = onScreen(target);
1647
+ if (at && !at.inView) zoom.pan(view.clientWidth / 2 - at.x, view.clientHeight / 2 - at.y);
1648
+ }
1649
+
1650
+ // The keys at the two levels. Returns false for a key left to the page.
1651
+ function hintKey(event) {
1652
+ var key = event.key;
1653
+ if (hint.filtering) {
1654
+ if (key === 'Enter') {
1655
+ if (hint.shown.length === 1) act(hint.shown[0].target);
1656
+ else if (hint.shown.length > 1) { hint.filtering = false; setLevel('pick'); showStrip(); }
1657
+ } else if (key === 'Backspace') {
1658
+ if (hint.query) { hint.query = hint.query.slice(0, -1); refresh(); }
1659
+ else if (hint.direct) setLevel(null);
1660
+ else { hint.filtering = false; setLevel('pick'); showStrip(); }
1661
+ } else if (key.length === 1) { hint.query += key; refresh(); }
1662
+ else return false;
1663
+ return true;
1664
+ }
1665
+ if (ZOOM_KEYS[key] || key === 'Tab') return false;
1666
+ if (key === 'Backspace') {
1667
+ if (hint.typed) { hint.typed = hint.typed.slice(0, -1); markTyped(); }
1668
+ else if (hint.query || hint.mode === 'all' && hint.direct) { hint.filtering = true; setLevel('filter'); showStrip(); }
1669
+ else setLevel(null);
1670
+ return true;
1671
+ }
1672
+ if (key === '/' && hint.mode !== 'links') { hint.filtering = true; setLevel('filter'); showStrip(); return true; }
1673
+ if (key.length === 1 && /[a-z]/i.test(key)) {
1674
+ var typed = hint.typed + key.toLowerCase();
1675
+ var exact = hint.shown.filter(function (one) { return one.code === typed; })[0];
1676
+ if (exact) { act(exact.target); return true; }
1677
+ if (hint.shown.some(function (one) { return one.code.indexOf(typed) === 0; })) { hint.typed = typed; markTyped(); }
1678
+ return true;
1679
+ }
1680
+ return key.indexOf('Arrow') !== 0;
1681
+ }
1682
+
1683
+ // Letters that no longer fit what has been typed step aside.
1684
+ function markTyped() {
1685
+ hint.shown.forEach(function (one) {
1686
+ var key = one.el || document.querySelector('#selected .hintkey[data-code="' + one.code + '"], #link-pop .hintkey[data-code="' + one.code + '"]');
1687
+ if (!key) return;
1688
+ var fits = one.code.indexOf(hint.typed) === 0;
1689
+ key.classList.toggle('gone', !fits);
1690
+ key.innerHTML = fits && hint.typed ? '<span class="done">' + hint.typed + '</span>' + one.code.slice(hint.typed.length) : one.code;
1691
+ });
1692
+ }
985
1693
 
986
- /* ---- keys, beyond the zoom's own ---- */
1694
+ saveButton.onclick = function () { setLevel(level === 'save' ? null : 'save'); };
1695
+ versionsButton.onclick = function () { setLevel(level === 'versions' ? null : 'versions'); };
1696
+ viewButton.onclick = function () { setLevel(level === 'view' || level === 'theme' ? null : 'view'); };
1697
+ themeRow.onclick = function () { setLevel(level === 'theme' ? 'view' : 'theme'); };
1698
+ document.addEventListener('click', function (event) {
1699
+ if (level && !(event.target.closest && event.target.closest('.menu'))) setLevel(null);
1700
+ });
987
1701
 
1702
+ function move(by) {
1703
+ var all = rows();
1704
+ if (!all.length) return;
1705
+ cursor = (cursor + by + all.length) % all.length;
1706
+ markCursor();
1707
+ if (level === 'theme') showTheme(themeItems[cursor].value || DATA.current);
1708
+ }
1709
+
1710
+ function enter() {
1711
+ var all = rows();
1712
+ if (!all.length) return;
1713
+ if (level === 'theme') keepTheme(themeItems[cursor].value);
1714
+ else all[cursor].click();
1715
+ }
1716
+
1717
+ var ZOOM_KEYS = { '+': 1, '=': 1, '-': 1, '_': 1, '0': 1, '1': 1 };
1718
+
1719
+ // On the window, before the zoom's own keys, so a menu's letters reach the
1720
+ // menu and not the diagram underneath: g flips guides in View, not a grab.
1721
+ window.addEventListener('keydown', function (event) {
1722
+ if (event.ctrlKey || event.metaKey || event.altKey) return;
1723
+ if (event.target.closest && event.target.closest('input, textarea, select, [contenteditable]')) return;
1724
+ var key = event.key;
1725
+ if (key === 'Escape') plain();
1726
+ else if (level === 'pick' || level === 'filter') {
1727
+ if (!hintKey(event)) return;
1728
+ } else if (level === null) {
1729
+ if (key === 'm' || key === 'M') setLevel('bar');
1730
+ else if (key === 'e' && EDIT) toggleEditor();
1731
+ else if (key === 's') startHints('all');
1732
+ else if (key === '/') startHints('all', true);
1733
+ else if (key === 'n') startHints('neighbors');
1734
+ else if (key === 'i') startHints('inside');
1735
+ // During a grab, l pans right.
1736
+ else if (key === 'l' && !zoom.held()) startHints('links');
1737
+ else if (key === 'Backspace') {
1738
+ if (zoom.held()) zoom.grab(false);
1739
+ else if (lit.length) unlight();
1740
+ else if (selection) select(null);
1741
+ else return;
1742
+ } else return;
1743
+ } else {
1744
+ if (ZOOM_KEYS[key] || key === 'Tab') return;
1745
+ if (key === 'Backspace') back();
1746
+ else if (key === 'ArrowDown') move(1);
1747
+ else if (key === 'ArrowUp') move(-1);
1748
+ else if (key === 'Enter' || key === ' ') enter();
1749
+ else if (key.length === 1) {
1750
+ var letter = key.toLowerCase();
1751
+ if (level === 'bar') {
1752
+ if (letter === 'v') setLevel('view');
1753
+ else if (letter === 'x') setLevel('save');
1754
+ else if (letter === 'o' && tiedFile()) setLevel('versions');
1755
+ else if (letter === 'e' && EDIT) { setLevel(null); toggleEditor(); }
1756
+ else if (letter === 'f' && !full.hidden) { setLevel(null); toggleFull(); }
1757
+ } else if (level === 'view' || level === 'save' || level === 'versions') {
1758
+ rows().forEach(function (row, i) {
1759
+ if (row.getAttribute('data-key') !== letter) return;
1760
+ cursor = i;
1761
+ markCursor();
1762
+ row.click();
1763
+ });
1764
+ }
1765
+ } else if (key.indexOf('Arrow') !== 0) return;
1766
+ }
1767
+ event.preventDefault();
1768
+ event.stopImmediatePropagation();
1769
+ }, true);
1770
+
1771
+ // With the menus closed, p and f still show the panel and go full screen.
988
1772
  document.addEventListener('keydown', function (event) {
989
1773
  if (event.ctrlKey || event.metaKey || event.altKey) return;
990
1774
  if (event.target.closest && event.target.closest('input, textarea, select')) return;
991
1775
  if (event.key === 'p') setPanel(!panelOn);
992
1776
  else if (event.key === 'f' && !full.hidden) toggleFull();
993
- else if (event.key === 'Escape') { showSave(false); unlight(); select(null); }
994
1777
  else return;
995
1778
  event.preventDefault();
996
1779
  });
997
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
+
998
2568
  layoutSide();
999
2569
  zoom.show();
1000
2570
  })();
@@ -1002,3 +2572,173 @@ const PAGE = `<!doctype html>
1002
2572
  </body>
1003
2573
  </html>
1004
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>`;