reladraw 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/page.js CHANGED
@@ -32,19 +32,44 @@ export function drawPage(source, file, options = {}, at = new Date()) {
32
32
  // The same diagram drawn in every theme, for the page's theme picker. A
33
33
  // theme moves nothing, so only the colors differ between them. A page or
34
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
- }
35
+ // Guides start as the file or --guides says, and the View menu's switch
36
+ // shows the drawing the other way. A diagram with no guides draws the
37
+ // same both ways, so it carries one drawing.
38
+ const guidesShown = options.guides ?? layout.diagram['guides'] === 'show';
39
+ const guideCount = layout.nodes.filter((node) => node.guide).length +
40
+ layout.edges.filter((edge) => edge.guide).length +
41
+ layout.marks.filter((mark) => mark.guide).length;
42
+ const drawAll = (guides) => {
43
+ const themes = {};
44
+ for (const one of THEME_NAMES) {
45
+ const drawnIn = { ...options, guides, theme: options.theme && one === current ? options.theme : THEMES[one] };
46
+ const theme = themeOf(layout, drawnIn);
47
+ themes[one] = { ...renderTagged(layout, drawnIn), theme, colors: colorsOf(theme), scheme: schemeOf(theme) };
48
+ }
49
+ // A --theme that is not one of the named ones is drawn as given, and offered as such.
50
+ if (themes[current] === undefined) {
51
+ const drawnIn = { ...options, guides };
52
+ const theme = themeOf(layout, drawnIn);
53
+ themes[current] = { ...renderTagged(layout, drawnIn), theme, colors: colorsOf(theme), scheme: schemeOf(theme) };
54
+ }
55
+ return themes;
56
+ };
57
+ const themes = drawAll(guidesShown);
58
+ const flipped = guideCount > 0 ? drawAll(!guidesShown) : null;
46
59
  return {
47
- html: page({ title, name, drawn, source, findings: found, themes, current, graph: graphOf(layout, themes) }),
60
+ html: page({
61
+ title,
62
+ name,
63
+ drawn,
64
+ source,
65
+ findings: found,
66
+ themes,
67
+ flipped,
68
+ guidesShown,
69
+ guideCount,
70
+ current,
71
+ graph: graphOf(layout, themes),
72
+ }),
48
73
  findings: found,
49
74
  };
50
75
  };
@@ -81,10 +106,24 @@ function graphOf(layout, themes) {
81
106
  }
82
107
  return undefined;
83
108
  };
109
+ // A badge's picture is joined to its box's text by `badge:`, not by a
110
+ // `join-with:` anyone wrote.
111
+ const isBadge = (node) => node.joinWith === 'text' && node.attrs['join-with'] === undefined;
84
112
  const nodes = {};
85
113
  for (const node of layout.nodes) {
86
114
  const text = plainText(node.text);
87
- nodes[node.name] = { label: text === '' ? node.name.split('.').pop() : text, links: views(linksFor(node)), ...describe(node.attrs) };
115
+ nodes[node.name] = {
116
+ label: text === '' ? node.name.split('.').pop() : text,
117
+ links: views(linksFor(node)),
118
+ ...describe(node.attrs),
119
+ box: [node.x, node.y, node.width, node.height].map((n) => Math.round(n * 10) / 10),
120
+ ...(node.parent ? { parent: node.parent.name } : {}),
121
+ children: node.children.filter((child) => !isBadge(child)).map((child) => child.name),
122
+ styles: stylesOf(node.attrs),
123
+ ...(node.guide ? { guide: true } : {}),
124
+ ...(isBadge(node) ? { badge: true } : {}),
125
+ ...(node.kind === 'none' && node.children.length === 0 ? { bare: true } : {}),
126
+ };
88
127
  }
89
128
  const edges = layout.edges.map((edge) => {
90
129
  // The upstream end is the one a line runs from: the end without a mark
@@ -93,10 +132,20 @@ function graphOf(layout, themes) {
93
132
  const backwards = start !== '' && end === '';
94
133
  const [up, down] = backwards ? [edge.to, edge.from] : [edge.from, edge.to];
95
134
  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) };
135
+ return {
136
+ up: up.name,
137
+ down: down.name,
138
+ label: text,
139
+ links: views(linksOf(edge.attrs)),
140
+ ...describe(edge.attrs),
141
+ styles: stylesOf(edge.attrs),
142
+ ...(edge.guide ? { guide: true } : {}),
143
+ };
97
144
  });
98
145
  return { nodes, edges };
99
146
  }
147
+ /** The styles a `style:` names; the parser joins them on a space. */
148
+ const stylesOf = (attrs) => (attrs['style'] === undefined ? [] : attrs['style'].split(' '));
100
149
  /** A text as a reader sees it: markup such as `[dim]` taken out, its lines run together. */
101
150
  function plainText(text) {
102
151
  return text.replace(/\[\/?[a-z][a-z0-9-]*\]/gi, '').replace(/\s*\n\s*/g, ' ').trim();
@@ -134,18 +183,13 @@ function colorsOf(theme) {
134
183
  '--on-accent': theme.primary.on,
135
184
  };
136
185
  }
137
- function page({ title, name, drawn, source, findings, themes, current, graph }) {
186
+ function page({ title, name, drawn, source, findings, themes, flipped, guidesShown, guideCount, current, graph }) {
138
187
  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>`;
188
+ // The theme list: first the diagram's own theme, which follows the file;
189
+ // then every theme as the reader's choice, dark ones together and light
190
+ // together, as the playground's picker has them, so stepping through the
191
+ // list doesn't flash between black and white.
192
+ const group = (light) => Object.keys(themes).filter((one) => (schemeOf(themes[one].theme) === 'light') === light);
149
193
  // The shown theme goes in whole; another is only the colors that differ
150
194
  // from it, when the rest of the drawing is the same, as it is since a theme
151
195
  // moves nothing. That keeps the page near its size with one theme.
@@ -155,17 +199,28 @@ function page({ title, name, drawn, source, findings, themes, current, graph })
155
199
  const same = theirs.length === ours.length && theirs.every((piece, i) => i % 2 === 1 || piece === ours[i]);
156
200
  return same ? theirs.filter((_, i) => i % 2 === 1) : other;
157
201
  };
202
+ // Each set of drawings, with guides one way, packed against its own drawing
203
+ // in the page's first theme: guides add things, so the two sets differ in
204
+ // more than colors and can't be packed against each other.
205
+ const packAll = (set) => {
206
+ const base = set[current];
207
+ return Object.fromEntries(Object.entries(set).map(([one, view]) => [
208
+ one,
209
+ one === current
210
+ ? { svg: view.svg, tagged: view.tagged, colors: view.colors, scheme: view.scheme }
211
+ : { svg: pack(view.svg, base.svg), tagged: pack(view.tagged, base.tagged), colors: view.colors, scheme: view.scheme },
212
+ ]));
213
+ };
158
214
  const data = {
159
215
  name,
160
216
  source,
161
217
  current,
162
218
  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
- ])),
219
+ themeGroups: { dark: group(false), light: group(true) },
220
+ themes: packAll(themes),
221
+ flipped: flipped && packAll(flipped),
222
+ guidesShown,
223
+ guideCount,
169
224
  findings: findings.map((finding) => ({ kind: finding.kind, message: finding.message, line: finding.line })),
170
225
  graph,
171
226
  };
@@ -180,7 +235,6 @@ function page({ title, name, drawn, source, findings, themes, current, graph })
180
235
  ...Object.entries(shown.colors).map(([key, value]) => `${key}: ${value};`),
181
236
  '--sel: #e0b25a; --up: #22b98c; --down: #e5484d;',
182
237
  ].join(' '))
183
- .replace('{{themes}}', () => picker)
184
238
  .replace('{{drawing}}', () => shown.tagged)
185
239
  .replace('{{data}}', () => scriptJson(data));
186
240
  }
@@ -278,9 +332,56 @@ const PAGE = `<!doctype html>
278
332
  padding: 3px 6px; cursor: pointer;
279
333
  }
280
334
  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; }
335
+
336
+ /* ---- the menus, and the keys that reach them ----
337
+ What the keys act on is ringed in amber, and the amber moves down with
338
+ them: the bar after m, then the open menu and the row the arrows are on,
339
+ then the theme list. The strip under the bar says where the keys are and
340
+ how to get out. */
341
+ .menu > button { position: relative; }
342
+ kbd {
343
+ font: 11px ui-monospace, Menlo, Consolas, monospace; padding: 0 5px; border-radius: 3px;
344
+ border: 1px solid color-mix(in srgb, var(--rule) 70%, transparent); color: var(--muted);
345
+ }
346
+ kbd.tag {
347
+ display: none; position: absolute; top: -8px; right: -7px; z-index: 6;
348
+ padding: 0 4px; border: 0; font-weight: 700; background: var(--sel); color: #1a1406;
349
+ }
350
+ #full { position: relative; }
351
+ .tools.keyed { outline: 2px solid var(--sel); outline-offset: 4px; border-radius: 6px; }
352
+ .tools.keyed kbd.tag { display: inline; }
353
+ .menu > button.open { border-color: var(--sel); background: color-mix(in srgb, var(--sel) 22%, transparent); }
354
+ .menu-list { min-width: 15rem; }
355
+ .menu-list .row {
356
+ display: flex; align-items: center; gap: 8px; width: 100%; text-align: left;
357
+ border-color: transparent; padding: 5px 8px;
358
+ }
359
+ .menu-list .row .name { flex: 1; }
360
+ .menu-list .row .value { color: var(--muted); }
361
+ .menu-list .row.cur { background: color-mix(in srgb, var(--ink) 12%, transparent); box-shadow: inset 3px 0 0 var(--sel); }
362
+ /* Its rows, not the list, so the theme list inside it stays solid. */
363
+ .menu-list.behind > .row, .menu-list.behind > hr { opacity: 0.5; }
364
+ .menu-list.sub { right: calc(100% + 6px); top: 0; max-height: 70vh; overflow: auto; gap: 0; }
365
+ .menu-list .group { padding: 6px 8px 2px; font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
366
+ .menu-list .mark { width: 12px; flex: none; color: var(--up); }
367
+ /* A switch: green and to the right when on, gray and to the left when off,
368
+ with the word beside it, so the state never rests on color alone. */
369
+ .sw { flex: none; position: relative; width: 30px; height: 16px; border-radius: 9px; background: color-mix(in srgb, var(--ink) 25%, transparent); }
370
+ .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); }
371
+ .switch[aria-checked="true"] .sw { background: #2f9e5a; }
372
+ .switch[aria-checked="true"] .sw::after { left: 16px; background: #fff; }
373
+ .switch .word { width: 2em; text-align: right; font-size: 12px; color: var(--muted); }
374
+ .switch[aria-checked="true"] .word { color: var(--up); }
375
+ .switch[aria-checked="false"] .name { color: var(--muted); }
376
+ .menu-list .row:disabled { opacity: 0.45; }
377
+ #strip {
378
+ display: flex; justify-content: space-between; gap: 16px; padding: 3px 16px; font-size: 12px;
379
+ background: color-mix(in srgb, var(--sel) 16%, var(--page)); color: var(--ink);
380
+ border-bottom: 1px solid color-mix(in srgb, var(--sel) 45%, transparent);
381
+ }
382
+ #strip[hidden] { display: none; }
383
+ #strip .crumbs b { color: var(--sel); }
384
+ #strip .hint { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
284
385
 
285
386
  /* ---- what is selected ---- */
286
387
  #selected { padding: 10px 16px 12px; overflow: auto; flex: 0 1 auto; min-height: 0; }
@@ -328,6 +429,21 @@ const PAGE = `<!doctype html>
328
429
  #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
430
  #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
431
  #view g[data-edge] .target { stroke: #000 !important; stroke-opacity: 0; }
432
+ #view svg.hinting g[data-name]:not(.hint-on) { opacity: .25 !important; }
433
+ #view svg.hinting g[data-name].hint-on { opacity: 1 !important; }
434
+ #hints { position: absolute; inset: 0; pointer-events: none; z-index: 4; }
435
+ .hintkey { font: 700 12px/16px ui-monospace, Menlo, Consolas, monospace; padding: 0 4px; border-radius: 3px;
436
+ background: var(--sel); color: #1a1406; box-shadow: 0 0 0 1px rgba(0, 0, 0, .55); white-space: nowrap; }
437
+ #hints .hintkey { position: absolute; left: 0; top: 0; }
438
+ .hintkey .done { opacity: .45; }
439
+ .hintkey.gone { visibility: hidden; }
440
+ #selected a.link .hintkey { margin-right: 2px; }
441
+ #link-pop { position: absolute; z-index: 5; display: flex; flex-direction: column; gap: 2px; padding: 6px 8px; font-size: 13px;
442
+ background: var(--page); border: 1px solid var(--sel); border-radius: 6px; box-shadow: 0 4px 16px rgba(0, 0, 0, .35); }
443
+ #link-pop a.link { display: flex; align-items: center; gap: 6px; color: var(--ink); text-decoration: none; }
444
+ #link-pop a.link svg { flex: none; }
445
+ #strip .query { color: var(--ink); }
446
+ #strip .query i { font-style: normal; color: var(--sel); }
331
447
  @keyframes run { to { stroke-dashoffset: -26; } }
332
448
  @media (prefers-reduced-motion: reduce) { #view g[data-edge] :is(line, path, polyline) { animation: none !important; } }
333
449
  aside h2 { margin: 0; padding: 10px 16px 6px; font-size: 13px; font-weight: 600; }
@@ -338,6 +454,7 @@ const PAGE = `<!doctype html>
338
454
  padding: 6px 8px; margin: 2px 0;
339
455
  }
340
456
  aside li button.chosen { border-color: var(--accent); }
457
+ aside li.none { padding: 6px 8px; color: var(--muted); font-size: 13px; }
341
458
  .where { color: var(--muted); font-variant-numeric: tabular-nums; }
342
459
  .kind { color: var(--accent); }
343
460
  .note { display: block; color: var(--muted); font-size: 12px; margin-top: 2px; }
@@ -360,25 +477,35 @@ const PAGE = `<!doctype html>
360
477
  <button id="fit" type="button" title="The whole diagram in view (0)">Fit</button>
361
478
  <button id="actual" type="button" title="The drawing at its own scale (1)">100%</button>
362
479
  <span class="gap"></span>
363
- <button id="full" type="button" title="Full screen (f)">Full screen</button>
480
+ <button id="full" type="button" title="Full screen (f)"><span class="label">Full screen</span><kbd class="tag">f</kbd></button>
364
481
  <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>
482
+ <button id="save" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="save-list">Save ▾<kbd class="tag">s</kbd></button>
483
+ <span id="save-list" class="menu-list" role="menu" hidden>
484
+ <button id="save-page" class="row" type="button" role="menuitem" data-key="p" title="Save this page as one HTML file, to keep or send"><span class="name">This page</span><kbd>p</kbd></button>
485
+ <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
486
  <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>
487
+ <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>
488
+ <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>
489
+ <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>
490
+ </span>
491
+ </span>
492
+ <span class="menu">
493
+ <button id="view-button" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="view-list">View ▾<kbd class="tag">v</kbd></button>
494
+ <span id="view-list" class="menu-list" role="menu" hidden>
495
+ <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>
496
+ <hr>
497
+ <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>
498
+ <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>
499
+ <hr>
500
+ <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>
501
+ <hr>
502
+ <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>
503
+ <span id="theme-list" class="menu-list sub" role="menu" hidden></span>
373
504
  </span>
374
505
  </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
506
  </div>
381
507
  </header>
508
+ <div id="strip" hidden><span class="crumbs"></span><span class="hint"></span></div>
382
509
  <main>
383
510
  <div id="view">
384
511
  <div id="stage">
@@ -411,7 +538,10 @@ const PAGE = `<!doctype html>
411
538
  // Another theme is carried as the colors that differ from the first, put
412
539
  // back in place the first time it is shown.
413
540
  var VARIES = new RegExp(DATA.varies, 'gi');
414
- var first = DATA.themes[DATA.current];
541
+ // Whether guides are drawn, which the View menu's switch changes. The
542
+ // drawings with guides the other way are a second set, packed the same way
543
+ // against its own first theme.
544
+ var guidesShown = DATA.guidesShown;
415
545
  function unpack(packed, base) {
416
546
  if (typeof packed === 'string') return packed;
417
547
  var pieces = base.split(VARIES);
@@ -419,8 +549,10 @@ const PAGE = `<!doctype html>
419
549
  return pieces.join('');
420
550
  }
421
551
  function themed(name) {
422
- var view = DATA.themes[name];
552
+ var set = guidesShown === DATA.guidesShown || !DATA.flipped ? DATA.themes : DATA.flipped;
553
+ var view = set[name];
423
554
  if (!view.unpacked) {
555
+ var first = set[DATA.current];
424
556
  view.svg = unpack(view.svg, first.svg);
425
557
  view.tagged = unpack(view.tagged, first.tagged);
426
558
  view.unpacked = true;
@@ -448,7 +580,7 @@ const PAGE = `<!doctype html>
448
580
  }
449
581
  full.onclick = toggleFull;
450
582
  document.addEventListener('fullscreenchange', function () {
451
- full.textContent = document.fullscreenElement ? 'Exit full screen' : 'Full screen';
583
+ full.querySelector('.label').textContent = document.fullscreenElement ? 'Exit full screen' : 'Full screen';
452
584
  });
453
585
 
454
586
  /* ---- saving ---- */
@@ -466,9 +598,10 @@ const PAGE = `<!doctype html>
466
598
  }
467
599
 
468
600
  function flash(button, text) {
469
- var was = button.textContent;
470
- button.textContent = text;
471
- setTimeout(function () { button.textContent = was; }, 1200);
601
+ var label = button.querySelector('.name') || button;
602
+ var was = label.textContent;
603
+ label.textContent = text;
604
+ setTimeout(function () { label.textContent = was; }, 1200);
472
605
  }
473
606
 
474
607
  // Chrome and Edge ask where to save and under what name, as the
@@ -507,17 +640,9 @@ const PAGE = `<!doctype html>
507
640
 
508
641
  // Every way out of the page is under one Save button: the page, its
509
642
  // 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.
643
+ // can be read. It is one of the menus (below), opened by a click or by m s.
511
644
  var saveButton = document.getElementById('save');
512
645
  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
646
 
522
647
  var savePage = document.getElementById('save-page');
523
648
  savePage.onclick = function () {
@@ -620,17 +745,26 @@ const PAGE = `<!doctype html>
620
745
 
621
746
  var panel = document.getElementById('findings');
622
747
  var list = document.getElementById('finding-list');
623
- var toggle = document.getElementById('toggle-findings');
624
748
  var count = DATA.findings.length;
625
749
 
750
+ // A row of the View menu that is on or off: its switch, and the word beside it.
751
+ function setSwitch(row, on) {
752
+ row.setAttribute('aria-checked', String(on));
753
+ row.querySelector('.word').textContent = on ? 'on' : 'off';
754
+ }
755
+ var panelRow = document.getElementById('panel-row');
756
+ var autoRow = document.getElementById('auto-row');
757
+ var findingsRow = document.getElementById('findings-row');
758
+ var guidesRow = document.getElementById('guides-row');
759
+ findingsRow.querySelector('.name').textContent = 'Findings (' + count + ')';
760
+ guidesRow.querySelector('.name').textContent = 'Guides (' + DATA.guideCount + ')';
761
+
626
762
  /* ---- the side panel: shown, hidden, or shown only when wanted ---- */
627
763
 
628
764
  // Hidden, it stays hidden whatever is selected. Auto-hide shows it only
629
765
  // while something is selected or the findings are open. Both choices are
630
766
  // remembered in this browser for every reladraw page.
631
767
  var side = document.getElementById('side');
632
- var panelButton = document.getElementById('toggle-panel');
633
- var autoHideBox = document.getElementById('auto-hide');
634
768
  var KEPT = 'reladraw-page-panel';
635
769
  var panelOn = true;
636
770
  var autoHide = false;
@@ -645,42 +779,41 @@ const PAGE = `<!doctype html>
645
779
  side.hidden = !(panelOn && (!autoHide || selection || !panel.hidden));
646
780
  // A finding lights what it is about only while the finding can be seen.
647
781
  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;
782
+ setSwitch(panelRow, panelOn);
783
+ setSwitch(autoRow, autoHide);
784
+ autoRow.disabled = !panelOn;
785
+ setSwitch(findingsRow, panelOn && !panel.hidden);
652
786
  }
653
787
  function setPanel(on) {
654
788
  panelOn = on;
655
789
  keepPanel();
656
790
  layoutSide();
657
791
  }
658
- panelButton.onclick = function () { setPanel(!panelOn); };
659
- autoHideBox.onchange = function () {
660
- autoHide = autoHideBox.checked;
792
+ function setAutoHide(on) {
793
+ if (!panelOn) return;
794
+ autoHide = on;
661
795
  keepPanel();
662
796
  layoutSide();
663
- };
797
+ }
798
+ panelRow.onclick = function () { setPanel(!panelOn); };
799
+ autoRow.onclick = function () { setAutoHide(!autoHide); };
664
800
 
665
801
  function showPanel(open) {
666
802
  panel.hidden = !open;
667
- toggle.setAttribute('aria-pressed', String(open));
668
803
  layoutSide();
669
804
  }
805
+ // Asking for the findings shows the panel they are in.
806
+ function flipFindings() {
807
+ if (!panelOn) { panelOn = true; keepPanel(); showPanel(true); }
808
+ else showPanel(panel.hidden);
809
+ }
810
+ findingsRow.onclick = flipFindings;
670
811
  if (count === 0) {
671
- toggle.textContent = 'No findings';
672
- toggle.disabled = true;
812
+ list.append(element('li', 'none', 'No findings.'));
673
813
  panel.hidden = true;
674
814
  } 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
815
  // Open from the start, unless the panel is to stay out of the way.
682
816
  panel.hidden = autoHide;
683
- toggle.setAttribute('aria-pressed', String(!autoHide));
684
817
  }
685
818
 
686
819
  var lit = [];
@@ -938,21 +1071,13 @@ const PAGE = `<!doctype html>
938
1071
  if (link && svg.contains(link)) event.preventDefault();
939
1072
  });
940
1073
 
941
- /* ---- the theme picker ---- */
1074
+ /* ---- drawing again: another theme, or guides the other way ---- */
942
1075
 
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
1076
  var schemeMeta = document.querySelector('meta[name="color-scheme"]');
949
- var CHOSEN_THEME = 'reladraw-page-theme';
950
1077
 
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;
1078
+ // Neither a theme nor guides move anything, so the view, the selection and
1079
+ // the panel stay as they were.
1080
+ function redraw() {
956
1081
  var next = shown();
957
1082
  Object.keys(next.colors).forEach(function (key) { document.documentElement.style.setProperty(key, next.colors[key]); });
958
1083
  schemeMeta.setAttribute('content', next.scheme);
@@ -966,31 +1091,648 @@ const PAGE = `<!doctype html>
966
1091
  select(selection);
967
1092
  }
968
1093
 
969
- picker.onchange = function () {
1094
+ function showTheme(name) {
1095
+ if (!DATA.themes[name] || name === shownTheme) return;
1096
+ shownTheme = name;
1097
+ redraw();
1098
+ }
1099
+
1100
+ // Guides start as the file or --guides says, and a flip lasts for this page
1101
+ // only: guides show how one diagram was built, not how a reader likes to
1102
+ // read. With none in the diagram the switch still works, and draws nothing new.
1103
+ function setGuides(on) {
1104
+ if (on === guidesShown) return;
1105
+ guidesShown = on;
1106
+ setSwitch(guidesRow, on);
1107
+ if (DATA.flipped) redraw();
1108
+ }
1109
+ setSwitch(guidesRow, guidesShown);
1110
+ guidesRow.onclick = function () { setGuides(!guidesShown); };
1111
+
1112
+ /* ---- the theme list ---- */
1113
+
1114
+ // "Diagram's theme" follows the file, so an edit to its theme shows on the
1115
+ // next render. Any other choice is the reader's, and holds on every reladraw
1116
+ // page in this browser, whatever its file says, until the diagram's theme
1117
+ // is chosen again.
1118
+ var CHOSEN_THEME = 'reladraw-page-theme';
1119
+ var chosenName = '';
1120
+ var themeRow = document.getElementById('theme-row');
1121
+ var themeList = document.getElementById('theme-list');
1122
+ var themeItems = [];
1123
+
1124
+ function addThemeItem(value, label) {
1125
+ var row = element('button', 'row');
1126
+ row.type = 'button';
1127
+ row.setAttribute('role', 'menuitemradio');
1128
+ row.append(element('span', 'mark'), element('span', 'name', label));
1129
+ row.onclick = function () { keepTheme(value); };
1130
+ themeItems.push({ value: value, row: row });
1131
+ themeList.append(row);
1132
+ }
1133
+ addThemeItem('', "Diagram's theme (" + DATA.current + ')');
1134
+ [['Dark', DATA.themeGroups.dark], ['Light', DATA.themeGroups.light]].forEach(function (group) {
1135
+ if (group[1].length === 0) return;
1136
+ themeList.append(element('span', 'group', group[0]));
1137
+ group[1].forEach(function (name) { addThemeItem(name, name); });
1138
+ });
1139
+
1140
+ function showChosen() {
1141
+ themeRow.querySelector('.value').textContent = '· ' + (chosenName || "Diagram's theme");
1142
+ themeItems.forEach(function (item) {
1143
+ var on = item.value === chosenName;
1144
+ item.row.setAttribute('aria-checked', String(on));
1145
+ item.row.querySelector('.mark').textContent = on ? '\\u25CF' : '';
1146
+ });
1147
+ }
1148
+
1149
+ function keepTheme(value) {
1150
+ chosenName = value;
970
1151
  try {
971
- if (picker.value) localStorage.setItem(CHOSEN_THEME, picker.value);
1152
+ if (value) localStorage.setItem(CHOSEN_THEME, value);
972
1153
  else localStorage.removeItem(CHOSEN_THEME);
973
1154
  } catch (e) {}
974
- showTheme(picker.value || DATA.current);
975
- };
1155
+ showTheme(value || DATA.current);
1156
+ showChosen();
1157
+ if (level === 'theme') setLevel('view', true);
1158
+ }
976
1159
 
977
1160
  // A theme chosen on an earlier page, if this page has it.
978
1161
  try {
979
- var chosenTheme = localStorage.getItem(CHOSEN_THEME);
980
- if (chosenTheme && DATA.themes[chosenTheme]) {
981
- picker.value = chosenTheme;
982
- showTheme(chosenTheme);
1162
+ var kept = localStorage.getItem(CHOSEN_THEME);
1163
+ if (kept && DATA.themes[kept]) {
1164
+ chosenName = kept;
1165
+ showTheme(kept);
983
1166
  }
984
1167
  } catch (e) {}
1168
+ showChosen();
1169
+
1170
+ /* ---- the menus, by pointer and by key ---- */
1171
+
1172
+ // The keys work in levels: the diagram; m, the bar, each control wearing
1173
+ // its letter; then a menu, View or Save; then, from View, the theme list.
1174
+ // A letter means what the level it is pressed in says, so no letter is used
1175
+ // up however deep the menus go. Keys that aren't letters mean the same at
1176
+ // every level: Backspace steps back one level, and beneath the menus lets
1177
+ // go of a grab, then a lit finding, then the selection; Escape goes straight
1178
+ // back to the plain diagram from anywhere; Enter does what the row the
1179
+ // arrows are on does. Opening a menu with a click puts the keys there too.
1180
+ var tools = document.querySelector('.tools');
1181
+ var viewButton = document.getElementById('view-button');
1182
+ var viewList = document.getElementById('view-list');
1183
+ var strip = document.getElementById('strip');
1184
+ var level = null;
1185
+ var cursor = 0;
1186
+ var themeBefore = null;
1187
+
1188
+ var STRIP = {
1189
+ bar: ['<b>Menu</b>', 'letter to open \\u00B7 \\u232B back \\u00B7 Esc out'],
1190
+ view: ['Menu \\u203A <b>View</b>', 'letter to flip \\u00B7 \\u2191\\u2193 Enter \\u00B7 \\u232B back \\u00B7 Esc out'],
1191
+ save: ['Menu \\u203A <b>Save</b>', 'letter to save \\u00B7 \\u2191\\u2193 Enter \\u00B7 \\u232B back \\u00B7 Esc out'],
1192
+ theme: ['Menu \\u203A View \\u203A <b>Theme</b>', '\\u2191\\u2193 to try \\u00B7 Enter keep \\u00B7 \\u232B back \\u00B7 Esc out'],
1193
+ };
1194
+
1195
+ function rows() {
1196
+ if (level === 'view') return Array.prototype.slice.call(viewList.querySelectorAll(':scope > .row'));
1197
+ if (level === 'save') return Array.prototype.slice.call(saveList.querySelectorAll(':scope > .row'));
1198
+ if (level === 'theme') return themeItems.map(function (item) { return item.row; });
1199
+ return [];
1200
+ }
1201
+
1202
+ function markCursor() {
1203
+ each(document.querySelectorAll('.menu-list .row.cur'), function (row) { row.classList.remove('cur'); });
1204
+ var all = rows();
1205
+ if (!all.length) return;
1206
+ cursor = Math.max(0, Math.min(cursor, all.length - 1));
1207
+ all[cursor].classList.add('cur');
1208
+ if (level === 'theme') all[cursor].scrollIntoView({ block: 'nearest' });
1209
+ }
1210
+
1211
+ // \`kept\`: leaving the theme list keeps the theme moved to, as Enter or a
1212
+ // click does; any other way out goes back to the theme shown before.
1213
+ function setLevel(next, kept) {
1214
+ if (level === 'theme' && next !== 'theme' && !kept && themeBefore !== null) showTheme(themeBefore);
1215
+ if (next === 'theme' && level !== 'theme') {
1216
+ themeBefore = shownTheme;
1217
+ cursor = 0;
1218
+ themeItems.forEach(function (item, i) { if (item.value === chosenName) cursor = i; });
1219
+ } else if (next !== level) {
1220
+ // Back from the theme list, the arrows are on Theme, the first row.
1221
+ cursor = 0;
1222
+ }
1223
+ level = next;
1224
+ tools.classList.toggle('keyed', level === 'bar');
1225
+ saveList.hidden = level !== 'save';
1226
+ saveButton.classList.toggle('open', level === 'save');
1227
+ saveButton.setAttribute('aria-expanded', String(level === 'save'));
1228
+ viewList.hidden = level !== 'view' && level !== 'theme';
1229
+ viewButton.classList.toggle('open', level === 'view' || level === 'theme');
1230
+ viewButton.setAttribute('aria-expanded', String(level === 'view' || level === 'theme'));
1231
+ themeList.hidden = level !== 'theme';
1232
+ viewList.classList.toggle('behind', level === 'theme');
1233
+ strip.hidden = level === null;
1234
+ if (level && STRIP[level]) {
1235
+ strip.querySelector('.crumbs').innerHTML = STRIP[level][0];
1236
+ strip.querySelector('.hint').textContent = STRIP[level][1];
1237
+ }
1238
+ if (level !== 'pick' && level !== 'filter') clearHints();
1239
+ markCursor();
1240
+ }
1241
+
1242
+ function back() {
1243
+ if (level === 'theme') setLevel('view');
1244
+ else if (level === 'view' || level === 'save') setLevel('bar');
1245
+ else setLevel(null);
1246
+ }
1247
+
1248
+ // Escape: the plain diagram, in one press, whatever was open or lit.
1249
+ function plain() {
1250
+ setLevel(null);
1251
+ zoom.grab(false);
1252
+ unlight();
1253
+ select(null);
1254
+ }
1255
+
1256
+ /* ---- picking from the keyboard: letters on things ---- */
1257
+
1258
+ // s puts a letter on everything, / starts a filter that narrows what gets
1259
+ // one, and with something selected n letters what one edge away from it,
1260
+ // i what is inside it and the box it sits in, and l its links. Typing a
1261
+ // letter picks. These are two more levels: 'pick', where letters pick, and
1262
+ // 'filter', where everything typed goes into the filter until Enter. A
1263
+ // filter's plain words match a thing's text; is:node, is:edge,
1264
+ // is:container and style:name narrow by kind and style.
1265
+ var hint = null;
1266
+ var hintLayer = element('div', '');
1267
+ hintLayer.id = 'hints';
1268
+ view.append(hintLayer);
1269
+ var popup = null;
1270
+ var frame = 0;
1271
+ var noticeTimer = 0;
1272
+ var MODE_WORD = { all: 'Select', neighbors: 'Neighbors', inside: 'Inside', links: 'Links' };
1273
+ var KINDS = ['node', 'edge', 'container'];
1274
+
1275
+ function shownThing(name) {
1276
+ var node = graph.nodes[name];
1277
+ return node && !node.badge && (!node.guide || guidesShown);
1278
+ }
1279
+ function shownEdge(edge) { return !edge.guide || guidesShown; }
1280
+ function nameOf(target) {
1281
+ if (target.kind === 'node') return graph.nodes[target.key].label;
1282
+ var edge = graph.edges[target.key];
1283
+ return edge.label || graph.nodes[edge.up].label + ' \\u2192 ' + graph.nodes[edge.down].label;
1284
+ }
1285
+
1286
+ // Where a thing's letter goes, in the drawing's own units: a box's top-left
1287
+ // corner, or the middle of its top edge when a child's letter already holds
1288
+ // the corner; bare text's, just left of it; a line's, somewhere along it
1289
+ // clear of its text.
1290
+ var anchors = { svg: null, at: {} };
1291
+ function anchorOf(target) {
1292
+ if (anchors.svg !== svg) anchors = { svg: svg, at: {} };
1293
+ var id = target.kind + ' ' + target.key;
1294
+ if (anchors.at[id]) return anchors.at[id];
1295
+ var at = null;
1296
+ if (target.kind === 'node') {
1297
+ var node = graph.nodes[target.key];
1298
+ var box = node.box;
1299
+ var crowded = node.children.some(function (child) {
1300
+ var other = graph.nodes[child].box;
1301
+ return Math.abs(other[0] - box[0]) < 8 && Math.abs(other[1] - box[1]) < 8;
1302
+ });
1303
+ if (node.bare) at = { x: box[0], y: box[1] + box[3] / 2, el: svg, side: 'left' };
1304
+ else at = { x: crowded ? box[0] + box[2] / 2 : box[0] + 2, y: box[1] + 2, el: svg };
1305
+ } else {
1306
+ var group = edgeGroups(target.key)[0];
1307
+ var stroke = group && group.querySelector('line:not(.target), path[fill="none"]:not(.target), polyline:not(.target)');
1308
+ if (stroke && stroke.getTotalLength) {
1309
+ var texts = Array.prototype.map.call(group.querySelectorAll('text'), function (text) { return text.getBBox(); });
1310
+ var length = stroke.getTotalLength();
1311
+ var clear = function (point) {
1312
+ return texts.every(function (box) {
1313
+ 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;
1314
+ });
1315
+ };
1316
+ var point = null;
1317
+ [0.3, 0.7, 0.2, 0.8, 0.5, 0.12, 0.88].some(function (along) {
1318
+ var tried = stroke.getPointAtLength(length * along);
1319
+ if (!point) point = tried;
1320
+ if (clear(tried)) { point = tried; return true; }
1321
+ return false;
1322
+ });
1323
+ at = { x: point.x, y: point.y, el: stroke };
1324
+ }
1325
+ }
1326
+ anchors.at[id] = at;
1327
+ return at;
1328
+ }
1329
+ // The same, in pixels from the view's top-left corner, or null.
1330
+ function onScreen(target) {
1331
+ var at = anchorOf(target);
1332
+ if (!at) return null;
1333
+ var matrix = at.el.getScreenCTM();
1334
+ if (!matrix) return null;
1335
+ var point = new DOMPoint(at.x, at.y).matrixTransform(matrix);
1336
+ var room = view.getBoundingClientRect();
1337
+ 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 };
1338
+ }
1339
+
1340
+ // Things in view come first, so the one-letter labels go to what can be
1341
+ // seen; then top to bottom and left to right. Boxes before lines.
1342
+ function ordered(targets) {
1343
+ // Links keep the order the source writes them in, as the panel lists them.
1344
+ if (targets.length && targets[0].kind === 'link') return targets;
1345
+ var placed = targets.map(function (target, i) { return { target: target, at: onScreen(target) || { x: 0, y: 0, inView: false }, i: i }; });
1346
+ placed.sort(function (a, b) {
1347
+ if (a.target.kind !== b.target.kind) return a.target.kind === 'node' ? -1 : 1;
1348
+ if (a.at.inView !== b.at.inView) return a.at.inView ? -1 : 1;
1349
+ return Math.round(a.at.y / 24) - Math.round(b.at.y / 24) || a.at.x - b.at.x;
1350
+ });
1351
+ return placed.map(function (one) { return one.target; });
1352
+ }
1353
+
1354
+ function allTargets() {
1355
+ var out = [];
1356
+ Object.keys(graph.nodes).forEach(function (name) { if (shownThing(name)) out.push({ kind: 'node', key: name }); });
1357
+ graph.edges.forEach(function (edge, index) { if (shownEdge(edge)) out.push({ kind: 'edge', key: index }); });
1358
+ return out;
1359
+ }
1360
+ function neighborTargets() {
1361
+ var nodes = [], edges = [];
1362
+ function addNode(name) {
1363
+ if (shownThing(name) && !(selection.kind === 'node' && name === selection.key) && nodes.indexOf(name) < 0) nodes.push(name);
1364
+ }
1365
+ if (selection.kind === 'edge') {
1366
+ var edge = graph.edges[selection.key];
1367
+ addNode(edge.up);
1368
+ addNode(edge.down);
1369
+ } else {
1370
+ graph.edges.forEach(function (edge, index) {
1371
+ if (!shownEdge(edge) || (edge.up !== selection.key && edge.down !== selection.key)) return;
1372
+ edges.push(index);
1373
+ addNode(edge.up === selection.key ? edge.down : edge.up);
1374
+ });
1375
+ }
1376
+ return nodes.map(function (name) { return { kind: 'node', key: name }; })
1377
+ .concat(edges.map(function (index) { return { kind: 'edge', key: index }; }));
1378
+ }
1379
+ function insideTargets() {
1380
+ var node = graph.nodes[selection.key];
1381
+ var out = node.children.filter(shownThing);
1382
+ if (node.parent && shownThing(node.parent)) out.push(node.parent);
1383
+ return out.map(function (name) { return { kind: 'node', key: name }; });
1384
+ }
1385
+ function linkTargets() {
1386
+ var thing = selection.kind === 'node' ? graph.nodes[selection.key] : graph.edges[selection.key];
1387
+ return thing.links.map(function (link, index) { return { kind: 'link', key: index, link: link }; });
1388
+ }
1389
+
1390
+ // As many one-letter labels as there are letters; past that, letters from
1391
+ // the end of the alphabet start two-letter ones, as few as will do.
1392
+ function codes(count) {
1393
+ var letters = 'abcdefghijklmnopqrstuvwxyz';
1394
+ if (count <= 26) return letters.slice(0, count).split('');
1395
+ for (var prefixes = 1; prefixes <= 26; prefixes++) {
1396
+ if (26 - prefixes + 26 * prefixes < count) continue;
1397
+ var out = letters.slice(0, 26 - prefixes).split('');
1398
+ for (var p = 0; out.length < count; p++) {
1399
+ for (var q = 0; q < 26 && out.length < count; q++) out.push(letters[25 - p] + letters[q]);
1400
+ }
1401
+ return out;
1402
+ }
1403
+ var all = [''];
1404
+ while (all.length < count) {
1405
+ var longer = [];
1406
+ all.forEach(function (start) { for (var i = 0; i < 26; i++) longer.push(start + letters[i]); });
1407
+ all = longer;
1408
+ }
1409
+ return all.slice(0, count);
1410
+ }
1411
+
1412
+ var styleNames = {};
1413
+ Object.keys(graph.nodes).forEach(function (name) { graph.nodes[name].styles.forEach(function (one) { styleNames[one] = true; }); });
1414
+ graph.edges.forEach(function (edge) { edge.styles.forEach(function (one) { styleNames[one] = true; }); });
985
1415
 
986
- /* ---- keys, beyond the zoom's own ---- */
1416
+ // The filter: every word must match. Returns the matches, and a message
1417
+ // when a word can match nothing at all, naming what it accepts.
1418
+ function filtered(targets, query) {
1419
+ var words = query.toLowerCase().split(' ').filter(function (word) { return word !== ''; });
1420
+ var message = '';
1421
+ var tests = words.map(function (word) {
1422
+ var colon = word.indexOf(':');
1423
+ var key = colon > 0 ? word.slice(0, colon) : '';
1424
+ var value = colon > 0 ? word.slice(colon + 1) : '';
1425
+ if (key === 'is') {
1426
+ var kinds = KINDS.filter(function (kind) { return kind.indexOf(value) === 0; });
1427
+ if (kinds.length === 0) message = 'is: takes node, edge or container';
1428
+ return function (target, thing) {
1429
+ if (value === '') return true;
1430
+ return kinds.some(function (kind) {
1431
+ if (kind === 'edge') return target.kind === 'edge';
1432
+ if (kind === 'node') return target.kind === 'node';
1433
+ return target.kind === 'node' && thing.children.length > 0;
1434
+ });
1435
+ };
1436
+ }
1437
+ if (key === 'style') {
1438
+ var names = Object.keys(styleNames).filter(function (name) { return name.toLowerCase().indexOf(value) === 0; });
1439
+ if (names.length === 0) message = 'no style named "' + value + '"';
1440
+ return function (target, thing) {
1441
+ return value === '' || (thing.styles || []).some(function (name) { return names.indexOf(name) >= 0; });
1442
+ };
1443
+ }
1444
+ if (key !== '' && /^[a-z]+$/.test(key)) {
1445
+ message = key + ': is not a filter; the filters are is: and style:';
1446
+ return function () { return false; };
1447
+ }
1448
+ return function (target, thing) { return (thing.label || '').toLowerCase().indexOf(word) >= 0; };
1449
+ });
1450
+ var matches = targets.filter(function (target) {
1451
+ var thing = target.kind === 'node' ? graph.nodes[target.key] : target.kind === 'edge' ? graph.edges[target.key] : target.link;
1452
+ return tests.every(function (test) { return test(target, thing); });
1453
+ });
1454
+ return { matches: matches, message: message };
1455
+ }
1456
+
1457
+ function startHints(mode, filtering) {
1458
+ var base;
1459
+ if (mode === 'all') base = allTargets();
1460
+ else if (!selection) { notice('Select something first: s, then a letter'); return; }
1461
+ else if (mode === 'inside' && selection.kind === 'edge') { notice('A line has nothing inside it'); return; }
1462
+ else base = mode === 'neighbors' ? neighborTargets() : mode === 'inside' ? insideTargets() : linkTargets();
1463
+ if (base.length === 0) {
1464
+ 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');
1465
+ return;
1466
+ }
1467
+ hint = { mode: mode, base: base, query: '', typed: '', filtering: !!filtering, direct: !!filtering, shown: [], message: '' };
1468
+ setLevel(filtering ? 'filter' : 'pick');
1469
+ refresh();
1470
+ }
1471
+
1472
+ function clearHints() {
1473
+ if (!hint && !popup) return;
1474
+ hint = null;
1475
+ hintLayer.textContent = '';
1476
+ if (popup) { popup.remove(); popup = null; }
1477
+ each(document.querySelectorAll('#selected .hintkey'), function (key) { key.remove(); });
1478
+ if (svg) {
1479
+ svg.classList.remove('hinting');
1480
+ each(svg.querySelectorAll('.hint-on'), function (group) { group.classList.remove('hint-on'); });
1481
+ }
1482
+ cancelAnimationFrame(frame);
1483
+ }
1484
+
1485
+ function groupsOf(target) {
1486
+ return target.kind === 'node' ? nodeGroups(target.key) : target.kind === 'edge' ? edgeGroups(target.key) : [];
1487
+ }
1488
+
1489
+ // Work out what wears a letter, and draw the letters and the strip.
1490
+ function refresh() {
1491
+ var result = filtered(hint.base, hint.query);
1492
+ hint.message = result.message;
1493
+ var targets = ordered(result.matches);
1494
+ var labels = codes(targets.length);
1495
+ hint.shown = targets.map(function (target, i) { return { target: target, code: labels[i] }; });
1496
+ hint.typed = '';
1497
+ // Dim what can't be picked, unless everything can. Links aren't in the
1498
+ // drawing, so picking one dims nothing.
1499
+ each(svg.querySelectorAll('.hint-on'), function (group) { group.classList.remove('hint-on'); });
1500
+ var narrowed = hint.mode !== 'links' && (hint.mode !== 'all' || hint.shown.length < hint.base.length);
1501
+ svg.classList.toggle('hinting', narrowed);
1502
+ hint.shown.forEach(function (one) { each(groupsOf(one.target), function (group) { group.classList.add('hint-on'); }); });
1503
+ if (selection) each(groupsOf(selection), function (group) { group.classList.add('hint-on'); });
1504
+ drawLabels();
1505
+ showStrip();
1506
+ }
1507
+
1508
+ function keyElement(code) {
1509
+ var key = element('span', 'hintkey');
1510
+ key.setAttribute('data-code', code);
1511
+ key.textContent = code;
1512
+ return key;
1513
+ }
1514
+
1515
+ function drawLabels() {
1516
+ hintLayer.textContent = '';
1517
+ if (popup) { popup.remove(); popup = null; }
1518
+ each(document.querySelectorAll('#selected .hintkey'), function (key) { key.remove(); });
1519
+ if (hint.mode === 'links') {
1520
+ if (!side.hidden) {
1521
+ // On the panel's own rows, where the links already are.
1522
+ var rows = selectedPanel.querySelectorAll('a.link');
1523
+ hint.shown.forEach(function (one) { var row = rows[one.target.key]; if (row) row.prepend(keyElement(one.code)); });
1524
+ } else {
1525
+ // The panel is hidden, so the links come up beside the selection.
1526
+ popup = element('div', '');
1527
+ popup.id = 'link-pop';
1528
+ hint.shown.forEach(function (one) {
1529
+ var row = element('a', 'link');
1530
+ row.href = one.target.link.href;
1531
+ row.target = '_blank';
1532
+ row.rel = 'noopener';
1533
+ row.append(keyElement(one.code));
1534
+ row.insertAdjacentHTML('beforeend', one.target.link.badges[shownTheme]);
1535
+ row.append(element('span', 'label', one.target.link.label));
1536
+ popup.append(row);
1537
+ });
1538
+ view.append(popup);
1539
+ }
1540
+ } else {
1541
+ hint.shown.forEach(function (one) {
1542
+ var key = keyElement(one.code);
1543
+ one.el = key;
1544
+ hintLayer.append(key);
1545
+ });
1546
+ }
1547
+ place();
1548
+ }
1549
+
1550
+ // Letters follow the drawing as it pans and zooms.
1551
+ function place() {
1552
+ cancelAnimationFrame(frame);
1553
+ if (!hint) return;
1554
+ hint.shown.forEach(function (one) {
1555
+ if (!one.el) return;
1556
+ var at = onScreen(one.target);
1557
+ one.el.style.display = at ? '' : 'none';
1558
+ 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%)');
1559
+ });
1560
+ if (popup && selection) {
1561
+ var at = onScreen(selection);
1562
+ if (at) {
1563
+ popup.style.left = Math.round(Math.min(Math.max(at.x, 8), view.clientWidth - popup.offsetWidth - 8)) + 'px';
1564
+ popup.style.top = Math.round(Math.min(Math.max(at.y - popup.offsetHeight - 10, 8), view.clientHeight - popup.offsetHeight - 8)) + 'px';
1565
+ }
1566
+ }
1567
+ frame = requestAnimationFrame(place);
1568
+ }
1569
+
1570
+ function escapeText(text) {
1571
+ var holder = element('span', '', text);
1572
+ return holder.innerHTML;
1573
+ }
1574
+
1575
+ function showStrip() {
1576
+ var crumbs = '<b>' + MODE_WORD[hint.mode] + '</b>';
1577
+ if (hint.mode !== 'all') crumbs += ' ' + (hint.mode === 'inside' ? '' : 'of ') + escapeText(nameOf(selection));
1578
+ if (hint.filtering || hint.query) crumbs += ' \\u203A <span class="query">/' + escapeText(hint.query) + (hint.filtering ? '<i>\\u258C</i>' : '') + '</span>';
1579
+ var count = hint.shown.length;
1580
+ var said;
1581
+ if (hint.filtering) {
1582
+ 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';
1583
+ } else {
1584
+ said = (hint.mode === 'links' ? 'letter to open' : 'letter to pick') + (hint.mode === 'links' ? '' : ' \\u00B7 / filter') + ' \\u00B7 \\u232B back \\u00B7 Esc out \\u00B7 ' + count;
1585
+ }
1586
+ strip.hidden = false;
1587
+ strip.querySelector('.crumbs').innerHTML = crumbs;
1588
+ strip.querySelector('.hint').textContent = said;
1589
+ }
1590
+
1591
+ function notice(text) {
1592
+ clearTimeout(noticeTimer);
1593
+ if (level) setLevel(null);
1594
+ strip.hidden = false;
1595
+ strip.querySelector('.crumbs').textContent = '';
1596
+ strip.querySelector('.hint').textContent = text;
1597
+ noticeTimer = setTimeout(function () { if (!level) strip.hidden = true; }, 1800);
1598
+ }
1599
+
1600
+ // A picked thing is selected, and brought into view if it was out of it.
1601
+ function act(target) {
1602
+ if (target.kind === 'link') {
1603
+ setLevel(null);
1604
+ window.open(target.link.href, '_blank', 'noopener');
1605
+ return;
1606
+ }
1607
+ setLevel(null);
1608
+ select(target);
1609
+ var at = onScreen(target);
1610
+ if (at && !at.inView) zoom.pan(view.clientWidth / 2 - at.x, view.clientHeight / 2 - at.y);
1611
+ }
1612
+
1613
+ // The keys at the two levels. Returns false for a key left to the page.
1614
+ function hintKey(event) {
1615
+ var key = event.key;
1616
+ if (hint.filtering) {
1617
+ if (key === 'Enter') {
1618
+ if (hint.shown.length === 1) act(hint.shown[0].target);
1619
+ else if (hint.shown.length > 1) { hint.filtering = false; setLevel('pick'); showStrip(); }
1620
+ } else if (key === 'Backspace') {
1621
+ if (hint.query) { hint.query = hint.query.slice(0, -1); refresh(); }
1622
+ else if (hint.direct) setLevel(null);
1623
+ else { hint.filtering = false; setLevel('pick'); showStrip(); }
1624
+ } else if (key.length === 1) { hint.query += key; refresh(); }
1625
+ else return false;
1626
+ return true;
1627
+ }
1628
+ if (ZOOM_KEYS[key] || key === 'Tab') return false;
1629
+ if (key === 'Backspace') {
1630
+ if (hint.typed) { hint.typed = hint.typed.slice(0, -1); markTyped(); }
1631
+ else if (hint.query || hint.mode === 'all' && hint.direct) { hint.filtering = true; setLevel('filter'); showStrip(); }
1632
+ else setLevel(null);
1633
+ return true;
1634
+ }
1635
+ if (key === '/' && hint.mode !== 'links') { hint.filtering = true; setLevel('filter'); showStrip(); return true; }
1636
+ if (key.length === 1 && /[a-z]/i.test(key)) {
1637
+ var typed = hint.typed + key.toLowerCase();
1638
+ var exact = hint.shown.filter(function (one) { return one.code === typed; })[0];
1639
+ if (exact) { act(exact.target); return true; }
1640
+ if (hint.shown.some(function (one) { return one.code.indexOf(typed) === 0; })) { hint.typed = typed; markTyped(); }
1641
+ return true;
1642
+ }
1643
+ return key.indexOf('Arrow') !== 0;
1644
+ }
1645
+
1646
+ // Letters that no longer fit what has been typed step aside.
1647
+ function markTyped() {
1648
+ hint.shown.forEach(function (one) {
1649
+ var key = one.el || document.querySelector('#selected .hintkey[data-code="' + one.code + '"], #link-pop .hintkey[data-code="' + one.code + '"]');
1650
+ if (!key) return;
1651
+ var fits = one.code.indexOf(hint.typed) === 0;
1652
+ key.classList.toggle('gone', !fits);
1653
+ key.innerHTML = fits && hint.typed ? '<span class="done">' + hint.typed + '</span>' + one.code.slice(hint.typed.length) : one.code;
1654
+ });
1655
+ }
1656
+
1657
+ saveButton.onclick = function () { setLevel(level === 'save' ? null : 'save'); };
1658
+ viewButton.onclick = function () { setLevel(level === 'view' || level === 'theme' ? null : 'view'); };
1659
+ themeRow.onclick = function () { setLevel(level === 'theme' ? 'view' : 'theme'); };
1660
+ document.addEventListener('click', function (event) {
1661
+ if (level && !(event.target.closest && event.target.closest('.menu'))) setLevel(null);
1662
+ });
1663
+
1664
+ function move(by) {
1665
+ var all = rows();
1666
+ if (!all.length) return;
1667
+ cursor = (cursor + by + all.length) % all.length;
1668
+ markCursor();
1669
+ if (level === 'theme') showTheme(themeItems[cursor].value || DATA.current);
1670
+ }
1671
+
1672
+ function enter() {
1673
+ var all = rows();
1674
+ if (!all.length) return;
1675
+ if (level === 'theme') keepTheme(themeItems[cursor].value);
1676
+ else all[cursor].click();
1677
+ }
1678
+
1679
+ var ZOOM_KEYS = { '+': 1, '=': 1, '-': 1, '_': 1, '0': 1, '1': 1 };
1680
+
1681
+ // On the window, before the zoom's own keys, so a menu's letters reach the
1682
+ // menu and not the diagram underneath: g flips guides in View, not a grab.
1683
+ window.addEventListener('keydown', function (event) {
1684
+ if (event.ctrlKey || event.metaKey || event.altKey) return;
1685
+ if (event.target.closest && event.target.closest('input, textarea, select, [contenteditable]')) return;
1686
+ var key = event.key;
1687
+ if (key === 'Escape') plain();
1688
+ else if (level === 'pick' || level === 'filter') {
1689
+ if (!hintKey(event)) return;
1690
+ } else if (level === null) {
1691
+ if (key === 'm' || key === 'M') setLevel('bar');
1692
+ else if (key === 's') startHints('all');
1693
+ else if (key === '/') startHints('all', true);
1694
+ else if (key === 'n') startHints('neighbors');
1695
+ else if (key === 'i') startHints('inside');
1696
+ // During a grab, l pans right.
1697
+ else if (key === 'l' && !zoom.held()) startHints('links');
1698
+ else if (key === 'Backspace') {
1699
+ if (zoom.held()) zoom.grab(false);
1700
+ else if (lit.length) unlight();
1701
+ else if (selection) select(null);
1702
+ else return;
1703
+ } else return;
1704
+ } else {
1705
+ if (ZOOM_KEYS[key] || key === 'Tab') return;
1706
+ if (key === 'Backspace') back();
1707
+ else if (key === 'ArrowDown') move(1);
1708
+ else if (key === 'ArrowUp') move(-1);
1709
+ else if (key === 'Enter' || key === ' ') enter();
1710
+ else if (key.length === 1) {
1711
+ var letter = key.toLowerCase();
1712
+ if (level === 'bar') {
1713
+ if (letter === 'v') setLevel('view');
1714
+ else if (letter === 's') setLevel('save');
1715
+ else if (letter === 'f' && !full.hidden) { setLevel(null); toggleFull(); }
1716
+ } else if (level === 'view' || level === 'save') {
1717
+ rows().forEach(function (row, i) {
1718
+ if (row.getAttribute('data-key') !== letter) return;
1719
+ cursor = i;
1720
+ markCursor();
1721
+ row.click();
1722
+ });
1723
+ }
1724
+ } else if (key.indexOf('Arrow') !== 0) return;
1725
+ }
1726
+ event.preventDefault();
1727
+ event.stopImmediatePropagation();
1728
+ }, true);
987
1729
 
1730
+ // With the menus closed, p and f still show the panel and go full screen.
988
1731
  document.addEventListener('keydown', function (event) {
989
1732
  if (event.ctrlKey || event.metaKey || event.altKey) return;
990
1733
  if (event.target.closest && event.target.closest('input, textarea, select')) return;
991
1734
  if (event.key === 'p') setPanel(!panelOn);
992
1735
  else if (event.key === 'f' && !full.hidden) toggleFull();
993
- else if (event.key === 'Escape') { showSave(false); unlight(); select(null); }
994
1736
  else return;
995
1737
  event.preventDefault();
996
1738
  });