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/.claude/skills/reladraw/SKILL.md +1 -1
- package/.claude/skills/reladraw/reference/syntax.md +18 -5
- package/README.md +1 -1
- package/SYNTAX.md +18 -5
- package/dist/page.js +848 -106
- package/dist/viewer.d.ts +2 -2
- package/dist/viewer.js +8 -7
- package/package.json +1 -1
- package/dist/search.d.ts +0 -104
- package/dist/search.js +0 -490
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
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({
|
|
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] = {
|
|
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 {
|
|
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
|
-
//
|
|
140
|
-
// as the reader's choice, dark ones together and light
|
|
141
|
-
// playground's picker has them, so stepping through the
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
470
|
-
|
|
471
|
-
|
|
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,
|
|
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
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
|
|
659
|
-
|
|
660
|
-
autoHide =
|
|
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
|
-
|
|
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
|
-
/* ----
|
|
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
|
-
//
|
|
952
|
-
//
|
|
953
|
-
function
|
|
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
|
-
|
|
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 (
|
|
1152
|
+
if (value) localStorage.setItem(CHOSEN_THEME, value);
|
|
972
1153
|
else localStorage.removeItem(CHOSEN_THEME);
|
|
973
1154
|
} catch (e) {}
|
|
974
|
-
showTheme(
|
|
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
|
|
980
|
-
if (
|
|
981
|
-
|
|
982
|
-
showTheme(
|
|
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
|
-
|
|
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
|
});
|