reladraw 0.26.1 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/reladraw/SKILL.md +7 -5
- package/.claude/skills/reladraw/reference/syntax.md +55 -7
- package/README.md +2 -2
- package/SYNTAX.md +55 -7
- package/dist/cli.js +15 -1
- package/dist/examples.d.ts +26 -0
- package/dist/examples.js +56 -0
- package/dist/page-data.d.ts +130 -0
- package/dist/page-data.js +226 -0
- package/dist/page.d.ts +46 -2
- package/dist/page.js +1992 -252
- package/dist/serve.d.ts +18 -0
- package/dist/serve.js +544 -0
- package/dist/viewer.js +1 -1
- package/examples/arch.reladraw +95 -0
- package/examples/around.reladraw +101 -0
- package/examples/borders.reladraw +28 -0
- package/examples/bundles.reladraw +49 -0
- package/examples/circles.reladraw +37 -0
- package/examples/coincident.reladraw +66 -0
- package/examples/contents.reladraw +52 -0
- package/examples/corridors.reladraw +48 -0
- package/examples/deck.reladraw +26 -0
- package/examples/flowchart.reladraw +23 -0
- package/examples/frames.reladraw +79 -0
- package/examples/gap.reladraw +21 -0
- package/examples/gaps.reladraw +25 -0
- package/examples/icons.reladraw +35 -0
- package/examples/lanes.reladraw +54 -0
- package/examples/lines.reladraw +73 -0
- package/examples/linetexts.reladraw +48 -0
- package/examples/marks.reladraw +39 -0
- package/examples/names.reladraw +23 -0
- package/examples/outlines.reladraw +56 -0
- package/examples/overhang.reladraw +24 -0
- package/examples/overlays.reladraw +75 -0
- package/examples/parts.reladraw +31 -0
- package/examples/passes.reladraw +56 -0
- package/examples/points.reladraw +28 -0
- package/examples/regions.reladraw +14 -0
- package/examples/routing.reladraw +88 -0
- package/examples/shapes.reladraw +38 -0
- package/examples/snug.reladraw +8 -0
- package/examples/text.reladraw +69 -0
- package/examples/themes.reladraw +16 -0
- package/examples/urls.reladraw +35 -0
- package/examples/venn.reladraw +15 -0
- package/package.json +2 -1
- package/dist/search.d.ts +0 -104
- package/dist/search.js +0 -490
package/dist/page.js
CHANGED
|
@@ -1,11 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { SourceError } from './errors.js';
|
|
3
|
-
import { blame, parse } from './parser.js';
|
|
4
|
-
import { linkLabel, linksOf } from './links.js';
|
|
5
|
-
import { markdownHtml } from './markdown.js';
|
|
6
|
-
import { iconSvg, renderTagged, themeOf } from './render.js';
|
|
7
|
-
import { resolve } from './resolve.js';
|
|
8
|
-
import { DEFAULT_THEME, luminance, THEME_NAMES, THEMES } from './themes.js';
|
|
1
|
+
import { dataOf, partsOf } from './page-data.js';
|
|
9
2
|
import { PAN_ZOOM } from './viewer.js';
|
|
10
3
|
/**
|
|
11
4
|
* `reladraw render --page`: one diagram in one self-contained HTML page, made
|
|
@@ -18,173 +11,56 @@ import { PAN_ZOOM } from './viewer.js';
|
|
|
18
11
|
* none, and the name everything saved from the page is given. The page is a
|
|
19
12
|
* snapshot of the file at `at`, and says so under its title.
|
|
20
13
|
*/
|
|
21
|
-
export function drawPage(source, file, options = {}, at = new Date()) {
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
const run = (text) => {
|
|
25
|
-
const layout = resolve(parse(text), options);
|
|
26
|
-
const found = options.checks === false ? [] : findings(layout, options.checks ? options.checks(fileChecks(layout)) : fileChecks(layout));
|
|
27
|
-
const title = layout.diagram['title'] ?? name;
|
|
28
|
-
// The theme it was drawn in: the one --theme named, else the file's.
|
|
29
|
-
const named = layout.diagram['theme'];
|
|
30
|
-
const current = (options.theme && THEME_NAMES.find((one) => THEMES[one] === options.theme)) ??
|
|
31
|
-
(named !== undefined && THEMES[named] !== undefined ? named : DEFAULT_THEME);
|
|
32
|
-
// The same diagram drawn in every theme, for the page's theme picker. A
|
|
33
|
-
// theme moves nothing, so only the colors differ between them. A page or
|
|
34
|
-
// text color the file states still holds in each, as in the playground.
|
|
35
|
-
const themes = {};
|
|
36
|
-
for (const one of THEME_NAMES) {
|
|
37
|
-
const drawnIn = { ...options, theme: options.theme && one === current ? options.theme : THEMES[one] };
|
|
38
|
-
const theme = themeOf(layout, drawnIn);
|
|
39
|
-
themes[one] = { ...renderTagged(layout, drawnIn), theme, colors: colorsOf(theme), scheme: schemeOf(theme) };
|
|
40
|
-
}
|
|
41
|
-
// A --theme that is not one of the named ones is drawn as given, and offered as such.
|
|
42
|
-
if (themes[current] === undefined) {
|
|
43
|
-
const theme = themeOf(layout, options);
|
|
44
|
-
themes[current] = { ...renderTagged(layout, options), theme, colors: colorsOf(theme), scheme: schemeOf(theme) };
|
|
45
|
-
}
|
|
46
|
-
return {
|
|
47
|
-
html: page({ title, name, drawn, source, findings: found, themes, current, graph: graphOf(layout, themes) }),
|
|
48
|
-
findings: found,
|
|
49
|
-
};
|
|
50
|
-
};
|
|
51
|
-
try {
|
|
52
|
-
return run(source);
|
|
53
|
-
}
|
|
54
|
-
catch (error) {
|
|
55
|
-
throw error instanceof SourceError ? blame(source, error, run) : error;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
/** A description as the panel shows it: its Markdown as HTML, or nothing. */
|
|
59
|
-
const describe = (attrs) => attrs['description'] === undefined ? {} : { description: markdownHtml(attrs['description']) };
|
|
60
|
-
const BADGE_SIDE = 20;
|
|
61
|
-
function graphOf(layout, themes) {
|
|
62
|
-
const views = (links) => (links ?? []).map((link) => {
|
|
63
|
-
const label = linkLabel(link, links);
|
|
64
|
-
const icon = link.icon === undefined
|
|
65
|
-
? { letters: [...label.trim()][0]?.toUpperCase() ?? '?' }
|
|
66
|
-
: 'picture' in link.icon
|
|
67
|
-
? layout.linkIcons[link.icon.picture]
|
|
68
|
-
: link.icon;
|
|
69
|
-
const badges = {};
|
|
70
|
-
for (const [one, view] of Object.entries(themes))
|
|
71
|
-
badges[one] = iconSvg(icon, BADGE_SIDE, view.theme);
|
|
72
|
-
return { href: link.href, label, badges };
|
|
73
|
-
});
|
|
74
|
-
// A node with no links of its own has its container's, as a click on it in
|
|
75
|
-
// the SVG follows its container's.
|
|
76
|
-
const linksFor = (node) => {
|
|
77
|
-
for (let at = node; at; at = at.parent) {
|
|
78
|
-
const own = linksOf(at.attrs);
|
|
79
|
-
if (own !== undefined)
|
|
80
|
-
return own;
|
|
81
|
-
}
|
|
82
|
-
return undefined;
|
|
83
|
-
};
|
|
84
|
-
const nodes = {};
|
|
85
|
-
for (const node of layout.nodes) {
|
|
86
|
-
const text = plainText(node.text);
|
|
87
|
-
nodes[node.name] = { label: text === '' ? node.name.split('.').pop() : text, links: views(linksFor(node)), ...describe(node.attrs) };
|
|
88
|
-
}
|
|
89
|
-
const edges = layout.edges.map((edge) => {
|
|
90
|
-
// The upstream end is the one a line runs from: the end without a mark
|
|
91
|
-
// when only the other has one, and otherwise the end written first.
|
|
92
|
-
const [, start = '', , end = ''] = edge.arrow.match(/^(.*?)(-{1,2})(.*)$/) ?? [];
|
|
93
|
-
const backwards = start !== '' && end === '';
|
|
94
|
-
const [up, down] = backwards ? [edge.to, edge.from] : [edge.from, edge.to];
|
|
95
|
-
const text = edge.texts.map((one) => plainText(one.text)).filter((one) => one !== '').join(' · ');
|
|
96
|
-
return { up: up.name, down: down.name, label: text, links: views(linksOf(edge.attrs)), ...describe(edge.attrs) };
|
|
97
|
-
});
|
|
98
|
-
return { nodes, edges };
|
|
99
|
-
}
|
|
100
|
-
/** A text as a reader sees it: markup such as `[dim]` taken out, its lines run together. */
|
|
101
|
-
function plainText(text) {
|
|
102
|
-
return text.replace(/\[\/?[a-z][a-z0-9-]*\]/gi, '').replace(/\s*\n\s*/g, ' ').trim();
|
|
14
|
+
export function drawPage(source, file, options = {}, at = new Date(), editing) {
|
|
15
|
+
const parts = partsOf(source, file, options, at);
|
|
16
|
+
return { html: page(parts, editing ?? null), findings: parts.findings };
|
|
103
17
|
}
|
|
104
|
-
/** A local time as `2026-11-05 14:32`, which sorts and reads the same everywhere. */
|
|
105
|
-
function stamp(at) {
|
|
106
|
-
const two = (n) => String(n).padStart(2, '0');
|
|
107
|
-
return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
|
|
108
|
-
}
|
|
109
|
-
const hex = (color) => /^#[0-9a-f]{6}$/i.test(color);
|
|
110
18
|
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* Split on, its matches are the odd pieces. Matching too much costs nothing;
|
|
114
|
-
* a drawing whose other pieces differ is carried whole.
|
|
19
|
+
* The page with no file: the playground. It opens on the first example and
|
|
20
|
+
* draws whatever its visitor last had in that browser, with the source shown.
|
|
115
21
|
*/
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
:
|
|
126
|
-
}
|
|
127
|
-
function colorsOf(theme) {
|
|
128
|
-
return {
|
|
129
|
-
'--page': theme.background,
|
|
130
|
-
'--ink': theme.text,
|
|
131
|
-
'--muted': theme.mutedText,
|
|
132
|
-
'--rule': theme.boxStroke,
|
|
133
|
-
'--accent': theme.primary.color,
|
|
134
|
-
'--on-accent': theme.primary.on,
|
|
135
|
-
};
|
|
22
|
+
export function playgroundPage(playground) {
|
|
23
|
+
const { examples, folder } = playground;
|
|
24
|
+
const parts = partsOf(examples[0]?.source ?? '', 'diagram.reladraw', {}, new Date());
|
|
25
|
+
const editing = folder !== undefined ? { kind: 'local', folder, examples } : { kind: 'hosted', examples };
|
|
26
|
+
return page(parts, editing, {
|
|
27
|
+
title: folder !== undefined ? `reladraw · ${folder}` : 'reladraw playground: write diagrams in the browser',
|
|
28
|
+
heading: 'reladraw',
|
|
29
|
+
drawn: folder !== undefined ? `local playground · ${folder} · not saved to a file` : 'a diagram language where you say where things go',
|
|
30
|
+
head: playground.head ?? '',
|
|
31
|
+
library: playground.library ?? '',
|
|
32
|
+
});
|
|
136
33
|
}
|
|
137
|
-
function page(
|
|
34
|
+
function page(parts, editing, framing) {
|
|
35
|
+
const { themes, current } = parts;
|
|
138
36
|
const shown = themes[current];
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
source,
|
|
161
|
-
current,
|
|
162
|
-
varies: VARIES.source,
|
|
163
|
-
themes: Object.fromEntries(Object.entries(themes).map(([one, view]) => [
|
|
164
|
-
one,
|
|
165
|
-
one === current
|
|
166
|
-
? { svg: view.svg, tagged: view.tagged, colors: view.colors, scheme: view.scheme }
|
|
167
|
-
: { svg: pack(view.svg, shown.svg), tagged: pack(view.tagged, shown.tagged), colors: view.colors, scheme: view.scheme },
|
|
168
|
-
])),
|
|
169
|
-
findings: findings.map((finding) => ({ kind: finding.kind, message: finding.message, line: finding.line })),
|
|
170
|
-
graph,
|
|
37
|
+
const data = { ...dataOf(parts), edit: editing };
|
|
38
|
+
const title = framing?.title ?? parts.title;
|
|
39
|
+
const noFile = editing !== null && editing.kind !== 'file';
|
|
40
|
+
const filled = {
|
|
41
|
+
panzoom: PAN_ZOOM,
|
|
42
|
+
scheme: shown.scheme,
|
|
43
|
+
title: escapeMarkup(title),
|
|
44
|
+
head: framing?.head ?? '',
|
|
45
|
+
heading: escapeMarkup(framing?.heading ?? parts.title),
|
|
46
|
+
drawn: escapeMarkup(framing?.drawn ?? parts.drawn),
|
|
47
|
+
links: noFile ? LINKS : '',
|
|
48
|
+
colors: [
|
|
49
|
+
...Object.entries(shown.colors).map(([key, value]) => `${key}: ${value};`),
|
|
50
|
+
'--sel: #e0b25a; --up: #22b98c; --down: #e5484d;',
|
|
51
|
+
].join(' '),
|
|
52
|
+
cheatsheet: editing ? CHEATSHEET : '',
|
|
53
|
+
dialogs: noFile ? DIALOGS : '',
|
|
54
|
+
drawing: shown.tagged,
|
|
55
|
+
// A literal </script> in the library's text would end its tag early.
|
|
56
|
+
library: framing?.library ? `<script>\n${framing.library.replace(/<\/script/gi, '<\\/script')}\n</script>` : '',
|
|
57
|
+
data: scriptJson(data),
|
|
171
58
|
};
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
return PAGE.replace(
|
|
175
|
-
.replace('{{scheme}}', () => shown.scheme)
|
|
176
|
-
.replace('{{title}}', () => escapeHtml(title))
|
|
177
|
-
.replace('{{heading}}', () => escapeHtml(title))
|
|
178
|
-
.replace('{{drawn}}', () => escapeHtml(drawn))
|
|
179
|
-
.replace('{{colors}}', () => [
|
|
180
|
-
...Object.entries(shown.colors).map(([key, value]) => `${key}: ${value};`),
|
|
181
|
-
'--sel: #e0b25a; --up: #22b98c; --down: #e5484d;',
|
|
182
|
-
].join(' '))
|
|
183
|
-
.replace('{{themes}}', () => picker)
|
|
184
|
-
.replace('{{drawing}}', () => shown.tagged)
|
|
185
|
-
.replace('{{data}}', () => scriptJson(data));
|
|
59
|
+
// One pass, so nothing put in (a title, a drawing, the library) is read
|
|
60
|
+
// again as a marker, and a function, so a `$` in it is taken as written.
|
|
61
|
+
return PAGE.replace(/\{\{([a-z]+)\}\}/g, (marker, key) => filled[key] ?? marker);
|
|
186
62
|
}
|
|
187
|
-
function
|
|
63
|
+
function escapeMarkup(text) {
|
|
188
64
|
// `{` too, so a title or file name can't be taken for a later marker.
|
|
189
65
|
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/\{/g, '{');
|
|
190
66
|
}
|
|
@@ -201,6 +77,7 @@ const PAGE = `<!doctype html>
|
|
|
201
77
|
<meta name="color-scheme" content="{{scheme}}">
|
|
202
78
|
<meta name="generator" content="reladraw">
|
|
203
79
|
<title>{{title}}</title>
|
|
80
|
+
{{head}}
|
|
204
81
|
<style>
|
|
205
82
|
:root { {{colors}} }
|
|
206
83
|
* { box-sizing: border-box; }
|
|
@@ -278,9 +155,56 @@ const PAGE = `<!doctype html>
|
|
|
278
155
|
padding: 3px 6px; cursor: pointer;
|
|
279
156
|
}
|
|
280
157
|
select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
158
|
+
|
|
159
|
+
/* ---- the menus, and the keys that reach them ----
|
|
160
|
+
What the keys act on is ringed in amber, and the amber moves down with
|
|
161
|
+
them: the bar after m, then the open menu and the row the arrows are on,
|
|
162
|
+
then the theme list. The strip under the bar says where the keys are and
|
|
163
|
+
how to get out. */
|
|
164
|
+
.menu > button { position: relative; }
|
|
165
|
+
kbd {
|
|
166
|
+
font: 11px ui-monospace, Menlo, Consolas, monospace; padding: 0 5px; border-radius: 3px;
|
|
167
|
+
border: 1px solid color-mix(in srgb, var(--rule) 70%, transparent); color: var(--muted);
|
|
168
|
+
}
|
|
169
|
+
kbd.tag {
|
|
170
|
+
display: none; position: absolute; top: -8px; right: -7px; z-index: 6;
|
|
171
|
+
padding: 0 4px; border: 0; font-weight: 700; background: var(--sel); color: #1a1406;
|
|
172
|
+
}
|
|
173
|
+
#full { position: relative; }
|
|
174
|
+
.tools.keyed { outline: 2px solid var(--sel); outline-offset: 4px; border-radius: 6px; }
|
|
175
|
+
.tools.keyed kbd.tag { display: inline; }
|
|
176
|
+
.menu > button.open { border-color: var(--sel); background: color-mix(in srgb, var(--sel) 22%, transparent); }
|
|
177
|
+
.menu-list { min-width: 15rem; }
|
|
178
|
+
.menu-list .row {
|
|
179
|
+
display: flex; align-items: center; gap: 8px; width: 100%; text-align: left;
|
|
180
|
+
border-color: transparent; padding: 5px 8px;
|
|
181
|
+
}
|
|
182
|
+
.menu-list .row .name { flex: 1; }
|
|
183
|
+
.menu-list .row .value { color: var(--muted); }
|
|
184
|
+
.menu-list .row.cur { background: color-mix(in srgb, var(--ink) 12%, transparent); box-shadow: inset 3px 0 0 var(--sel); }
|
|
185
|
+
/* Its rows, not the list, so the theme list inside it stays solid. */
|
|
186
|
+
.menu-list.behind > .row, .menu-list.behind > hr { opacity: 0.5; }
|
|
187
|
+
.menu-list.sub { right: calc(100% + 6px); top: 0; max-height: 70vh; overflow: auto; gap: 0; }
|
|
188
|
+
.menu-list .group { padding: 6px 8px 2px; font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
|
|
189
|
+
.menu-list .mark { width: 12px; flex: none; color: var(--up); }
|
|
190
|
+
/* A switch: green and to the right when on, gray and to the left when off,
|
|
191
|
+
with the word beside it, so the state never rests on color alone. */
|
|
192
|
+
.sw { flex: none; position: relative; width: 30px; height: 16px; border-radius: 9px; background: color-mix(in srgb, var(--ink) 25%, transparent); }
|
|
193
|
+
.sw::after { content: ''; position: absolute; top: 2px; left: 2px; width: 12px; height: 12px; border-radius: 50%; background: color-mix(in srgb, var(--ink) 55%, transparent); }
|
|
194
|
+
.switch[aria-checked="true"] .sw { background: #2f9e5a; }
|
|
195
|
+
.switch[aria-checked="true"] .sw::after { left: 16px; background: #fff; }
|
|
196
|
+
.switch .word { width: 2em; text-align: right; font-size: 12px; color: var(--muted); }
|
|
197
|
+
.switch[aria-checked="true"] .word { color: var(--up); }
|
|
198
|
+
.switch[aria-checked="false"] .name { color: var(--muted); }
|
|
199
|
+
.menu-list .row:disabled { opacity: 0.45; }
|
|
200
|
+
#strip {
|
|
201
|
+
display: flex; justify-content: space-between; gap: 16px; padding: 3px 16px; font-size: 12px;
|
|
202
|
+
background: color-mix(in srgb, var(--sel) 16%, var(--page)); color: var(--ink);
|
|
203
|
+
border-bottom: 1px solid color-mix(in srgb, var(--sel) 45%, transparent);
|
|
204
|
+
}
|
|
205
|
+
#strip[hidden] { display: none; }
|
|
206
|
+
#strip .crumbs b { color: var(--sel); }
|
|
207
|
+
#strip .hint { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
284
208
|
|
|
285
209
|
/* ---- what is selected ---- */
|
|
286
210
|
#selected { padding: 10px 16px 12px; overflow: auto; flex: 0 1 auto; min-height: 0; }
|
|
@@ -328,6 +252,21 @@ const PAGE = `<!doctype html>
|
|
|
328
252
|
#view g[data-edge].up :is(line, path[fill="none"], polyline):not(.target) { stroke: var(--up); stroke-dasharray: 8 5; animation: run .9s linear infinite; }
|
|
329
253
|
#view g[data-edge].down :is(line, path[fill="none"], polyline):not(.target) { stroke: var(--down); stroke-dasharray: 8 5; animation: run .9s linear infinite; }
|
|
330
254
|
#view g[data-edge] .target { stroke: #000 !important; stroke-opacity: 0; }
|
|
255
|
+
#view svg.hinting g[data-name]:not(.hint-on) { opacity: .25 !important; }
|
|
256
|
+
#view svg.hinting g[data-name].hint-on { opacity: 1 !important; }
|
|
257
|
+
#hints { position: absolute; inset: 0; pointer-events: none; z-index: 4; }
|
|
258
|
+
.hintkey { font: 700 12px/16px ui-monospace, Menlo, Consolas, monospace; padding: 0 4px; border-radius: 3px;
|
|
259
|
+
background: var(--sel); color: #1a1406; box-shadow: 0 0 0 1px rgba(0, 0, 0, .55); white-space: nowrap; }
|
|
260
|
+
#hints .hintkey { position: absolute; left: 0; top: 0; }
|
|
261
|
+
.hintkey .done { opacity: .45; }
|
|
262
|
+
.hintkey.gone { visibility: hidden; }
|
|
263
|
+
#selected a.link .hintkey { margin-right: 2px; }
|
|
264
|
+
#link-pop { position: absolute; z-index: 5; display: flex; flex-direction: column; gap: 2px; padding: 6px 8px; font-size: 13px;
|
|
265
|
+
background: var(--page); border: 1px solid var(--sel); border-radius: 6px; box-shadow: 0 4px 16px rgba(0, 0, 0, .35); }
|
|
266
|
+
#link-pop a.link { display: flex; align-items: center; gap: 6px; color: var(--ink); text-decoration: none; }
|
|
267
|
+
#link-pop a.link svg { flex: none; }
|
|
268
|
+
#strip .query { color: var(--ink); }
|
|
269
|
+
#strip .query i { font-style: normal; color: var(--sel); }
|
|
331
270
|
@keyframes run { to { stroke-dashoffset: -26; } }
|
|
332
271
|
@media (prefers-reduced-motion: reduce) { #view g[data-edge] :is(line, path, polyline) { animation: none !important; } }
|
|
333
272
|
aside h2 { margin: 0; padding: 10px 16px 6px; font-size: 13px; font-weight: 600; }
|
|
@@ -338,10 +277,137 @@ const PAGE = `<!doctype html>
|
|
|
338
277
|
padding: 6px 8px; margin: 2px 0;
|
|
339
278
|
}
|
|
340
279
|
aside li button.chosen { border-color: var(--accent); }
|
|
280
|
+
aside li.none { padding: 6px 8px; color: var(--muted); font-size: 13px; }
|
|
281
|
+
aside li.error { padding: 6px 8px; margin: 2px 0; border-left: 3px solid var(--down); font-size: 13px; }
|
|
282
|
+
aside li.error .kind { color: var(--down); }
|
|
283
|
+
#live {
|
|
284
|
+
padding: 5px 16px; font-size: 12.5px;
|
|
285
|
+
background: color-mix(in srgb, var(--down) 18%, var(--page)); color: var(--ink);
|
|
286
|
+
border-bottom: 1px solid color-mix(in srgb, var(--down) 45%, transparent);
|
|
287
|
+
}
|
|
288
|
+
#live[hidden] { display: none; }
|
|
289
|
+
|
|
290
|
+
/* The editor: a colored layer under a transparent textarea, every property
|
|
291
|
+
that can move a character the same on both, as in the playground. */
|
|
292
|
+
#editor {
|
|
293
|
+
width: 38%; min-width: 280px; max-width: 760px; display: flex; flex-direction: column;
|
|
294
|
+
border-right: 1px solid color-mix(in srgb, var(--rule) 45%, transparent);
|
|
295
|
+
background: color-mix(in srgb, var(--ink) 4%, var(--page));
|
|
296
|
+
}
|
|
297
|
+
#editor[hidden] { display: none; }
|
|
298
|
+
.src-head { display: flex; align-items: center; gap: 6px; padding: 6px 10px; font-size: 12.5px;
|
|
299
|
+
border-bottom: 1px solid color-mix(in srgb, var(--rule) 45%, transparent); }
|
|
300
|
+
.src-head .file { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
301
|
+
#unsaved { color: var(--sel); }
|
|
302
|
+
#unsaved[hidden] { display: none; }
|
|
303
|
+
.src-head .gap, #changed .gap { flex: 1; }
|
|
304
|
+
.src-head kbd { font: 11px system-ui, sans-serif; color: var(--muted); margin-left: 4px; }
|
|
305
|
+
#changed { display: flex; align-items: center; gap: 6px; padding: 6px 10px; font-size: 12.5px;
|
|
306
|
+
background: color-mix(in srgb, var(--sel) 18%, var(--page));
|
|
307
|
+
border-bottom: 1px solid color-mix(in srgb, var(--sel) 45%, transparent); }
|
|
308
|
+
#changed[hidden] { display: none; }
|
|
309
|
+
.code { position: relative; flex: 1; min-height: 0; }
|
|
310
|
+
.code > * {
|
|
311
|
+
position: absolute; inset: 0; margin: 0; border: 0; padding: 12px 14px;
|
|
312
|
+
font: 12.5px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, 'DejaVu Sans Mono', monospace;
|
|
313
|
+
tab-size: 2; white-space: pre-wrap; overflow-wrap: break-word; word-break: normal;
|
|
314
|
+
}
|
|
315
|
+
#paint { overflow: hidden; color: var(--ink); pointer-events: none; }
|
|
316
|
+
#source { resize: none; outline: 0; background: transparent; color: transparent; caret-color: var(--ink); overflow-y: auto; }
|
|
317
|
+
#source::selection { background: rgba(130, 170, 240, 0.28); color: transparent; }
|
|
318
|
+
:root { --syn-comment: #6a6a78; --syn-string: #9ec99a; --syn-keyword: #82aaf0; --syn-name: #b8c9e8;
|
|
319
|
+
--syn-relation: #c79bdc; --syn-attribute: #6fb3ac; --syn-value: #d3a05f; }
|
|
320
|
+
:root[data-scheme="light"] { --syn-comment: #8a8a96; --syn-string: #3f7a3a; --syn-keyword: #2f5fb8; --syn-name: #34495e;
|
|
321
|
+
--syn-relation: #8a3fa8; --syn-attribute: #217a72; --syn-value: #a0601a; }
|
|
322
|
+
.t-comment, .t-bracket, .t-markup { color: var(--syn-comment); }
|
|
323
|
+
.t-string { color: var(--syn-string); }
|
|
324
|
+
.t-keyword { color: var(--syn-keyword); }
|
|
325
|
+
.t-name { color: var(--syn-name); }
|
|
326
|
+
.t-relation, .t-arrow { color: var(--syn-relation); }
|
|
327
|
+
.t-attribute { color: var(--syn-attribute); }
|
|
328
|
+
.t-value, .t-color { color: var(--syn-value); }
|
|
329
|
+
#edit[aria-pressed="true"] { border-color: var(--sel); color: var(--sel); }
|
|
330
|
+
.menu-list.wide { min-width: 340px; }
|
|
331
|
+
.menu-list .row .when { color: var(--muted); font-variant-numeric: tabular-nums; width: 3.2em; flex: none; }
|
|
332
|
+
.menu-list .row .who { width: 4.5em; flex: none; }
|
|
333
|
+
.menu-list .row .who.you { color: #8fb3ff; }
|
|
334
|
+
.menu-list .row .who.agent { color: var(--up); }
|
|
335
|
+
.menu-list .row .who.disk, .menu-list .row .who.opened { color: var(--muted); }
|
|
336
|
+
.menu-list .row .about { flex: 1; text-align: left; white-space: normal; }
|
|
341
337
|
.where { color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
342
338
|
.kind { color: var(--accent); }
|
|
343
339
|
.note { display: block; color: var(--muted); font-size: 12px; margin-top: 2px; }
|
|
340
|
+
aside li.error button.copy { float: right; display: inline-block; width: auto; margin: 0 0 0 8px; padding: 0 6px; font-size: 11px;
|
|
341
|
+
border-color: color-mix(in srgb, var(--rule) 70%, transparent); }
|
|
342
|
+
aside li.error .goto { cursor: pointer; text-decoration: underline dotted; }
|
|
343
|
+
|
|
344
|
+
/* ---- a page with no file: the playground ---- */
|
|
345
|
+
.links { display: inline-flex; gap: 12px; margin-left: 10px; font-size: 12px; }
|
|
346
|
+
.heading .drawn { display: inline; }
|
|
347
|
+
.links a { color: var(--muted); text-decoration: none; border-bottom: 1px solid color-mix(in srgb, var(--rule) 60%, transparent); }
|
|
348
|
+
.links a:hover { color: var(--ink); }
|
|
349
|
+
/* The divider between the source and the picture: dragged, or moved with the arrows. */
|
|
350
|
+
#split { flex: none; width: 6px; margin: 0 -3px; z-index: 2; cursor: col-resize; touch-action: none; }
|
|
351
|
+
#split[hidden] { display: none; }
|
|
352
|
+
#split:hover, #split.dragging, #split:focus-visible { background: color-mix(in srgb, var(--accent) 45%, transparent); outline: 0; }
|
|
353
|
+
body.dragging { cursor: col-resize; user-select: none; }
|
|
354
|
+
/* The cheatsheet. Closed it is one line; open it takes height off the
|
|
355
|
+
source rather than off the diagram, since it answers what to type. */
|
|
356
|
+
#cheatsheet { flex: none; max-height: 46%; overflow: auto; border-top: 1px solid color-mix(in srgb, var(--rule) 45%, transparent); }
|
|
357
|
+
#cheatsheet summary { padding: 7px 12px; cursor: pointer; color: var(--muted); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; user-select: none; }
|
|
358
|
+
#cheatsheet summary:hover { color: var(--ink); }
|
|
359
|
+
#cheatsheet dl { margin: 0; padding: 0 12px 14px; display: grid; grid-template-columns: max-content 1fr; gap: 4px 12px; font-size: 12px; }
|
|
360
|
+
#cheatsheet dt { color: var(--muted); white-space: nowrap; }
|
|
361
|
+
#cheatsheet dd { margin: 0; line-height: 1.7; }
|
|
362
|
+
#cheatsheet code { font: 11.5px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; display: inline-block;
|
|
363
|
+
padding: 1px 5px; margin-right: 3px; border-radius: 3px; background: color-mix(in srgb, var(--ink) 9%, transparent); }
|
|
364
|
+
#cheatsheet b { color: var(--syn-relation); font-weight: 400; }
|
|
365
|
+
#cheatsheet i { color: var(--muted); font-style: normal; }
|
|
366
|
+
dialog { padding: 0; color: var(--ink); background: var(--page); overflow: hidden;
|
|
367
|
+
border: 1px solid color-mix(in srgb, var(--rule) 70%, transparent); border-radius: 6px; }
|
|
368
|
+
dialog::backdrop { background: rgba(0, 0, 0, .6); }
|
|
369
|
+
dialog .bar { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 16px;
|
|
370
|
+
border-bottom: 1px solid color-mix(in srgb, var(--rule) 45%, transparent); }
|
|
371
|
+
dialog h2 { margin: 0; font-size: 13px; font-weight: 600; }
|
|
372
|
+
dialog footer { display: flex; align-items: center; gap: 8px; padding: 8px 16px; font-size: 12px; color: var(--muted);
|
|
373
|
+
border-top: 1px solid color-mix(in srgb, var(--rule) 45%, transparent); }
|
|
374
|
+
dialog footer .gap { flex: 1; }
|
|
375
|
+
#examples { width: min(1100px, calc(100vw - 32px)); height: min(720px, calc(100vh - 32px)); max-width: none; max-height: none; }
|
|
376
|
+
#examples[open] { display: grid; grid-template-columns: 220px 1fr; grid-template-rows: auto auto minmax(0, 1fr) auto; }
|
|
377
|
+
#examples .bar, #examples footer { grid-column: 1 / -1; }
|
|
378
|
+
#catalog { grid-row: 2 / 4; margin: 0; padding: 6px 0; list-style: none; overflow-y: auto; outline: 0;
|
|
379
|
+
border-right: 1px solid color-mix(in srgb, var(--rule) 45%, transparent); }
|
|
380
|
+
#catalog li { padding: 6px 16px; font-size: 13px; cursor: pointer; border-left: 2px solid transparent; }
|
|
381
|
+
#catalog li:hover { background: color-mix(in srgb, var(--ink) 8%, transparent); }
|
|
382
|
+
#catalog li[aria-selected="true"] { background: color-mix(in srgb, var(--ink) 13%, transparent); border-left-color: var(--accent); }
|
|
383
|
+
#catalog:focus-visible li[aria-selected="true"] { outline: 1px solid var(--muted); outline-offset: -1px; }
|
|
384
|
+
#about { margin: 0; padding: 10px 16px; font-size: 13px; border-bottom: 1px solid color-mix(in srgb, var(--rule) 45%, transparent); }
|
|
385
|
+
#preview { min-width: 0; min-height: 0; overflow: auto; padding: 16px; display: flex; }
|
|
386
|
+
#preview svg { max-width: 100%; max-height: 100%; height: auto; margin: auto; }
|
|
387
|
+
#format-settings, #save-as { width: min(640px, calc(100vw - 32px)); max-height: calc(100vh - 32px); }
|
|
388
|
+
#format-settings[open], #save-as[open] { display: flex; flex-direction: column; }
|
|
389
|
+
dialog .about { margin: 0; padding: 10px 16px; font-size: 12px; color: var(--muted); }
|
|
390
|
+
#format-fields { overflow-y: auto; padding: 4px 16px; }
|
|
391
|
+
#format-fields label { display: grid; grid-template-columns: 190px 1fr; gap: 2px 12px; align-items: center; padding: 8px 0; font-size: 13px;
|
|
392
|
+
border-bottom: 1px solid color-mix(in srgb, var(--rule) 30%, transparent); }
|
|
393
|
+
#format-fields label span { grid-column: 2; color: var(--muted); font-size: 11px; }
|
|
394
|
+
#format-fields select, #format-fields input, #save-as input { font: 12px ui-monospace, Menlo, Consolas, monospace; color: var(--ink); background: var(--page);
|
|
395
|
+
border: 1px solid color-mix(in srgb, var(--rule) 70%, transparent); border-radius: 4px; padding: 4px 6px; min-width: 0; }
|
|
396
|
+
#save-as label { display: flex; align-items: center; gap: 8px; padding: 0 16px 12px; font-size: 13px; }
|
|
397
|
+
#save-as input { flex: 1; font-size: 13px; }
|
|
398
|
+
#format-problem, #save-problem { margin: 0; padding: 0 16px; color: var(--down); font-size: 12px; }
|
|
399
|
+
#format-problem:not(:empty), #save-problem:not(:empty) { padding: 8px 16px; }
|
|
400
|
+
@media (max-width: 760px) {
|
|
401
|
+
#examples[open] { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 2fr) auto minmax(0, 3fr) auto; }
|
|
402
|
+
#catalog { grid-row: auto; border-right: 0; border-bottom: 1px solid color-mix(in srgb, var(--rule) 45%, transparent); }
|
|
403
|
+
#format-fields label { grid-template-columns: 1fr; }
|
|
404
|
+
#format-fields label span { grid-column: 1; }
|
|
405
|
+
dialog footer .keys { display: none; }
|
|
406
|
+
}
|
|
344
407
|
@media (max-width: 700px) {
|
|
408
|
+
#split { display: none; }
|
|
409
|
+
#editor { width: auto !important; max-width: none; min-width: 0; height: 45%; border-right: 0;
|
|
410
|
+
border-bottom: 1px solid color-mix(in srgb, var(--rule) 45%, transparent); }
|
|
345
411
|
main { flex-direction: column; }
|
|
346
412
|
#side { width: auto; max-height: 45%; border-left: 0; border-top: 1px solid color-mix(in srgb, var(--rule) 45%, transparent); }
|
|
347
413
|
}
|
|
@@ -351,35 +417,69 @@ const PAGE = `<!doctype html>
|
|
|
351
417
|
<header>
|
|
352
418
|
<div class="heading">
|
|
353
419
|
<h1>{{heading}}</h1>
|
|
354
|
-
<p class="drawn">{{drawn}}</p>
|
|
420
|
+
<p class="drawn">{{drawn}}</p>{{links}}
|
|
355
421
|
</div>
|
|
356
422
|
<div class="tools">
|
|
423
|
+
<button id="browse" type="button" title="Open one of the examples in the editor" aria-haspopup="dialog" hidden>Examples</button>
|
|
424
|
+
<button id="edit"type="button" title="Show or hide the source (e)" aria-pressed="false" hidden>Edit<kbd class="tag">e</kbd></button>
|
|
425
|
+
<span class="gap" id="edit-gap" hidden></span>
|
|
357
426
|
<button id="out" type="button" title="Zoom out (-)" aria-label="Zoom out">−</button>
|
|
358
427
|
<span id="scale" aria-live="polite">100%</span>
|
|
359
428
|
<button id="in" type="button" title="Zoom in (+)" aria-label="Zoom in">+</button>
|
|
360
429
|
<button id="fit" type="button" title="The whole diagram in view (0)">Fit</button>
|
|
361
430
|
<button id="actual" type="button" title="The drawing at its own scale (1)">100%</button>
|
|
362
431
|
<span class="gap"></span>
|
|
363
|
-
<button id="full" type="button" title="Full screen (f)">Full screen</button>
|
|
432
|
+
<button id="full" type="button" title="Full screen (f)"><span class="label">Full screen</span><kbd class="tag">f</kbd></button>
|
|
433
|
+
<span class="menu" id="versions-menu" hidden>
|
|
434
|
+
<button id="versions-button" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="versions-list">Versions ▾<kbd class="tag">o</kbd></button>
|
|
435
|
+
<span id="versions-list" class="menu-list wide" role="menu" hidden></span>
|
|
436
|
+
</span>
|
|
364
437
|
<span class="menu">
|
|
365
|
-
<button id="save" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="save-list">
|
|
366
|
-
<span id="save-list" class="menu-list" hidden>
|
|
367
|
-
<button id="save-page" type="button" title="Save this page as one HTML file, to keep or send">This page</button>
|
|
368
|
-
<button id="save-source" type="button" title="Save the .reladraw file this page was drawn from">.reladraw source</button>
|
|
438
|
+
<button id="save" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="save-list">Export ▾<kbd class="tag">x</kbd></button>
|
|
439
|
+
<span id="save-list" class="menu-list" role="menu" hidden>
|
|
440
|
+
<button id="save-page" class="row" type="button" role="menuitem" data-key="p" title="Save this page as one HTML file, to keep or send"><span class="name">This page</span><kbd>p</kbd></button>
|
|
441
|
+
<button id="save-source" class="row" type="button" role="menuitem" data-key="r" title="Save the .reladraw file this page was drawn from"><span class="name">.reladraw source</span><kbd>r</kbd></button>
|
|
369
442
|
<hr>
|
|
370
|
-
<button id="save-svg" type="button" title="Save the picture as an SVG file">SVG</button>
|
|
371
|
-
<button id="save-png" type="button" title="Save the picture as a PNG, at twice its size">PNG</button>
|
|
372
|
-
<button id="copy-svg" type="button" title="Copy the picture's SVG to the clipboard">Copy SVG</button>
|
|
443
|
+
<button id="save-svg" class="row" type="button" role="menuitem" data-key="s" title="Save the picture as an SVG file"><span class="name">SVG</span><kbd>s</kbd></button>
|
|
444
|
+
<button id="save-png" class="row" type="button" role="menuitem" data-key="n" title="Save the picture as a PNG, at twice its size"><span class="name">PNG</span><kbd>n</kbd></button>
|
|
445
|
+
<button id="copy-svg" class="row" type="button" role="menuitem" data-key="c" title="Copy the picture's SVG to the clipboard"><span class="name">Copy SVG</span><kbd>c</kbd></button>
|
|
446
|
+
</span>
|
|
447
|
+
</span>
|
|
448
|
+
<span class="menu">
|
|
449
|
+
<button id="view-button" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="view-list">View ▾<kbd class="tag">v</kbd></button>
|
|
450
|
+
<span id="view-list" class="menu-list" role="menu" hidden>
|
|
451
|
+
<button id="theme-row" class="row" type="button" role="menuitem" data-key="t" aria-haspopup="true" title="Draw the diagram in another theme"><span class="name">Theme <span class="value"></span> ▸</span><kbd>t</kbd></button>
|
|
452
|
+
<hr>
|
|
453
|
+
<button id="panel-row" class="row switch" type="button" role="menuitemcheckbox" data-key="p" title="Show or hide the side panel"><span class="sw"></span><span class="name">Side panel</span><span class="word"></span><kbd>p</kbd></button>
|
|
454
|
+
<button id="auto-row" class="row switch" type="button" role="menuitemcheckbox" data-key="a" title="Show the side panel only while something is selected or the findings are open"><span class="sw"></span><span class="name">Auto-hide panel</span><span class="word"></span><kbd>a</kbd></button>
|
|
455
|
+
<hr>
|
|
456
|
+
<button id="findings-row" class="row switch" type="button" role="menuitemcheckbox" data-key="f" title="Show or hide the findings in the side panel"><span class="sw"></span><span class="name">Findings</span><span class="word"></span><kbd>f</kbd></button>
|
|
457
|
+
<hr>
|
|
458
|
+
<button id="guides-row" class="row switch" type="button" role="menuitemcheckbox" data-key="g" title="Draw the guides: everything written with guide in front"><span class="sw"></span><span class="name">Guides</span><span class="word"></span><kbd>g</kbd></button>
|
|
459
|
+
<span id="theme-list" class="menu-list sub" role="menu" hidden></span>
|
|
373
460
|
</span>
|
|
374
461
|
</span>
|
|
375
|
-
<select id="theme" aria-label="Theme" title="Draw the diagram in another theme">{{themes}}</select>
|
|
376
|
-
<span class="gap"></span>
|
|
377
|
-
<button id="toggle-panel" type="button" aria-controls="side" title="Show or hide the side panel (p)">Panel</button>
|
|
378
|
-
<label class="check" title="Show the side panel only while something is selected or the findings are open"><input id="auto-hide" type="checkbox"> Auto-hide</label>
|
|
379
|
-
<button id="toggle-findings" type="button" aria-controls="findings"></button>
|
|
380
462
|
</div>
|
|
381
463
|
</header>
|
|
464
|
+
<div id="strip" hidden><span class="crumbs"></span><span class="hint"></span></div>
|
|
465
|
+
<div id="live" role="status" hidden></div>
|
|
382
466
|
<main>
|
|
467
|
+
<section id="editor" aria-label="Source" hidden>
|
|
468
|
+
<div id="changed" role="alert" hidden><span class="what"></span><span class="gap"></span><button id="load-disk" type="button">Load it</button><button id="keep-mine" type="button">Keep mine</button></div>
|
|
469
|
+
<div class="src-head">
|
|
470
|
+
<span class="file"></span><span id="unsaved" title="Not saved to the file" hidden>●</span><span class="gap"></span>
|
|
471
|
+
<button id="open-file" type="button" title="Open a .reladraw file into the source" hidden>Open…</button>
|
|
472
|
+
<input id="file-input" type="file" accept=".reladraw,text/plain" hidden>
|
|
473
|
+
<button id="format" type="button" title="Lay the source out again, as reladraw format does, with the nearest .reladraw-format (Shift+Alt+F)"><span class="name">Format</span></button>
|
|
474
|
+
<button id="format-open" type="button" aria-haspopup="dialog" title="How Format lays the source out" hidden>Format settings…</button>
|
|
475
|
+
<button id="write" type="button" title="Write the source to the file (⌘S or Ctrl+S)"><span class="name">Save</span><kbd>⌘S</kbd></button>
|
|
476
|
+
</div>
|
|
477
|
+
<div class="code">
|
|
478
|
+
<pre id="paint" aria-hidden="true"></pre>
|
|
479
|
+
<textarea id="source" spellcheck="false" autocapitalize="off" autocomplete="off" aria-label="Source"></textarea>
|
|
480
|
+
</div>{{cheatsheet}}
|
|
481
|
+
</section>
|
|
482
|
+
<div id="split" role="separator" aria-orientation="vertical" aria-label="Resize the source and the diagram" tabindex="0" title="Drag to resize; double-click to reset" hidden></div>
|
|
383
483
|
<div id="view">
|
|
384
484
|
<div id="stage">
|
|
385
485
|
{{drawing}}
|
|
@@ -395,13 +495,18 @@ const PAGE = `<!doctype html>
|
|
|
395
495
|
<ol id="finding-list"></ol>
|
|
396
496
|
</aside>
|
|
397
497
|
</div>
|
|
398
|
-
</main>
|
|
498
|
+
</main>{{dialogs}}
|
|
499
|
+
{{library}}
|
|
399
500
|
<script>
|
|
400
501
|
(function () {
|
|
502
|
+
var DATA = {{data}};
|
|
503
|
+
// How the source is edited, or null on a snapshot, which has no editor.
|
|
504
|
+
var EDIT = DATA.edit;
|
|
401
505
|
// The page as written, taken before anything below changes it, so This
|
|
402
506
|
// page saves what reladraw wrote rather than the current zoom and highlights.
|
|
403
|
-
|
|
404
|
-
var
|
|
507
|
+
// A page with an editor makes a fresh snapshot instead (below).
|
|
508
|
+
var written = EDIT ? '' : '<!doctype html>\\n' + document.documentElement.outerHTML + '\\n';
|
|
509
|
+
document.documentElement.setAttribute('data-scheme', DATA.themes[DATA.current].scheme === 'light' ? 'light' : 'dark');
|
|
405
510
|
var view = document.getElementById('view');
|
|
406
511
|
var stage = document.getElementById('stage');
|
|
407
512
|
var svg = stage.querySelector('svg');
|
|
@@ -411,7 +516,10 @@ const PAGE = `<!doctype html>
|
|
|
411
516
|
// Another theme is carried as the colors that differ from the first, put
|
|
412
517
|
// back in place the first time it is shown.
|
|
413
518
|
var VARIES = new RegExp(DATA.varies, 'gi');
|
|
414
|
-
|
|
519
|
+
// Whether guides are drawn, which the View menu's switch changes. The
|
|
520
|
+
// drawings with guides the other way are a second set, packed the same way
|
|
521
|
+
// against its own first theme.
|
|
522
|
+
var guidesShown = DATA.guidesShown;
|
|
415
523
|
function unpack(packed, base) {
|
|
416
524
|
if (typeof packed === 'string') return packed;
|
|
417
525
|
var pieces = base.split(VARIES);
|
|
@@ -419,8 +527,10 @@ const PAGE = `<!doctype html>
|
|
|
419
527
|
return pieces.join('');
|
|
420
528
|
}
|
|
421
529
|
function themed(name) {
|
|
422
|
-
var
|
|
530
|
+
var set = guidesShown === DATA.guidesShown || !DATA.flipped ? DATA.themes : DATA.flipped;
|
|
531
|
+
var view = set[name];
|
|
423
532
|
if (!view.unpacked) {
|
|
533
|
+
var first = set[DATA.current];
|
|
424
534
|
view.svg = unpack(view.svg, first.svg);
|
|
425
535
|
view.tagged = unpack(view.tagged, first.tagged);
|
|
426
536
|
view.unpacked = true;
|
|
@@ -448,7 +558,7 @@ const PAGE = `<!doctype html>
|
|
|
448
558
|
}
|
|
449
559
|
full.onclick = toggleFull;
|
|
450
560
|
document.addEventListener('fullscreenchange', function () {
|
|
451
|
-
full.textContent = document.fullscreenElement ? 'Exit full screen' : 'Full screen';
|
|
561
|
+
full.querySelector('.label').textContent = document.fullscreenElement ? 'Exit full screen' : 'Full screen';
|
|
452
562
|
});
|
|
453
563
|
|
|
454
564
|
/* ---- saving ---- */
|
|
@@ -466,9 +576,10 @@ const PAGE = `<!doctype html>
|
|
|
466
576
|
}
|
|
467
577
|
|
|
468
578
|
function flash(button, text) {
|
|
469
|
-
var
|
|
470
|
-
|
|
471
|
-
|
|
579
|
+
var label = button.querySelector('.name') || button;
|
|
580
|
+
var was = label.textContent;
|
|
581
|
+
label.textContent = text;
|
|
582
|
+
setTimeout(function () { label.textContent = was; }, 1200);
|
|
472
583
|
}
|
|
473
584
|
|
|
474
585
|
// Chrome and Edge ask where to save and under what name, as the
|
|
@@ -505,31 +616,30 @@ const PAGE = `<!doctype html>
|
|
|
505
616
|
return Promise.resolve(blob);
|
|
506
617
|
}
|
|
507
618
|
|
|
508
|
-
// Every way out of the page is under one
|
|
619
|
+
// Every way out of the page is under one Export button: the page, its
|
|
509
620
|
// source, and the picture. The list stays open after a choice, so "Copied"
|
|
510
|
-
// can be read,
|
|
621
|
+
// can be read. It is one of the menus (below), opened by a click or by m x.
|
|
622
|
+
// Save is the served page's writing of the file, so this is Export everywhere.
|
|
511
623
|
var saveButton = document.getElementById('save');
|
|
512
624
|
var saveList = document.getElementById('save-list');
|
|
513
|
-
function showSave(open) {
|
|
514
|
-
saveList.hidden = !open;
|
|
515
|
-
saveButton.setAttribute('aria-expanded', String(open));
|
|
516
|
-
}
|
|
517
|
-
saveButton.onclick = function () { showSave(saveList.hidden); };
|
|
518
|
-
document.addEventListener('click', function (event) {
|
|
519
|
-
if (!saveList.hidden && !event.target.closest('.menu')) showSave(false);
|
|
520
|
-
});
|
|
521
625
|
|
|
522
626
|
var savePage = document.getElementById('save-page');
|
|
523
627
|
savePage.onclick = function () {
|
|
524
628
|
saveFile(savePage, DATA.name + '.html', 'Web page', 'text/html', function () {
|
|
525
|
-
return ready(new Blob([written], { type: 'text/html' }));
|
|
629
|
+
if (!EDIT) return ready(new Blob([written], { type: 'text/html' }));
|
|
630
|
+
// A page with an editor saves a snapshot of the diagram as last drawn,
|
|
631
|
+
// as render --page writes it: this page itself would be the editor,
|
|
632
|
+
// still looking for its server or holding the whole library.
|
|
633
|
+
return libReady.then(function (lib) {
|
|
634
|
+
return new Blob([lib.drawPage(DATA.source, DATA.name + '.reladraw').html], { type: 'text/html' });
|
|
635
|
+
});
|
|
526
636
|
});
|
|
527
637
|
};
|
|
528
638
|
|
|
529
639
|
var saveSource = document.getElementById('save-source');
|
|
530
640
|
saveSource.onclick = function () {
|
|
531
641
|
saveFile(saveSource, DATA.name + '.reladraw', 'reladraw source', 'text/plain', function () {
|
|
532
|
-
return ready(new Blob([DATA.source], { type: 'text/plain' }));
|
|
642
|
+
return ready(new Blob([EDIT ? sourceBox.value : DATA.source], { type: 'text/plain' }));
|
|
533
643
|
});
|
|
534
644
|
};
|
|
535
645
|
|
|
@@ -620,17 +730,26 @@ const PAGE = `<!doctype html>
|
|
|
620
730
|
|
|
621
731
|
var panel = document.getElementById('findings');
|
|
622
732
|
var list = document.getElementById('finding-list');
|
|
623
|
-
var toggle = document.getElementById('toggle-findings');
|
|
624
733
|
var count = DATA.findings.length;
|
|
625
734
|
|
|
735
|
+
// A row of the View menu that is on or off: its switch, and the word beside it.
|
|
736
|
+
function setSwitch(row, on) {
|
|
737
|
+
row.setAttribute('aria-checked', String(on));
|
|
738
|
+
row.querySelector('.word').textContent = on ? 'on' : 'off';
|
|
739
|
+
}
|
|
740
|
+
var panelRow = document.getElementById('panel-row');
|
|
741
|
+
var autoRow = document.getElementById('auto-row');
|
|
742
|
+
var findingsRow = document.getElementById('findings-row');
|
|
743
|
+
var guidesRow = document.getElementById('guides-row');
|
|
744
|
+
findingsRow.querySelector('.name').textContent = 'Findings (' + count + ')';
|
|
745
|
+
guidesRow.querySelector('.name').textContent = 'Guides (' + DATA.guideCount + ')';
|
|
746
|
+
|
|
626
747
|
/* ---- the side panel: shown, hidden, or shown only when wanted ---- */
|
|
627
748
|
|
|
628
749
|
// Hidden, it stays hidden whatever is selected. Auto-hide shows it only
|
|
629
750
|
// while something is selected or the findings are open. Both choices are
|
|
630
751
|
// remembered in this browser for every reladraw page.
|
|
631
752
|
var side = document.getElementById('side');
|
|
632
|
-
var panelButton = document.getElementById('toggle-panel');
|
|
633
|
-
var autoHideBox = document.getElementById('auto-hide');
|
|
634
753
|
var KEPT = 'reladraw-page-panel';
|
|
635
754
|
var panelOn = true;
|
|
636
755
|
var autoHide = false;
|
|
@@ -645,42 +764,40 @@ const PAGE = `<!doctype html>
|
|
|
645
764
|
side.hidden = !(panelOn && (!autoHide || selection || !panel.hidden));
|
|
646
765
|
// A finding lights what it is about only while the finding can be seen.
|
|
647
766
|
if ((panel.hidden || side.hidden) && lit && lit.length) unlight();
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
767
|
+
setSwitch(panelRow, panelOn);
|
|
768
|
+
setSwitch(autoRow, autoHide);
|
|
769
|
+
autoRow.disabled = !panelOn;
|
|
770
|
+
setSwitch(findingsRow, panelOn && !panel.hidden);
|
|
652
771
|
}
|
|
653
772
|
function setPanel(on) {
|
|
654
773
|
panelOn = on;
|
|
655
774
|
keepPanel();
|
|
656
775
|
layoutSide();
|
|
657
776
|
}
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
autoHide =
|
|
777
|
+
function setAutoHide(on) {
|
|
778
|
+
if (!panelOn) return;
|
|
779
|
+
autoHide = on;
|
|
661
780
|
keepPanel();
|
|
662
781
|
layoutSide();
|
|
663
|
-
}
|
|
782
|
+
}
|
|
783
|
+
panelRow.onclick = function () { setPanel(!panelOn); };
|
|
784
|
+
autoRow.onclick = function () { setAutoHide(!autoHide); };
|
|
664
785
|
|
|
665
786
|
function showPanel(open) {
|
|
666
787
|
panel.hidden = !open;
|
|
667
|
-
toggle.setAttribute('aria-pressed', String(open));
|
|
668
788
|
layoutSide();
|
|
669
789
|
}
|
|
790
|
+
// Asking for the findings shows the panel they are in.
|
|
791
|
+
function flipFindings() {
|
|
792
|
+
if (!panelOn) { panelOn = true; keepPanel(); showPanel(true); }
|
|
793
|
+
else showPanel(panel.hidden);
|
|
794
|
+
}
|
|
795
|
+
findingsRow.onclick = flipFindings;
|
|
670
796
|
if (count === 0) {
|
|
671
|
-
toggle.textContent = 'No findings';
|
|
672
|
-
toggle.disabled = true;
|
|
673
797
|
panel.hidden = true;
|
|
674
798
|
} else {
|
|
675
|
-
toggle.textContent = count === 1 ? '1 finding' : count + ' findings';
|
|
676
|
-
// Asking for the findings shows the panel they are in.
|
|
677
|
-
toggle.onclick = function () {
|
|
678
|
-
if (!panelOn) { panelOn = true; keepPanel(); showPanel(true); }
|
|
679
|
-
else showPanel(panel.hidden);
|
|
680
|
-
};
|
|
681
799
|
// Open from the start, unless the panel is to stay out of the way.
|
|
682
800
|
panel.hidden = autoHide;
|
|
683
|
-
toggle.setAttribute('aria-pressed', String(!autoHide));
|
|
684
801
|
}
|
|
685
802
|
|
|
686
803
|
var lit = [];
|
|
@@ -717,7 +834,41 @@ const PAGE = `<!doctype html>
|
|
|
717
834
|
zoom.pan((room.left + room.width / 2) - (r.l + r.r) / 2, (room.top + room.height / 2) - (r.t + r.b) / 2);
|
|
718
835
|
}
|
|
719
836
|
|
|
720
|
-
|
|
837
|
+
// The list, made again when a served page redraws. An error the file has
|
|
838
|
+
// stopped parsing with heads it, over the findings of the last drawing that
|
|
839
|
+
// parsed, which is still the one shown.
|
|
840
|
+
function listFindings(error) {
|
|
841
|
+
list.textContent = '';
|
|
842
|
+
count = DATA.findings.length + (error ? 1 : 0);
|
|
843
|
+
findingsRow.querySelector('.name').textContent = 'Findings (' + count + ')';
|
|
844
|
+
guidesRow.querySelector('.name').textContent = 'Guides (' + DATA.guideCount + ')';
|
|
845
|
+
if (error) {
|
|
846
|
+
var wrong = element('li', 'error');
|
|
847
|
+
var head = element('span', 'where', error.line ? 'line ' + error.line + ' ' : '');
|
|
848
|
+
if (EDIT) {
|
|
849
|
+
if (error.line) {
|
|
850
|
+
head.classList.add('goto');
|
|
851
|
+
head.title = 'Select the line in the source';
|
|
852
|
+
head.onclick = function () { goToLine(error.line); };
|
|
853
|
+
}
|
|
854
|
+
// For pasting into an issue, or a chat with an agent.
|
|
855
|
+
var copy = element('button', 'copy', 'Copy');
|
|
856
|
+
copy.type = 'button';
|
|
857
|
+
copy.title = 'Copy the error';
|
|
858
|
+
copy.onclick = function () {
|
|
859
|
+
toClipboard((error.line ? 'line ' + error.line + ': ' : '') + error.message)
|
|
860
|
+
.then(function () { flash(copy, 'Copied'); }, function () { flash(copy, 'Blocked'); });
|
|
861
|
+
};
|
|
862
|
+
wrong.append(copy);
|
|
863
|
+
}
|
|
864
|
+
wrong.append(head, element('span', 'kind', 'error'), document.createElement('br'),
|
|
865
|
+
document.createTextNode(error.message), element('span', 'note', 'The picture is the last version that drew.'));
|
|
866
|
+
list.append(wrong);
|
|
867
|
+
}
|
|
868
|
+
if (count === 0) list.append(element('li', 'none', 'No findings.'));
|
|
869
|
+
DATA.findings.forEach(addFinding);
|
|
870
|
+
}
|
|
871
|
+
function addFinding(finding) {
|
|
721
872
|
var item = document.createElement('li');
|
|
722
873
|
var button = document.createElement('button');
|
|
723
874
|
button.type = 'button';
|
|
@@ -734,10 +885,15 @@ const PAGE = `<!doctype html>
|
|
|
734
885
|
note.textContent = 'Nothing drawn comes from that line.';
|
|
735
886
|
button.append(note);
|
|
736
887
|
}
|
|
737
|
-
|
|
888
|
+
// With the source shown, the line it names is selected there too.
|
|
889
|
+
button.onclick = function () {
|
|
890
|
+
light(finding, button);
|
|
891
|
+
if (EDIT && !editorPane.hidden) goToLine(finding.line);
|
|
892
|
+
};
|
|
738
893
|
item.append(button);
|
|
739
894
|
list.append(item);
|
|
740
|
-
}
|
|
895
|
+
}
|
|
896
|
+
listFindings(null);
|
|
741
897
|
|
|
742
898
|
/* ---- selecting a box or a line ---- */
|
|
743
899
|
|
|
@@ -938,24 +1094,17 @@ const PAGE = `<!doctype html>
|
|
|
938
1094
|
if (link && svg.contains(link)) event.preventDefault();
|
|
939
1095
|
});
|
|
940
1096
|
|
|
941
|
-
/* ----
|
|
1097
|
+
/* ---- drawing again: another theme, or guides the other way ---- */
|
|
942
1098
|
|
|
943
|
-
// "Diagram's theme" follows the file, so an edit to its theme shows on the
|
|
944
|
-
// next render. Any other choice is the reader's, and holds on every reladraw
|
|
945
|
-
// page in this browser, whatever its file says, until the diagram's theme
|
|
946
|
-
// is chosen again.
|
|
947
|
-
var picker = document.getElementById('theme');
|
|
948
1099
|
var schemeMeta = document.querySelector('meta[name="color-scheme"]');
|
|
949
|
-
var CHOSEN_THEME = 'reladraw-page-theme';
|
|
950
1100
|
|
|
951
|
-
//
|
|
952
|
-
//
|
|
953
|
-
function
|
|
954
|
-
if (!DATA.themes[name] || name === shownTheme) return;
|
|
955
|
-
shownTheme = name;
|
|
1101
|
+
// Neither a theme nor guides move anything, so the view, the selection and
|
|
1102
|
+
// the panel stay as they were.
|
|
1103
|
+
function redraw() {
|
|
956
1104
|
var next = shown();
|
|
957
1105
|
Object.keys(next.colors).forEach(function (key) { document.documentElement.style.setProperty(key, next.colors[key]); });
|
|
958
1106
|
schemeMeta.setAttribute('content', next.scheme);
|
|
1107
|
+
document.documentElement.setAttribute('data-scheme', next.scheme === 'light' ? 'light' : 'dark');
|
|
959
1108
|
unlight();
|
|
960
1109
|
hovered = null;
|
|
961
1110
|
tip.style.display = 'none';
|
|
@@ -966,35 +1115,1456 @@ const PAGE = `<!doctype html>
|
|
|
966
1115
|
select(selection);
|
|
967
1116
|
}
|
|
968
1117
|
|
|
969
|
-
|
|
1118
|
+
function showTheme(name) {
|
|
1119
|
+
if (!DATA.themes[name] || name === shownTheme) return;
|
|
1120
|
+
shownTheme = name;
|
|
1121
|
+
redraw();
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// Guides start as the file or --guides says, and a flip lasts for this page
|
|
1125
|
+
// only: guides show how one diagram was built, not how a reader likes to
|
|
1126
|
+
// read. With none in the diagram the switch still works, and draws nothing new.
|
|
1127
|
+
function setGuides(on) {
|
|
1128
|
+
if (on === guidesShown) return;
|
|
1129
|
+
guidesShown = on;
|
|
1130
|
+
setSwitch(guidesRow, on);
|
|
1131
|
+
if (DATA.flipped) redraw();
|
|
1132
|
+
}
|
|
1133
|
+
setSwitch(guidesRow, guidesShown);
|
|
1134
|
+
guidesRow.onclick = function () { setGuides(!guidesShown); };
|
|
1135
|
+
|
|
1136
|
+
/* ---- the theme list ---- */
|
|
1137
|
+
|
|
1138
|
+
// "Diagram's theme" follows the file, so an edit to its theme shows on the
|
|
1139
|
+
// next render. Any other choice is the reader's, and holds on every reladraw
|
|
1140
|
+
// page in this browser, whatever its file says, until the diagram's theme
|
|
1141
|
+
// is chosen again.
|
|
1142
|
+
var CHOSEN_THEME = 'reladraw-page-theme';
|
|
1143
|
+
var chosenName = '';
|
|
1144
|
+
var themeRow = document.getElementById('theme-row');
|
|
1145
|
+
var themeList = document.getElementById('theme-list');
|
|
1146
|
+
var themeItems = [];
|
|
1147
|
+
|
|
1148
|
+
function addThemeItem(value, label) {
|
|
1149
|
+
var row = element('button', 'row');
|
|
1150
|
+
row.type = 'button';
|
|
1151
|
+
row.setAttribute('role', 'menuitemradio');
|
|
1152
|
+
row.append(element('span', 'mark'), element('span', 'name', label));
|
|
1153
|
+
row.onclick = function () { keepTheme(value); };
|
|
1154
|
+
themeItems.push({ value: value, row: row });
|
|
1155
|
+
themeList.append(row);
|
|
1156
|
+
}
|
|
1157
|
+
addThemeItem('', "Diagram's theme (" + DATA.current + ')');
|
|
1158
|
+
[['Dark', DATA.themeGroups.dark], ['Light', DATA.themeGroups.light]].forEach(function (group) {
|
|
1159
|
+
if (group[1].length === 0) return;
|
|
1160
|
+
themeList.append(element('span', 'group', group[0]));
|
|
1161
|
+
group[1].forEach(function (name) { addThemeItem(name, name); });
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
function showChosen() {
|
|
1165
|
+
themeRow.querySelector('.value').textContent = '· ' + (chosenName || "Diagram's theme");
|
|
1166
|
+
themeItems.forEach(function (item) {
|
|
1167
|
+
var on = item.value === chosenName;
|
|
1168
|
+
item.row.setAttribute('aria-checked', String(on));
|
|
1169
|
+
item.row.querySelector('.mark').textContent = on ? '\\u25CF' : '';
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function keepTheme(value) {
|
|
1174
|
+
chosenName = value;
|
|
970
1175
|
try {
|
|
971
|
-
if (
|
|
1176
|
+
if (value) localStorage.setItem(CHOSEN_THEME, value);
|
|
972
1177
|
else localStorage.removeItem(CHOSEN_THEME);
|
|
973
1178
|
} catch (e) {}
|
|
974
|
-
showTheme(
|
|
975
|
-
|
|
1179
|
+
showTheme(value || DATA.current);
|
|
1180
|
+
showChosen();
|
|
1181
|
+
if (level === 'theme') setLevel('view', true);
|
|
1182
|
+
}
|
|
976
1183
|
|
|
977
1184
|
// A theme chosen on an earlier page, if this page has it.
|
|
978
1185
|
try {
|
|
979
|
-
var
|
|
980
|
-
if (
|
|
981
|
-
|
|
982
|
-
showTheme(
|
|
1186
|
+
var kept = localStorage.getItem(CHOSEN_THEME);
|
|
1187
|
+
if (kept && DATA.themes[kept]) {
|
|
1188
|
+
chosenName = kept;
|
|
1189
|
+
showTheme(kept);
|
|
983
1190
|
}
|
|
984
1191
|
} catch (e) {}
|
|
1192
|
+
showChosen();
|
|
1193
|
+
|
|
1194
|
+
/* ---- the menus, by pointer and by key ---- */
|
|
1195
|
+
|
|
1196
|
+
// The keys work in levels: the diagram; m, the bar, each control wearing
|
|
1197
|
+
// its letter; then a menu, View or Save; then, from View, the theme list.
|
|
1198
|
+
// A letter means what the level it is pressed in says, so no letter is used
|
|
1199
|
+
// up however deep the menus go. Keys that aren't letters mean the same at
|
|
1200
|
+
// every level: Backspace steps back one level, and beneath the menus lets
|
|
1201
|
+
// go of a grab, then a lit finding, then the selection; Escape goes straight
|
|
1202
|
+
// back to the plain diagram from anywhere; Enter does what the row the
|
|
1203
|
+
// arrows are on does. Opening a menu with a click puts the keys there too.
|
|
1204
|
+
var tools = document.querySelector('.tools');
|
|
1205
|
+
var viewButton = document.getElementById('view-button');
|
|
1206
|
+
var viewList = document.getElementById('view-list');
|
|
1207
|
+
var versionsButton = document.getElementById('versions-button');
|
|
1208
|
+
var versionsList = document.getElementById('versions-list');
|
|
1209
|
+
var strip = document.getElementById('strip');
|
|
1210
|
+
var level = null;
|
|
1211
|
+
var cursor = 0;
|
|
1212
|
+
var themeBefore = null;
|
|
1213
|
+
|
|
1214
|
+
var STRIP = {
|
|
1215
|
+
bar: ['<b>Menu</b>', 'letter to open \\u00B7 \\u232B back \\u00B7 Esc out'],
|
|
1216
|
+
view: ['Menu \\u203A <b>View</b>', 'letter to flip \\u00B7 \\u2191\\u2193 Enter \\u00B7 \\u232B back \\u00B7 Esc out'],
|
|
1217
|
+
save: ['Menu \\u203A <b>Export</b>', 'letter to save \\u00B7 \\u2191\\u2193 Enter \\u00B7 \\u232B back \\u00B7 Esc out'],
|
|
1218
|
+
versions: ['Menu \\u203A <b>Versions</b>', '\\u2191\\u2193 Enter to put one in the editor \\u00B7 \\u232B back \\u00B7 Esc out'],
|
|
1219
|
+
theme: ['Menu \\u203A View \\u203A <b>Theme</b>', '\\u2191\\u2193 to try \\u00B7 Enter keep \\u00B7 \\u232B back \\u00B7 Esc out'],
|
|
1220
|
+
};
|
|
1221
|
+
|
|
1222
|
+
function rows() {
|
|
1223
|
+
if (level === 'view') return Array.prototype.slice.call(viewList.querySelectorAll(':scope > .row'));
|
|
1224
|
+
if (level === 'save') return Array.prototype.slice.call(saveList.querySelectorAll(':scope > .row'));
|
|
1225
|
+
if (level === 'versions') return Array.prototype.slice.call(versionsList.querySelectorAll(':scope > .row'));
|
|
1226
|
+
if (level === 'theme') return themeItems.map(function (item) { return item.row; });
|
|
1227
|
+
return [];
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function markCursor() {
|
|
1231
|
+
each(document.querySelectorAll('.menu-list .row.cur'), function (row) { row.classList.remove('cur'); });
|
|
1232
|
+
var all = rows();
|
|
1233
|
+
if (!all.length) return;
|
|
1234
|
+
cursor = Math.max(0, Math.min(cursor, all.length - 1));
|
|
1235
|
+
all[cursor].classList.add('cur');
|
|
1236
|
+
if (level === 'theme') all[cursor].scrollIntoView({ block: 'nearest' });
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// \`kept\`: leaving the theme list keeps the theme moved to, as Enter or a
|
|
1240
|
+
// click does; any other way out goes back to the theme shown before.
|
|
1241
|
+
function setLevel(next, kept) {
|
|
1242
|
+
if (level === 'theme' && next !== 'theme' && !kept && themeBefore !== null) showTheme(themeBefore);
|
|
1243
|
+
if (next === 'theme' && level !== 'theme') {
|
|
1244
|
+
themeBefore = shownTheme;
|
|
1245
|
+
cursor = 0;
|
|
1246
|
+
themeItems.forEach(function (item, i) { if (item.value === chosenName) cursor = i; });
|
|
1247
|
+
} else if (next !== level) {
|
|
1248
|
+
// Back from the theme list, the arrows are on Theme, the first row.
|
|
1249
|
+
cursor = 0;
|
|
1250
|
+
}
|
|
1251
|
+
level = next;
|
|
1252
|
+
tools.classList.toggle('keyed', level === 'bar');
|
|
1253
|
+
saveList.hidden = level !== 'save';
|
|
1254
|
+
saveButton.classList.toggle('open', level === 'save');
|
|
1255
|
+
saveButton.setAttribute('aria-expanded', String(level === 'save'));
|
|
1256
|
+
versionsList.hidden = level !== 'versions';
|
|
1257
|
+
versionsButton.classList.toggle('open', level === 'versions');
|
|
1258
|
+
versionsButton.setAttribute('aria-expanded', String(level === 'versions'));
|
|
1259
|
+
viewList.hidden = level !== 'view' && level !== 'theme';
|
|
1260
|
+
viewButton.classList.toggle('open', level === 'view' || level === 'theme');
|
|
1261
|
+
viewButton.setAttribute('aria-expanded', String(level === 'view' || level === 'theme'));
|
|
1262
|
+
themeList.hidden = level !== 'theme';
|
|
1263
|
+
viewList.classList.toggle('behind', level === 'theme');
|
|
1264
|
+
strip.hidden = level === null;
|
|
1265
|
+
if (level && STRIP[level]) {
|
|
1266
|
+
strip.querySelector('.crumbs').innerHTML = STRIP[level][0];
|
|
1267
|
+
strip.querySelector('.hint').textContent = STRIP[level][1];
|
|
1268
|
+
}
|
|
1269
|
+
if (level !== 'pick' && level !== 'filter') clearHints();
|
|
1270
|
+
markCursor();
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function back() {
|
|
1274
|
+
if (level === 'theme') setLevel('view');
|
|
1275
|
+
else if (level === 'view' || level === 'save' || level === 'versions') setLevel('bar');
|
|
1276
|
+
else setLevel(null);
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// Escape: the plain diagram, in one press, whatever was open or lit.
|
|
1280
|
+
function plain() {
|
|
1281
|
+
setLevel(null);
|
|
1282
|
+
zoom.grab(false);
|
|
1283
|
+
unlight();
|
|
1284
|
+
select(null);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/* ---- picking from the keyboard: letters on things ---- */
|
|
1288
|
+
|
|
1289
|
+
// s puts a letter on everything, / starts a filter that narrows what gets
|
|
1290
|
+
// one, and with something selected n letters what one edge away from it,
|
|
1291
|
+
// i what is inside it and the box it sits in, and l its links. Typing a
|
|
1292
|
+
// letter picks. These are two more levels: 'pick', where letters pick, and
|
|
1293
|
+
// 'filter', where everything typed goes into the filter until Enter. A
|
|
1294
|
+
// filter's plain words match a thing's text; is:node, is:edge,
|
|
1295
|
+
// is:container and style:name narrow by kind and style.
|
|
1296
|
+
var hint = null;
|
|
1297
|
+
var hintLayer = element('div', '');
|
|
1298
|
+
hintLayer.id = 'hints';
|
|
1299
|
+
view.append(hintLayer);
|
|
1300
|
+
var popup = null;
|
|
1301
|
+
var frame = 0;
|
|
1302
|
+
var noticeTimer = 0;
|
|
1303
|
+
var MODE_WORD = { all: 'Select', neighbors: 'Neighbors', inside: 'Inside', links: 'Links' };
|
|
1304
|
+
var KINDS = ['node', 'edge', 'container'];
|
|
1305
|
+
|
|
1306
|
+
function shownThing(name) {
|
|
1307
|
+
var node = graph.nodes[name];
|
|
1308
|
+
return node && !node.badge && (!node.guide || guidesShown);
|
|
1309
|
+
}
|
|
1310
|
+
function shownEdge(edge) { return !edge.guide || guidesShown; }
|
|
1311
|
+
function nameOf(target) {
|
|
1312
|
+
if (target.kind === 'node') return graph.nodes[target.key].label;
|
|
1313
|
+
var edge = graph.edges[target.key];
|
|
1314
|
+
return edge.label || graph.nodes[edge.up].label + ' \\u2192 ' + graph.nodes[edge.down].label;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// Where a thing's letter goes, in the drawing's own units: a box's top-left
|
|
1318
|
+
// corner, or the middle of its top edge when a child's letter already holds
|
|
1319
|
+
// the corner; bare text's, just left of it; a line's, somewhere along it
|
|
1320
|
+
// clear of its text.
|
|
1321
|
+
var anchors = { svg: null, at: {} };
|
|
1322
|
+
function anchorOf(target) {
|
|
1323
|
+
if (anchors.svg !== svg) anchors = { svg: svg, at: {} };
|
|
1324
|
+
var id = target.kind + ' ' + target.key;
|
|
1325
|
+
if (anchors.at[id]) return anchors.at[id];
|
|
1326
|
+
var at = null;
|
|
1327
|
+
if (target.kind === 'node') {
|
|
1328
|
+
var node = graph.nodes[target.key];
|
|
1329
|
+
var box = node.box;
|
|
1330
|
+
var crowded = node.children.some(function (child) {
|
|
1331
|
+
var other = graph.nodes[child].box;
|
|
1332
|
+
return Math.abs(other[0] - box[0]) < 8 && Math.abs(other[1] - box[1]) < 8;
|
|
1333
|
+
});
|
|
1334
|
+
if (node.bare) at = { x: box[0], y: box[1] + box[3] / 2, el: svg, side: 'left' };
|
|
1335
|
+
else at = { x: crowded ? box[0] + box[2] / 2 : box[0] + 2, y: box[1] + 2, el: svg };
|
|
1336
|
+
} else {
|
|
1337
|
+
var group = edgeGroups(target.key)[0];
|
|
1338
|
+
var stroke = group && group.querySelector('line:not(.target), path[fill="none"]:not(.target), polyline:not(.target)');
|
|
1339
|
+
if (stroke && stroke.getTotalLength) {
|
|
1340
|
+
var texts = Array.prototype.map.call(group.querySelectorAll('text'), function (text) { return text.getBBox(); });
|
|
1341
|
+
var length = stroke.getTotalLength();
|
|
1342
|
+
var clear = function (point) {
|
|
1343
|
+
return texts.every(function (box) {
|
|
1344
|
+
return point.x < box.x - 12 || point.x > box.x + box.width + 12 || point.y < box.y - 12 || point.y > box.y + box.height + 12;
|
|
1345
|
+
});
|
|
1346
|
+
};
|
|
1347
|
+
var point = null;
|
|
1348
|
+
[0.3, 0.7, 0.2, 0.8, 0.5, 0.12, 0.88].some(function (along) {
|
|
1349
|
+
var tried = stroke.getPointAtLength(length * along);
|
|
1350
|
+
if (!point) point = tried;
|
|
1351
|
+
if (clear(tried)) { point = tried; return true; }
|
|
1352
|
+
return false;
|
|
1353
|
+
});
|
|
1354
|
+
at = { x: point.x, y: point.y, el: stroke };
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
anchors.at[id] = at;
|
|
1358
|
+
return at;
|
|
1359
|
+
}
|
|
1360
|
+
// The same, in pixels from the view's top-left corner, or null.
|
|
1361
|
+
function onScreen(target) {
|
|
1362
|
+
var at = anchorOf(target);
|
|
1363
|
+
if (!at) return null;
|
|
1364
|
+
var matrix = at.el.getScreenCTM();
|
|
1365
|
+
if (!matrix) return null;
|
|
1366
|
+
var point = new DOMPoint(at.x, at.y).matrixTransform(matrix);
|
|
1367
|
+
var room = view.getBoundingClientRect();
|
|
1368
|
+
return { x: point.x - room.left, y: point.y - room.top, side: at.side, inView: point.x >= room.left && point.x <= room.right && point.y >= room.top && point.y <= room.bottom };
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// Things in view come first, so the one-letter labels go to what can be
|
|
1372
|
+
// seen; then top to bottom and left to right. Boxes before lines.
|
|
1373
|
+
function ordered(targets) {
|
|
1374
|
+
// Links keep the order the source writes them in, as the panel lists them.
|
|
1375
|
+
if (targets.length && targets[0].kind === 'link') return targets;
|
|
1376
|
+
var placed = targets.map(function (target, i) { return { target: target, at: onScreen(target) || { x: 0, y: 0, inView: false }, i: i }; });
|
|
1377
|
+
placed.sort(function (a, b) {
|
|
1378
|
+
if (a.target.kind !== b.target.kind) return a.target.kind === 'node' ? -1 : 1;
|
|
1379
|
+
if (a.at.inView !== b.at.inView) return a.at.inView ? -1 : 1;
|
|
1380
|
+
return Math.round(a.at.y / 24) - Math.round(b.at.y / 24) || a.at.x - b.at.x;
|
|
1381
|
+
});
|
|
1382
|
+
return placed.map(function (one) { return one.target; });
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function allTargets() {
|
|
1386
|
+
var out = [];
|
|
1387
|
+
Object.keys(graph.nodes).forEach(function (name) { if (shownThing(name)) out.push({ kind: 'node', key: name }); });
|
|
1388
|
+
graph.edges.forEach(function (edge, index) { if (shownEdge(edge)) out.push({ kind: 'edge', key: index }); });
|
|
1389
|
+
return out;
|
|
1390
|
+
}
|
|
1391
|
+
function neighborTargets() {
|
|
1392
|
+
var nodes = [], edges = [];
|
|
1393
|
+
function addNode(name) {
|
|
1394
|
+
if (shownThing(name) && !(selection.kind === 'node' && name === selection.key) && nodes.indexOf(name) < 0) nodes.push(name);
|
|
1395
|
+
}
|
|
1396
|
+
if (selection.kind === 'edge') {
|
|
1397
|
+
var edge = graph.edges[selection.key];
|
|
1398
|
+
addNode(edge.up);
|
|
1399
|
+
addNode(edge.down);
|
|
1400
|
+
} else {
|
|
1401
|
+
graph.edges.forEach(function (edge, index) {
|
|
1402
|
+
if (!shownEdge(edge) || (edge.up !== selection.key && edge.down !== selection.key)) return;
|
|
1403
|
+
edges.push(index);
|
|
1404
|
+
addNode(edge.up === selection.key ? edge.down : edge.up);
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
return nodes.map(function (name) { return { kind: 'node', key: name }; })
|
|
1408
|
+
.concat(edges.map(function (index) { return { kind: 'edge', key: index }; }));
|
|
1409
|
+
}
|
|
1410
|
+
function insideTargets() {
|
|
1411
|
+
var node = graph.nodes[selection.key];
|
|
1412
|
+
var out = node.children.filter(shownThing);
|
|
1413
|
+
if (node.parent && shownThing(node.parent)) out.push(node.parent);
|
|
1414
|
+
return out.map(function (name) { return { kind: 'node', key: name }; });
|
|
1415
|
+
}
|
|
1416
|
+
function linkTargets() {
|
|
1417
|
+
var thing = selection.kind === 'node' ? graph.nodes[selection.key] : graph.edges[selection.key];
|
|
1418
|
+
return thing.links.map(function (link, index) { return { kind: 'link', key: index, link: link }; });
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// As many one-letter labels as there are letters; past that, letters from
|
|
1422
|
+
// the end of the alphabet start two-letter ones, as few as will do.
|
|
1423
|
+
function codes(count) {
|
|
1424
|
+
var letters = 'abcdefghijklmnopqrstuvwxyz';
|
|
1425
|
+
if (count <= 26) return letters.slice(0, count).split('');
|
|
1426
|
+
for (var prefixes = 1; prefixes <= 26; prefixes++) {
|
|
1427
|
+
if (26 - prefixes + 26 * prefixes < count) continue;
|
|
1428
|
+
var out = letters.slice(0, 26 - prefixes).split('');
|
|
1429
|
+
for (var p = 0; out.length < count; p++) {
|
|
1430
|
+
for (var q = 0; q < 26 && out.length < count; q++) out.push(letters[25 - p] + letters[q]);
|
|
1431
|
+
}
|
|
1432
|
+
return out;
|
|
1433
|
+
}
|
|
1434
|
+
var all = [''];
|
|
1435
|
+
while (all.length < count) {
|
|
1436
|
+
var longer = [];
|
|
1437
|
+
all.forEach(function (start) { for (var i = 0; i < 26; i++) longer.push(start + letters[i]); });
|
|
1438
|
+
all = longer;
|
|
1439
|
+
}
|
|
1440
|
+
return all.slice(0, count);
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
// The styles the diagram uses, which a filter's style: names; made again
|
|
1444
|
+
// when an edited page redraws.
|
|
1445
|
+
var styleNames = {};
|
|
1446
|
+
function collectStyles() {
|
|
1447
|
+
styleNames = {};
|
|
1448
|
+
Object.keys(graph.nodes).forEach(function (name) { graph.nodes[name].styles.forEach(function (one) { styleNames[one] = true; }); });
|
|
1449
|
+
graph.edges.forEach(function (edge) { edge.styles.forEach(function (one) { styleNames[one] = true; }); });
|
|
1450
|
+
}
|
|
1451
|
+
collectStyles();
|
|
1452
|
+
|
|
1453
|
+
// The filter: every word must match. Returns the matches, and a message
|
|
1454
|
+
// when a word can match nothing at all, naming what it accepts.
|
|
1455
|
+
function filtered(targets, query) {
|
|
1456
|
+
var words = query.toLowerCase().split(' ').filter(function (word) { return word !== ''; });
|
|
1457
|
+
var message = '';
|
|
1458
|
+
var tests = words.map(function (word) {
|
|
1459
|
+
var colon = word.indexOf(':');
|
|
1460
|
+
var key = colon > 0 ? word.slice(0, colon) : '';
|
|
1461
|
+
var value = colon > 0 ? word.slice(colon + 1) : '';
|
|
1462
|
+
if (key === 'is') {
|
|
1463
|
+
var kinds = KINDS.filter(function (kind) { return kind.indexOf(value) === 0; });
|
|
1464
|
+
if (kinds.length === 0) message = 'is: takes node, edge or container';
|
|
1465
|
+
return function (target, thing) {
|
|
1466
|
+
if (value === '') return true;
|
|
1467
|
+
return kinds.some(function (kind) {
|
|
1468
|
+
if (kind === 'edge') return target.kind === 'edge';
|
|
1469
|
+
if (kind === 'node') return target.kind === 'node';
|
|
1470
|
+
return target.kind === 'node' && thing.children.length > 0;
|
|
1471
|
+
});
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
if (key === 'style') {
|
|
1475
|
+
var names = Object.keys(styleNames).filter(function (name) { return name.toLowerCase().indexOf(value) === 0; });
|
|
1476
|
+
if (names.length === 0) message = 'no style named "' + value + '"';
|
|
1477
|
+
return function (target, thing) {
|
|
1478
|
+
return value === '' || (thing.styles || []).some(function (name) { return names.indexOf(name) >= 0; });
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
if (key !== '' && /^[a-z]+$/.test(key)) {
|
|
1482
|
+
message = key + ': is not a filter; the filters are is: and style:';
|
|
1483
|
+
return function () { return false; };
|
|
1484
|
+
}
|
|
1485
|
+
return function (target, thing) { return (thing.label || '').toLowerCase().indexOf(word) >= 0; };
|
|
1486
|
+
});
|
|
1487
|
+
var matches = targets.filter(function (target) {
|
|
1488
|
+
var thing = target.kind === 'node' ? graph.nodes[target.key] : target.kind === 'edge' ? graph.edges[target.key] : target.link;
|
|
1489
|
+
return tests.every(function (test) { return test(target, thing); });
|
|
1490
|
+
});
|
|
1491
|
+
return { matches: matches, message: message };
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
function startHints(mode, filtering) {
|
|
1495
|
+
var base;
|
|
1496
|
+
if (mode === 'all') base = allTargets();
|
|
1497
|
+
else if (!selection) { notice('Select something first: s, then a letter'); return; }
|
|
1498
|
+
else if (mode === 'inside' && selection.kind === 'edge') { notice('A line has nothing inside it'); return; }
|
|
1499
|
+
else base = mode === 'neighbors' ? neighborTargets() : mode === 'inside' ? insideTargets() : linkTargets();
|
|
1500
|
+
if (base.length === 0) {
|
|
1501
|
+
notice(mode === 'links' ? nameOf(selection) + ' has no links' : mode === 'inside' ? nameOf(selection) + ' has nothing inside it and sits in nothing' : nameOf(selection) + ' has no lines');
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
hint = { mode: mode, base: base, query: '', typed: '', filtering: !!filtering, direct: !!filtering, shown: [], message: '' };
|
|
1505
|
+
setLevel(filtering ? 'filter' : 'pick');
|
|
1506
|
+
refresh();
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
function clearHints() {
|
|
1510
|
+
if (!hint && !popup) return;
|
|
1511
|
+
hint = null;
|
|
1512
|
+
hintLayer.textContent = '';
|
|
1513
|
+
if (popup) { popup.remove(); popup = null; }
|
|
1514
|
+
each(document.querySelectorAll('#selected .hintkey'), function (key) { key.remove(); });
|
|
1515
|
+
if (svg) {
|
|
1516
|
+
svg.classList.remove('hinting');
|
|
1517
|
+
each(svg.querySelectorAll('.hint-on'), function (group) { group.classList.remove('hint-on'); });
|
|
1518
|
+
}
|
|
1519
|
+
cancelAnimationFrame(frame);
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
function groupsOf(target) {
|
|
1523
|
+
return target.kind === 'node' ? nodeGroups(target.key) : target.kind === 'edge' ? edgeGroups(target.key) : [];
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// Work out what wears a letter, and draw the letters and the strip.
|
|
1527
|
+
function refresh() {
|
|
1528
|
+
var result = filtered(hint.base, hint.query);
|
|
1529
|
+
hint.message = result.message;
|
|
1530
|
+
var targets = ordered(result.matches);
|
|
1531
|
+
var labels = codes(targets.length);
|
|
1532
|
+
hint.shown = targets.map(function (target, i) { return { target: target, code: labels[i] }; });
|
|
1533
|
+
hint.typed = '';
|
|
1534
|
+
// Dim what can't be picked, unless everything can. Links aren't in the
|
|
1535
|
+
// drawing, so picking one dims nothing.
|
|
1536
|
+
each(svg.querySelectorAll('.hint-on'), function (group) { group.classList.remove('hint-on'); });
|
|
1537
|
+
var narrowed = hint.mode !== 'links' && (hint.mode !== 'all' || hint.shown.length < hint.base.length);
|
|
1538
|
+
svg.classList.toggle('hinting', narrowed);
|
|
1539
|
+
hint.shown.forEach(function (one) { each(groupsOf(one.target), function (group) { group.classList.add('hint-on'); }); });
|
|
1540
|
+
if (selection) each(groupsOf(selection), function (group) { group.classList.add('hint-on'); });
|
|
1541
|
+
drawLabels();
|
|
1542
|
+
showStrip();
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
function keyElement(code) {
|
|
1546
|
+
var key = element('span', 'hintkey');
|
|
1547
|
+
key.setAttribute('data-code', code);
|
|
1548
|
+
key.textContent = code;
|
|
1549
|
+
return key;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
function drawLabels() {
|
|
1553
|
+
hintLayer.textContent = '';
|
|
1554
|
+
if (popup) { popup.remove(); popup = null; }
|
|
1555
|
+
each(document.querySelectorAll('#selected .hintkey'), function (key) { key.remove(); });
|
|
1556
|
+
if (hint.mode === 'links') {
|
|
1557
|
+
if (!side.hidden) {
|
|
1558
|
+
// On the panel's own rows, where the links already are.
|
|
1559
|
+
var rows = selectedPanel.querySelectorAll('a.link');
|
|
1560
|
+
hint.shown.forEach(function (one) { var row = rows[one.target.key]; if (row) row.prepend(keyElement(one.code)); });
|
|
1561
|
+
} else {
|
|
1562
|
+
// The panel is hidden, so the links come up beside the selection.
|
|
1563
|
+
popup = element('div', '');
|
|
1564
|
+
popup.id = 'link-pop';
|
|
1565
|
+
hint.shown.forEach(function (one) {
|
|
1566
|
+
var row = element('a', 'link');
|
|
1567
|
+
row.href = one.target.link.href;
|
|
1568
|
+
row.target = '_blank';
|
|
1569
|
+
row.rel = 'noopener';
|
|
1570
|
+
row.append(keyElement(one.code));
|
|
1571
|
+
row.insertAdjacentHTML('beforeend', one.target.link.badges[shownTheme]);
|
|
1572
|
+
row.append(element('span', 'label', one.target.link.label));
|
|
1573
|
+
popup.append(row);
|
|
1574
|
+
});
|
|
1575
|
+
view.append(popup);
|
|
1576
|
+
}
|
|
1577
|
+
} else {
|
|
1578
|
+
hint.shown.forEach(function (one) {
|
|
1579
|
+
var key = keyElement(one.code);
|
|
1580
|
+
one.el = key;
|
|
1581
|
+
hintLayer.append(key);
|
|
1582
|
+
});
|
|
1583
|
+
}
|
|
1584
|
+
place();
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
// Letters follow the drawing as it pans and zooms.
|
|
1588
|
+
function place() {
|
|
1589
|
+
cancelAnimationFrame(frame);
|
|
1590
|
+
if (!hint) return;
|
|
1591
|
+
hint.shown.forEach(function (one) {
|
|
1592
|
+
if (!one.el) return;
|
|
1593
|
+
var at = onScreen(one.target);
|
|
1594
|
+
one.el.style.display = at ? '' : 'none';
|
|
1595
|
+
if (at) one.el.style.transform = 'translate(' + Math.round(at.x) + 'px,' + Math.round(at.y) + 'px) ' + (at.side === 'left' ? 'translate(calc(-100% - 4px), -50%)' : 'translate(-50%, -50%)');
|
|
1596
|
+
});
|
|
1597
|
+
if (popup && selection) {
|
|
1598
|
+
var at = onScreen(selection);
|
|
1599
|
+
if (at) {
|
|
1600
|
+
popup.style.left = Math.round(Math.min(Math.max(at.x, 8), view.clientWidth - popup.offsetWidth - 8)) + 'px';
|
|
1601
|
+
popup.style.top = Math.round(Math.min(Math.max(at.y - popup.offsetHeight - 10, 8), view.clientHeight - popup.offsetHeight - 8)) + 'px';
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
frame = requestAnimationFrame(place);
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
function escapeText(text) {
|
|
1608
|
+
var holder = element('span', '', text);
|
|
1609
|
+
return holder.innerHTML;
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
function showStrip() {
|
|
1613
|
+
var crumbs = '<b>' + MODE_WORD[hint.mode] + '</b>';
|
|
1614
|
+
if (hint.mode !== 'all') crumbs += ' ' + (hint.mode === 'inside' ? '' : 'of ') + escapeText(nameOf(selection));
|
|
1615
|
+
if (hint.filtering || hint.query) crumbs += ' \\u203A <span class="query">/' + escapeText(hint.query) + (hint.filtering ? '<i>\\u258C</i>' : '') + '</span>';
|
|
1616
|
+
var count = hint.shown.length;
|
|
1617
|
+
var said;
|
|
1618
|
+
if (hint.filtering) {
|
|
1619
|
+
said = (hint.message || (count === 1 ? '1 match \\u00B7 Enter picks it' : count + ' match' + (count === 1 ? '' : 'es') + (count ? ' \\u00B7 Enter to pick' : ''))) + ' \\u00B7 \\u232B \\u00B7 Esc out';
|
|
1620
|
+
} else {
|
|
1621
|
+
said = (hint.mode === 'links' ? 'letter to open' : 'letter to pick') + (hint.mode === 'links' ? '' : ' \\u00B7 / filter') + ' \\u00B7 \\u232B back \\u00B7 Esc out \\u00B7 ' + count;
|
|
1622
|
+
}
|
|
1623
|
+
strip.hidden = false;
|
|
1624
|
+
strip.querySelector('.crumbs').innerHTML = crumbs;
|
|
1625
|
+
strip.querySelector('.hint').textContent = said;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
function notice(text) {
|
|
1629
|
+
clearTimeout(noticeTimer);
|
|
1630
|
+
if (level) setLevel(null);
|
|
1631
|
+
strip.hidden = false;
|
|
1632
|
+
strip.querySelector('.crumbs').textContent = '';
|
|
1633
|
+
strip.querySelector('.hint').textContent = text;
|
|
1634
|
+
noticeTimer = setTimeout(function () { if (!level) strip.hidden = true; }, 1800);
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
// A picked thing is selected, and brought into view if it was out of it.
|
|
1638
|
+
function act(target) {
|
|
1639
|
+
if (target.kind === 'link') {
|
|
1640
|
+
setLevel(null);
|
|
1641
|
+
window.open(target.link.href, '_blank', 'noopener');
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
setLevel(null);
|
|
1645
|
+
select(target);
|
|
1646
|
+
var at = onScreen(target);
|
|
1647
|
+
if (at && !at.inView) zoom.pan(view.clientWidth / 2 - at.x, view.clientHeight / 2 - at.y);
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
// The keys at the two levels. Returns false for a key left to the page.
|
|
1651
|
+
function hintKey(event) {
|
|
1652
|
+
var key = event.key;
|
|
1653
|
+
if (hint.filtering) {
|
|
1654
|
+
if (key === 'Enter') {
|
|
1655
|
+
if (hint.shown.length === 1) act(hint.shown[0].target);
|
|
1656
|
+
else if (hint.shown.length > 1) { hint.filtering = false; setLevel('pick'); showStrip(); }
|
|
1657
|
+
} else if (key === 'Backspace') {
|
|
1658
|
+
if (hint.query) { hint.query = hint.query.slice(0, -1); refresh(); }
|
|
1659
|
+
else if (hint.direct) setLevel(null);
|
|
1660
|
+
else { hint.filtering = false; setLevel('pick'); showStrip(); }
|
|
1661
|
+
} else if (key.length === 1) { hint.query += key; refresh(); }
|
|
1662
|
+
else return false;
|
|
1663
|
+
return true;
|
|
1664
|
+
}
|
|
1665
|
+
if (ZOOM_KEYS[key] || key === 'Tab') return false;
|
|
1666
|
+
if (key === 'Backspace') {
|
|
1667
|
+
if (hint.typed) { hint.typed = hint.typed.slice(0, -1); markTyped(); }
|
|
1668
|
+
else if (hint.query || hint.mode === 'all' && hint.direct) { hint.filtering = true; setLevel('filter'); showStrip(); }
|
|
1669
|
+
else setLevel(null);
|
|
1670
|
+
return true;
|
|
1671
|
+
}
|
|
1672
|
+
if (key === '/' && hint.mode !== 'links') { hint.filtering = true; setLevel('filter'); showStrip(); return true; }
|
|
1673
|
+
if (key.length === 1 && /[a-z]/i.test(key)) {
|
|
1674
|
+
var typed = hint.typed + key.toLowerCase();
|
|
1675
|
+
var exact = hint.shown.filter(function (one) { return one.code === typed; })[0];
|
|
1676
|
+
if (exact) { act(exact.target); return true; }
|
|
1677
|
+
if (hint.shown.some(function (one) { return one.code.indexOf(typed) === 0; })) { hint.typed = typed; markTyped(); }
|
|
1678
|
+
return true;
|
|
1679
|
+
}
|
|
1680
|
+
return key.indexOf('Arrow') !== 0;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// Letters that no longer fit what has been typed step aside.
|
|
1684
|
+
function markTyped() {
|
|
1685
|
+
hint.shown.forEach(function (one) {
|
|
1686
|
+
var key = one.el || document.querySelector('#selected .hintkey[data-code="' + one.code + '"], #link-pop .hintkey[data-code="' + one.code + '"]');
|
|
1687
|
+
if (!key) return;
|
|
1688
|
+
var fits = one.code.indexOf(hint.typed) === 0;
|
|
1689
|
+
key.classList.toggle('gone', !fits);
|
|
1690
|
+
key.innerHTML = fits && hint.typed ? '<span class="done">' + hint.typed + '</span>' + one.code.slice(hint.typed.length) : one.code;
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
985
1693
|
|
|
986
|
-
|
|
1694
|
+
saveButton.onclick = function () { setLevel(level === 'save' ? null : 'save'); };
|
|
1695
|
+
versionsButton.onclick = function () { setLevel(level === 'versions' ? null : 'versions'); };
|
|
1696
|
+
viewButton.onclick = function () { setLevel(level === 'view' || level === 'theme' ? null : 'view'); };
|
|
1697
|
+
themeRow.onclick = function () { setLevel(level === 'theme' ? 'view' : 'theme'); };
|
|
1698
|
+
document.addEventListener('click', function (event) {
|
|
1699
|
+
if (level && !(event.target.closest && event.target.closest('.menu'))) setLevel(null);
|
|
1700
|
+
});
|
|
987
1701
|
|
|
1702
|
+
function move(by) {
|
|
1703
|
+
var all = rows();
|
|
1704
|
+
if (!all.length) return;
|
|
1705
|
+
cursor = (cursor + by + all.length) % all.length;
|
|
1706
|
+
markCursor();
|
|
1707
|
+
if (level === 'theme') showTheme(themeItems[cursor].value || DATA.current);
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
function enter() {
|
|
1711
|
+
var all = rows();
|
|
1712
|
+
if (!all.length) return;
|
|
1713
|
+
if (level === 'theme') keepTheme(themeItems[cursor].value);
|
|
1714
|
+
else all[cursor].click();
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
var ZOOM_KEYS = { '+': 1, '=': 1, '-': 1, '_': 1, '0': 1, '1': 1 };
|
|
1718
|
+
|
|
1719
|
+
// On the window, before the zoom's own keys, so a menu's letters reach the
|
|
1720
|
+
// menu and not the diagram underneath: g flips guides in View, not a grab.
|
|
1721
|
+
window.addEventListener('keydown', function (event) {
|
|
1722
|
+
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
|
1723
|
+
if (event.target.closest && event.target.closest('input, textarea, select, [contenteditable]')) return;
|
|
1724
|
+
var key = event.key;
|
|
1725
|
+
if (key === 'Escape') plain();
|
|
1726
|
+
else if (level === 'pick' || level === 'filter') {
|
|
1727
|
+
if (!hintKey(event)) return;
|
|
1728
|
+
} else if (level === null) {
|
|
1729
|
+
if (key === 'm' || key === 'M') setLevel('bar');
|
|
1730
|
+
else if (key === 'e' && EDIT) toggleEditor();
|
|
1731
|
+
else if (key === 's') startHints('all');
|
|
1732
|
+
else if (key === '/') startHints('all', true);
|
|
1733
|
+
else if (key === 'n') startHints('neighbors');
|
|
1734
|
+
else if (key === 'i') startHints('inside');
|
|
1735
|
+
// During a grab, l pans right.
|
|
1736
|
+
else if (key === 'l' && !zoom.held()) startHints('links');
|
|
1737
|
+
else if (key === 'Backspace') {
|
|
1738
|
+
if (zoom.held()) zoom.grab(false);
|
|
1739
|
+
else if (lit.length) unlight();
|
|
1740
|
+
else if (selection) select(null);
|
|
1741
|
+
else return;
|
|
1742
|
+
} else return;
|
|
1743
|
+
} else {
|
|
1744
|
+
if (ZOOM_KEYS[key] || key === 'Tab') return;
|
|
1745
|
+
if (key === 'Backspace') back();
|
|
1746
|
+
else if (key === 'ArrowDown') move(1);
|
|
1747
|
+
else if (key === 'ArrowUp') move(-1);
|
|
1748
|
+
else if (key === 'Enter' || key === ' ') enter();
|
|
1749
|
+
else if (key.length === 1) {
|
|
1750
|
+
var letter = key.toLowerCase();
|
|
1751
|
+
if (level === 'bar') {
|
|
1752
|
+
if (letter === 'v') setLevel('view');
|
|
1753
|
+
else if (letter === 'x') setLevel('save');
|
|
1754
|
+
else if (letter === 'o' && tiedFile()) setLevel('versions');
|
|
1755
|
+
else if (letter === 'e' && EDIT) { setLevel(null); toggleEditor(); }
|
|
1756
|
+
else if (letter === 'f' && !full.hidden) { setLevel(null); toggleFull(); }
|
|
1757
|
+
} else if (level === 'view' || level === 'save' || level === 'versions') {
|
|
1758
|
+
rows().forEach(function (row, i) {
|
|
1759
|
+
if (row.getAttribute('data-key') !== letter) return;
|
|
1760
|
+
cursor = i;
|
|
1761
|
+
markCursor();
|
|
1762
|
+
row.click();
|
|
1763
|
+
});
|
|
1764
|
+
}
|
|
1765
|
+
} else if (key.indexOf('Arrow') !== 0) return;
|
|
1766
|
+
}
|
|
1767
|
+
event.preventDefault();
|
|
1768
|
+
event.stopImmediatePropagation();
|
|
1769
|
+
}, true);
|
|
1770
|
+
|
|
1771
|
+
// With the menus closed, p and f still show the panel and go full screen.
|
|
988
1772
|
document.addEventListener('keydown', function (event) {
|
|
989
1773
|
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
|
990
1774
|
if (event.target.closest && event.target.closest('input, textarea, select')) return;
|
|
991
1775
|
if (event.key === 'p') setPanel(!panelOn);
|
|
992
1776
|
else if (event.key === 'f' && !full.hidden) toggleFull();
|
|
993
|
-
else if (event.key === 'Escape') { showSave(false); unlight(); select(null); }
|
|
994
1777
|
else return;
|
|
995
1778
|
event.preventDefault();
|
|
996
1779
|
});
|
|
997
1780
|
|
|
1781
|
+
/* ---- editing: a page tied to a file, and a page with no file ---- */
|
|
1782
|
+
|
|
1783
|
+
// A page with an editor draws in the browser: with the library the server
|
|
1784
|
+
// that served it also serves, or, on the hosted playground, with the
|
|
1785
|
+
// library inlined in the page. Each drawing keeps the view, the theme and
|
|
1786
|
+
// whatever is still there to be selected. A text that doesn't draw leaves
|
|
1787
|
+
// the last picture that did, with the error at the head of the findings.
|
|
1788
|
+
//
|
|
1789
|
+
// Tied to a file, the page follows it: each time the file changes on disk,
|
|
1790
|
+
// the server sends its text, and Save writes it. With no file, the text is
|
|
1791
|
+
// kept in this browser, the examples and Open are offered, and Save
|
|
1792
|
+
// downloads it; served with no file, Save names a file in the folder, and
|
|
1793
|
+
// from then on the page is tied to that file as if it had been opened with it.
|
|
1794
|
+
var heading = document.querySelector('header h1');
|
|
1795
|
+
var drawnLine = document.querySelector('header .drawn');
|
|
1796
|
+
var liveNote = document.getElementById('live');
|
|
1797
|
+
|
|
1798
|
+
function tiedFile() { return EDIT && EDIT.kind === 'file' ? EDIT.file : null; }
|
|
1799
|
+
|
|
1800
|
+
function adopt(next, title) {
|
|
1801
|
+
next.edit = EDIT;
|
|
1802
|
+
DATA = next;
|
|
1803
|
+
graph = DATA.graph;
|
|
1804
|
+
collectStyles();
|
|
1805
|
+
shownTheme = chosenName && DATA.themes[chosenName] ? chosenName : DATA.current;
|
|
1806
|
+
// A page with no file is headed reladraw, whatever the diagram is called.
|
|
1807
|
+
if (!EDIT || EDIT.kind === 'file') {
|
|
1808
|
+
heading.textContent = title;
|
|
1809
|
+
document.title = title;
|
|
1810
|
+
}
|
|
1811
|
+
themeItems[0].row.querySelector('.name').textContent = "Diagram's theme (" + DATA.current + ')';
|
|
1812
|
+
if (selection && !(selection.kind === 'node' ? graph.nodes[selection.key] : graph.edges[selection.key])) selection = null;
|
|
1813
|
+
if (level === 'pick' || level === 'filter') setLevel(null);
|
|
1814
|
+
listFindings(null);
|
|
1815
|
+
redraw();
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
var libReady = !EDIT ? Promise.resolve(null)
|
|
1819
|
+
: EDIT.kind === 'hosted' ? Promise.resolve(globalThis.reladraw)
|
|
1820
|
+
: Promise.all(['/lib/index.js', '/lib/page.js', '/lib/page-data.js'].map(function (path) { return import(path); }))
|
|
1821
|
+
.then(function (all) { return Object.assign({}, all[0], all[1], all[2]); });
|
|
1822
|
+
var lib = null;
|
|
1823
|
+
|
|
1824
|
+
function clock(at) {
|
|
1825
|
+
return ('0' + at.getHours()).slice(-2) + ':' + ('0' + at.getMinutes()).slice(-2);
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
function toClipboard(text) {
|
|
1829
|
+
if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(text);
|
|
1830
|
+
return Promise.reject(new Error('no clipboard'));
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
var editorPane = document.getElementById('editor');
|
|
1834
|
+
var editButton = document.getElementById('edit');
|
|
1835
|
+
var splitBar = document.getElementById('split');
|
|
1836
|
+
var sourceBox = document.getElementById('source');
|
|
1837
|
+
var paintBox = document.getElementById('paint');
|
|
1838
|
+
var unsavedMark = document.getElementById('unsaved');
|
|
1839
|
+
var writeButton = document.getElementById('write');
|
|
1840
|
+
var formatButton = document.getElementById('format');
|
|
1841
|
+
var openButton = document.getElementById('open-file');
|
|
1842
|
+
var formatOpen = document.getElementById('format-open');
|
|
1843
|
+
var browseButton = document.getElementById('browse');
|
|
1844
|
+
|
|
1845
|
+
// \`quiet\` shows it without taking the keys, as when the page opens.
|
|
1846
|
+
function toggleEditor(show, quiet) {
|
|
1847
|
+
if (!EDIT) return;
|
|
1848
|
+
var on = show === undefined ? editorPane.hidden : show;
|
|
1849
|
+
editorPane.hidden = !on;
|
|
1850
|
+
splitBar.hidden = !on;
|
|
1851
|
+
editButton.setAttribute('aria-pressed', String(on));
|
|
1852
|
+
if (on) { paintSource(); if (!quiet) sourceBox.focus(); }
|
|
1853
|
+
else if (document.activeElement === sourceBox) sourceBox.blur();
|
|
1854
|
+
}
|
|
1855
|
+
editButton.onclick = function () { toggleEditor(); };
|
|
1856
|
+
|
|
1857
|
+
function escapeCode(text) { return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
|
1858
|
+
// The scanner covers each line end to end, so this rebuilds the text exactly
|
|
1859
|
+
// and only wraps some of it in spans; a lost character would slide the rest
|
|
1860
|
+
// of the line out from under the cursor.
|
|
1861
|
+
function paintSource() {
|
|
1862
|
+
var text = sourceBox.value;
|
|
1863
|
+
var out = '';
|
|
1864
|
+
try {
|
|
1865
|
+
if (!lib) throw new Error('not loaded');
|
|
1866
|
+
text.split(/\\r?\\n/).forEach(function (line) {
|
|
1867
|
+
lib.highlightLine(line).forEach(function (span) {
|
|
1868
|
+
var piece = escapeCode(line.slice(span.start, span.end));
|
|
1869
|
+
out += span.kind === 'plain' ? piece : '<span class="t-' + span.kind + '">' + piece + '</span>';
|
|
1870
|
+
});
|
|
1871
|
+
out += '\\n';
|
|
1872
|
+
});
|
|
1873
|
+
} catch (e) {
|
|
1874
|
+
// Coloring is decoration; the editor has to keep working without it.
|
|
1875
|
+
out = escapeCode(text) + '\\n';
|
|
1876
|
+
}
|
|
1877
|
+
paintBox.innerHTML = out;
|
|
1878
|
+
// A textarea's scroll bar comes out of its own width, so the colored
|
|
1879
|
+
// layer is given the width the text actually gets.
|
|
1880
|
+
paintBox.style.width = sourceBox.clientWidth + 'px';
|
|
1881
|
+
paintBox.scrollTop = sourceBox.scrollTop;
|
|
1882
|
+
}
|
|
1883
|
+
sourceBox.addEventListener('scroll', function () { paintBox.scrollTop = sourceBox.scrollTop; });
|
|
1884
|
+
window.addEventListener('resize', function () { if (EDIT && !editorPane.hidden) paintSource(); });
|
|
1885
|
+
|
|
1886
|
+
// Selects a line of the source, as a finding or an error naming it does.
|
|
1887
|
+
function goToLine(number) {
|
|
1888
|
+
if (!EDIT) return;
|
|
1889
|
+
toggleEditor(true, true);
|
|
1890
|
+
var lines = sourceBox.value.split('\\n');
|
|
1891
|
+
var start = 0;
|
|
1892
|
+
for (var i = 0; i < number - 1 && i < lines.length; i++) start += lines[i].length + 1;
|
|
1893
|
+
var end = start + (lines[number - 1] || '').length;
|
|
1894
|
+
sourceBox.focus();
|
|
1895
|
+
sourceBox.setSelectionRange(start, end);
|
|
1896
|
+
// Chrome doesn't scroll a selection made from outside the field into
|
|
1897
|
+
// view; leaving and coming back is what makes it scroll.
|
|
1898
|
+
sourceBox.blur();
|
|
1899
|
+
sourceBox.focus();
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
/* ---- drawing what is typed ---- */
|
|
1903
|
+
|
|
1904
|
+
// The name the diagram is drawn as, which a saved file is given.
|
|
1905
|
+
var fileName = 'diagram.reladraw';
|
|
1906
|
+
var drawnText = DATA.source;
|
|
1907
|
+
var cost = 0;
|
|
1908
|
+
var typing = null;
|
|
1909
|
+
|
|
1910
|
+
function draw(text) {
|
|
1911
|
+
if (text === drawnText || !lib) return;
|
|
1912
|
+
drawnText = text;
|
|
1913
|
+
var started = performance.now();
|
|
1914
|
+
var made;
|
|
1915
|
+
try {
|
|
1916
|
+
made = lib.pageData(text, fileName, {});
|
|
1917
|
+
} catch (error) {
|
|
1918
|
+
cost = performance.now() - started;
|
|
1919
|
+
listFindings({ message: error.message, line: error.line });
|
|
1920
|
+
if (panelOn && panel.hidden) showPanel(true);
|
|
1921
|
+
return;
|
|
1922
|
+
}
|
|
1923
|
+
cost = performance.now() - started;
|
|
1924
|
+
adopt(made.data, made.title);
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
// A diagram quick to draw is drawn on every keystroke; a slow one once the
|
|
1928
|
+
// typing pauses, so the keys aren't held up behind it.
|
|
1929
|
+
function drawSoon() {
|
|
1930
|
+
clearTimeout(typing);
|
|
1931
|
+
if (cost < 50) draw(sourceBox.value);
|
|
1932
|
+
else typing = setTimeout(function () { draw(sourceBox.value); }, 150);
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
sourceBox.addEventListener('input', function () {
|
|
1936
|
+
paintSource();
|
|
1937
|
+
edited();
|
|
1938
|
+
drawSoon();
|
|
1939
|
+
});
|
|
1940
|
+
sourceBox.addEventListener('keydown', function (event) {
|
|
1941
|
+
if (event.key === 'Escape') { sourceBox.blur(); event.preventDefault(); }
|
|
1942
|
+
});
|
|
1943
|
+
|
|
1944
|
+
// Text put in whole: a version, an example, a file opened, the file as it is on disk.
|
|
1945
|
+
function load(text) {
|
|
1946
|
+
sourceBox.value = text;
|
|
1947
|
+
paintSource();
|
|
1948
|
+
edited();
|
|
1949
|
+
draw(text);
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
// Text put in as one edit the browser can undo, with the cursor kept on its line.
|
|
1953
|
+
function replaceSource(text) {
|
|
1954
|
+
var before = sourceBox.value;
|
|
1955
|
+
var line = before.slice(0, sourceBox.selectionStart).split('\\n').length;
|
|
1956
|
+
var scroll = sourceBox.scrollTop;
|
|
1957
|
+
sourceBox.focus();
|
|
1958
|
+
sourceBox.setSelectionRange(0, before.length);
|
|
1959
|
+
var undoable = false;
|
|
1960
|
+
try { undoable = document.execCommand('insertText', false, text); } catch (e) {}
|
|
1961
|
+
if (!undoable || sourceBox.value !== text) {
|
|
1962
|
+
sourceBox.value = text;
|
|
1963
|
+
sourceBox.dispatchEvent(new Event('input'));
|
|
1964
|
+
}
|
|
1965
|
+
var lines = text.split('\\n');
|
|
1966
|
+
var at = 0;
|
|
1967
|
+
for (var i = 0; i < line - 1 && i < lines.length; i++) at += lines[i].length + 1;
|
|
1968
|
+
sourceBox.setSelectionRange(at, at);
|
|
1969
|
+
sourceBox.scrollTop = scroll;
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
/* ---- the divider between the source and the picture ---- */
|
|
1973
|
+
|
|
1974
|
+
// Kept as a share of the width rather than pixels, so a remembered split
|
|
1975
|
+
// means the same in a wider or narrower window.
|
|
1976
|
+
var SPLIT = 'reladraw.split';
|
|
1977
|
+
var mainBox = document.querySelector('main');
|
|
1978
|
+
function divide(share, keep) {
|
|
1979
|
+
if (share === null) {
|
|
1980
|
+
editorPane.style.removeProperty('width');
|
|
1981
|
+
editorPane.style.removeProperty('max-width');
|
|
1982
|
+
} else {
|
|
1983
|
+
share = Math.max(0.15, Math.min(0.8, share));
|
|
1984
|
+
editorPane.style.width = (share * 100).toFixed(2) + '%';
|
|
1985
|
+
editorPane.style.maxWidth = 'none';
|
|
1986
|
+
splitBar.setAttribute('aria-valuenow', String(Math.round(share * 100)));
|
|
1987
|
+
}
|
|
1988
|
+
paintSource();
|
|
1989
|
+
if (!keep) return;
|
|
1990
|
+
try {
|
|
1991
|
+
if (share === null) localStorage.removeItem(SPLIT);
|
|
1992
|
+
else localStorage.setItem(SPLIT, String(share));
|
|
1993
|
+
} catch (e) {}
|
|
1994
|
+
}
|
|
1995
|
+
function shareAt(x) {
|
|
1996
|
+
var box = mainBox.getBoundingClientRect();
|
|
1997
|
+
return (x - box.left) / box.width;
|
|
1998
|
+
}
|
|
1999
|
+
var splitting = false;
|
|
2000
|
+
splitBar.addEventListener('pointerdown', function (event) {
|
|
2001
|
+
if (event.button !== 0) return;
|
|
2002
|
+
splitting = true;
|
|
2003
|
+
try { splitBar.setPointerCapture(event.pointerId); } catch (e) {}
|
|
2004
|
+
splitBar.classList.add('dragging');
|
|
2005
|
+
document.body.classList.add('dragging');
|
|
2006
|
+
event.preventDefault();
|
|
2007
|
+
});
|
|
2008
|
+
splitBar.addEventListener('pointermove', function (event) { if (splitting) divide(shareAt(event.clientX), false); });
|
|
2009
|
+
function letGo(event) {
|
|
2010
|
+
if (!splitting) return;
|
|
2011
|
+
splitting = false;
|
|
2012
|
+
splitBar.classList.remove('dragging');
|
|
2013
|
+
document.body.classList.remove('dragging');
|
|
2014
|
+
divide(shareAt(event.clientX), true);
|
|
2015
|
+
zoom.show();
|
|
2016
|
+
}
|
|
2017
|
+
splitBar.addEventListener('pointerup', letGo);
|
|
2018
|
+
splitBar.addEventListener('pointercancel', letGo);
|
|
2019
|
+
splitBar.addEventListener('dblclick', function () { divide(null, true); });
|
|
2020
|
+
splitBar.addEventListener('keydown', function (event) {
|
|
2021
|
+
var step = { ArrowLeft: -0.02, ArrowRight: 0.02 }[event.key];
|
|
2022
|
+
if (!step) return;
|
|
2023
|
+
divide(shareAt(splitBar.getBoundingClientRect().left) + step, true);
|
|
2024
|
+
event.preventDefault();
|
|
2025
|
+
event.stopPropagation();
|
|
2026
|
+
});
|
|
2027
|
+
|
|
2028
|
+
/* ---- formatting ---- */
|
|
2029
|
+
|
|
2030
|
+
// Tied to a file, the settings are the nearest .reladraw-format, as
|
|
2031
|
+
// reladraw format reads them; with no file, the ones set in this browser.
|
|
2032
|
+
function settingsText() {
|
|
2033
|
+
var file = tiedFile();
|
|
2034
|
+
if (file) return fetch('/format-settings?file=' + encodeURIComponent(file)).then(function (r) { return r.text(); });
|
|
2035
|
+
return Promise.resolve(formatText());
|
|
2036
|
+
}
|
|
2037
|
+
function formatSource() {
|
|
2038
|
+
Promise.all([libReady, settingsText()]).then(function (got) {
|
|
2039
|
+
var settings;
|
|
2040
|
+
try {
|
|
2041
|
+
settings = lib.readFormatSettings(got[1]);
|
|
2042
|
+
} catch (error) {
|
|
2043
|
+
if (tiedFile()) throw error;
|
|
2044
|
+
openFormatSettings();
|
|
2045
|
+
return;
|
|
2046
|
+
}
|
|
2047
|
+
var laid = lib.format(sourceBox.value, settings);
|
|
2048
|
+
if (laid === sourceBox.value) { flash(formatButton, 'Already formatted'); return; }
|
|
2049
|
+
replaceSource(laid);
|
|
2050
|
+
}).catch(function (error) { flash(formatButton, 'Can\\u2019t format: ' + error.message.split('\\n')[0]); });
|
|
2051
|
+
}
|
|
2052
|
+
formatButton.onclick = formatSource;
|
|
2053
|
+
|
|
2054
|
+
// Save on the keyboard works wherever the focus is, and never saves the
|
|
2055
|
+
// page; Shift+Alt+F formats, as in VS Code, by the key's code, since on a
|
|
2056
|
+
// Mac Alt turns F into another character.
|
|
2057
|
+
window.addEventListener('keydown', function (event) {
|
|
2058
|
+
if (!EDIT) return;
|
|
2059
|
+
if ((event.metaKey || event.ctrlKey) && !event.altKey && (event.key === 's' || event.key === 'S')) {
|
|
2060
|
+
event.preventDefault();
|
|
2061
|
+
event.stopImmediatePropagation();
|
|
2062
|
+
saveText();
|
|
2063
|
+
} else if (event.shiftKey && event.altKey && !event.ctrlKey && !event.metaKey && event.code === 'KeyF') {
|
|
2064
|
+
event.preventDefault();
|
|
2065
|
+
event.stopImmediatePropagation();
|
|
2066
|
+
formatSource();
|
|
2067
|
+
}
|
|
2068
|
+
}, true);
|
|
2069
|
+
|
|
2070
|
+
function saveText() {
|
|
2071
|
+
if (tiedFile()) write(false);
|
|
2072
|
+
else if (EDIT.kind === 'local') openSaveAs();
|
|
2073
|
+
else saveDownload();
|
|
2074
|
+
}
|
|
2075
|
+
writeButton.onclick = saveText;
|
|
2076
|
+
|
|
2077
|
+
/* ---- a page tied to a file ---- */
|
|
2078
|
+
|
|
2079
|
+
// What the file holds, as last heard from the server; and what the
|
|
2080
|
+
// editor's text was loaded or saved from, which a save checks the file
|
|
2081
|
+
// against, so a change on disk the person kept their text over is asked
|
|
2082
|
+
// about rather than written over.
|
|
2083
|
+
var disk = null;
|
|
2084
|
+
var base = null;
|
|
2085
|
+
var versions = [];
|
|
2086
|
+
var down = false;
|
|
2087
|
+
var changedStrip = document.getElementById('changed');
|
|
2088
|
+
|
|
2089
|
+
function markUnsaved() {
|
|
2090
|
+
if (!EDIT) return;
|
|
2091
|
+
if (tiedFile()) {
|
|
2092
|
+
unsavedMark.hidden = sourceBox.value === disk;
|
|
2093
|
+
unsavedMark.title = 'Not saved to the file';
|
|
2094
|
+
} else {
|
|
2095
|
+
unsavedMark.hidden = !unsavedHere();
|
|
2096
|
+
unsavedMark.title = 'Not saved to a file';
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
// Ties the page to a file whose text is \`text\`: from a served page's start,
|
|
2101
|
+
// or from the first Save of a page with no file, after which the page is
|
|
2102
|
+
// what it would have been opened with the file.
|
|
2103
|
+
function tie(file, text) {
|
|
2104
|
+
var fresh = !!EDIT && EDIT.kind !== 'file';
|
|
2105
|
+
EDIT = DATA.edit = { kind: 'file', file: file };
|
|
2106
|
+
fileName = file.split('/').pop();
|
|
2107
|
+
disk = text;
|
|
2108
|
+
base = text;
|
|
2109
|
+
browseButton.hidden = true;
|
|
2110
|
+
openButton.hidden = true;
|
|
2111
|
+
formatOpen.hidden = true;
|
|
2112
|
+
document.getElementById('versions-menu').hidden = false;
|
|
2113
|
+
writeButton.querySelector('.name').textContent = 'Save';
|
|
2114
|
+
writeButton.title = 'Write the source to the file (\\u2318S or Ctrl+S)';
|
|
2115
|
+
formatButton.title = 'Lay the source out again, as reladraw format does, with the nearest .reladraw-format (Shift+Alt+F)';
|
|
2116
|
+
editorPane.querySelector('.file').textContent = file;
|
|
2117
|
+
drawnLine.textContent = file + ' \\u00b7 live, read ' + clock(new Date());
|
|
2118
|
+
var links = document.querySelector('header .links');
|
|
2119
|
+
if (links) links.remove();
|
|
2120
|
+
if (fresh) {
|
|
2121
|
+
// Drawn again under the file's name, and headed with the diagram's title.
|
|
2122
|
+
drawnText = null;
|
|
2123
|
+
draw(sourceBox.value);
|
|
2124
|
+
}
|
|
2125
|
+
markUnsaved();
|
|
2126
|
+
follow(file);
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
function who(version) {
|
|
2130
|
+
if (version.who === 'you') return 'you';
|
|
2131
|
+
if (version.who === 'agent') return 'agent' + (version.note ? ': \\u201c' + version.note + '\\u201d' : '');
|
|
2132
|
+
return 'changed on disk';
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
// A change on disk loads at once, unless the editor holds text that isn't
|
|
2136
|
+
// in the file: then the text stays and a strip offers the file instead.
|
|
2137
|
+
function heard(message) {
|
|
2138
|
+
var clean = sourceBox.value === disk && base === disk;
|
|
2139
|
+
disk = message.text;
|
|
2140
|
+
versions = message.versions || [];
|
|
2141
|
+
listVersions();
|
|
2142
|
+
if (clean || sourceBox.value === disk) {
|
|
2143
|
+
base = disk;
|
|
2144
|
+
changedStrip.hidden = true;
|
|
2145
|
+
load(disk);
|
|
2146
|
+
} else {
|
|
2147
|
+
var last = versions[versions.length - 1];
|
|
2148
|
+
changedStrip.querySelector('.what').textContent =
|
|
2149
|
+
fileName + ' changed on disk at ' + clock(new Date(message.at)) + (last ? ' (' + who(last) + ')' : '');
|
|
2150
|
+
changedStrip.hidden = false;
|
|
2151
|
+
if (editorPane.hidden) toggleEditor(true);
|
|
2152
|
+
}
|
|
2153
|
+
markUnsaved();
|
|
2154
|
+
}
|
|
2155
|
+
document.getElementById('load-disk').onclick = function () {
|
|
2156
|
+
base = disk;
|
|
2157
|
+
changedStrip.hidden = true;
|
|
2158
|
+
load(disk);
|
|
2159
|
+
};
|
|
2160
|
+
document.getElementById('keep-mine').onclick = function () { changedStrip.hidden = true; };
|
|
2161
|
+
|
|
2162
|
+
function write(replace) {
|
|
2163
|
+
var file = tiedFile();
|
|
2164
|
+
var text = sourceBox.value;
|
|
2165
|
+
return fetch('/save?file=' + encodeURIComponent(file), {
|
|
2166
|
+
method: 'POST',
|
|
2167
|
+
body: JSON.stringify({ text: text, base: base, replace: !!replace }),
|
|
2168
|
+
}).then(function (answer) {
|
|
2169
|
+
if (answer.status === 409) {
|
|
2170
|
+
return answer.json().then(function (now) {
|
|
2171
|
+
var what = fileName + ' changed on disk at ' + clock(new Date(now.at)) +
|
|
2172
|
+
(now.who === 'agent' ? ' (agent' + (now.note ? ': \\u201c' + now.note + '\\u201d' : '') + ')' : '') +
|
|
2173
|
+
' after you started editing. Saving puts your text in its place; the version on disk stays in Versions.\\n\\nReplace the file on disk?';
|
|
2174
|
+
if (confirm(what)) return write(true);
|
|
2175
|
+
});
|
|
2176
|
+
}
|
|
2177
|
+
if (!answer.ok) throw new Error('the server said ' + answer.status);
|
|
2178
|
+
disk = text;
|
|
2179
|
+
base = text;
|
|
2180
|
+
changedStrip.hidden = true;
|
|
2181
|
+
markUnsaved();
|
|
2182
|
+
flash(writeButton, 'Saved');
|
|
2183
|
+
}).catch(function (error) {
|
|
2184
|
+
flash(writeButton, 'Not saved');
|
|
2185
|
+
liveNote.textContent = 'Couldn\\u2019t save ' + file + ': ' + error.message;
|
|
2186
|
+
liveNote.hidden = false;
|
|
2187
|
+
});
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
// Newest first. Picking one puts it in the editor as unsaved text.
|
|
2191
|
+
function listVersions() {
|
|
2192
|
+
versionsList.textContent = '';
|
|
2193
|
+
versions.slice().reverse().forEach(function (version, i) {
|
|
2194
|
+
var row = element('button', 'row');
|
|
2195
|
+
row.type = 'button';
|
|
2196
|
+
row.setAttribute('role', 'menuitem');
|
|
2197
|
+
var label = version.who === 'opened' ? 'opened' : version.who === 'disk' ? 'on disk' : version.who;
|
|
2198
|
+
var about = version.who === 'opened' ? 'as the server first read it'
|
|
2199
|
+
: version.who === 'disk' ? 'changed on disk'
|
|
2200
|
+
: version.who === 'agent' ? (version.note || 'changed by an agent')
|
|
2201
|
+
: 'saved from this page';
|
|
2202
|
+
row.append(element('span', 'when', clock(new Date(version.at))), element('span', 'who ' + version.who, label),
|
|
2203
|
+
element('span', 'about', about + (i === 0 ? ' (in the file now)' : '')));
|
|
2204
|
+
row.onclick = function () {
|
|
2205
|
+
setLevel(null);
|
|
2206
|
+
toggleEditor(true);
|
|
2207
|
+
load(version.text);
|
|
2208
|
+
};
|
|
2209
|
+
versionsList.append(row);
|
|
2210
|
+
});
|
|
2211
|
+
if (!versions.length) versionsList.append(element('span', 'none', 'No versions yet.'));
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
function follow(file) {
|
|
2215
|
+
var events = new EventSource('/events?file=' + encodeURIComponent(file));
|
|
2216
|
+
events.addEventListener('source', function (event) {
|
|
2217
|
+
var message = JSON.parse(event.data);
|
|
2218
|
+
if (down) {
|
|
2219
|
+
down = false;
|
|
2220
|
+
liveNote.hidden = true;
|
|
2221
|
+
writeButton.disabled = false;
|
|
2222
|
+
}
|
|
2223
|
+
drawnLine.textContent = file + ' \\u00b7 live, ' + (message.changed ? 'changed ' : 'read ') + clock(new Date(message.at));
|
|
2224
|
+
heard(message);
|
|
2225
|
+
});
|
|
2226
|
+
events.onerror = function () {
|
|
2227
|
+
if (down) return;
|
|
2228
|
+
down = true;
|
|
2229
|
+
writeButton.disabled = true;
|
|
2230
|
+
drawnLine.textContent = file + ' \\u00b7 not live';
|
|
2231
|
+
liveNote.textContent = 'The server has stopped, so this page can\\u2019t save or follow ' + file +
|
|
2232
|
+
'. Start it again with reladraw serve ' + file + ' in the same folder, and this page picks the file up again. Export \\u25BE still saves what you typed.';
|
|
2233
|
+
liveNote.hidden = false;
|
|
2234
|
+
};
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
window.addEventListener('beforeunload', function (event) {
|
|
2238
|
+
if (tiedFile() && sourceBox.value !== disk) { event.preventDefault(); event.returnValue = ''; }
|
|
2239
|
+
});
|
|
2240
|
+
|
|
2241
|
+
/* ---- a page with no file: the text kept in this browser ---- */
|
|
2242
|
+
|
|
2243
|
+
// The same names the playground has always kept them under, so a visitor
|
|
2244
|
+
// finds their diagram where they left it.
|
|
2245
|
+
var STORE = 'reladraw.source';
|
|
2246
|
+
var KEPT_TEXT = 'reladraw.kept';
|
|
2247
|
+
var KEPT_NAME = 'reladraw.name';
|
|
2248
|
+
function stored(key) { try { return localStorage.getItem(key); } catch (e) { return null; } }
|
|
2249
|
+
function store(key, value) { try { localStorage.setItem(key, value); } catch (e) {} }
|
|
2250
|
+
|
|
2251
|
+
// The text as last saved or opened, so opening another file asks first only
|
|
2252
|
+
// when there is something to lose; and the example the editor holds
|
|
2253
|
+
// unchanged, or null once it is edited, since an example left alone is one
|
|
2254
|
+
// click away and nothing to lose.
|
|
2255
|
+
var keptText = null;
|
|
2256
|
+
var current = null;
|
|
2257
|
+
var stowing = null;
|
|
2258
|
+
var EXAMPLES = EDIT && EDIT.examples ? EDIT.examples : [];
|
|
2259
|
+
|
|
2260
|
+
function named(text) {
|
|
2261
|
+
for (var i = 0; i < EXAMPLES.length; i++) if (EXAMPLES[i].source === text) return EXAMPLES[i].name;
|
|
2262
|
+
return null;
|
|
2263
|
+
}
|
|
2264
|
+
function unsavedHere() { return sourceBox.value !== keptText && current === null; }
|
|
2265
|
+
|
|
2266
|
+
function edited() {
|
|
2267
|
+
if (EDIT && EDIT.kind !== 'file') {
|
|
2268
|
+
current = named(sourceBox.value);
|
|
2269
|
+
// The stored copy trails the typing: it isn't read until the page is opened again.
|
|
2270
|
+
clearTimeout(stowing);
|
|
2271
|
+
stowing = setTimeout(function () { store(STORE, sourceBox.value); }, 400);
|
|
2272
|
+
}
|
|
2273
|
+
markUnsaved();
|
|
2274
|
+
}
|
|
2275
|
+
function keep(text, name) {
|
|
2276
|
+
keptText = text;
|
|
2277
|
+
fileName = name;
|
|
2278
|
+
store(KEPT_TEXT, text);
|
|
2279
|
+
store(KEPT_NAME, name);
|
|
2280
|
+
if (EDIT.kind === 'hosted') editorPane.querySelector('.file').textContent = name;
|
|
2281
|
+
markUnsaved();
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
// A different diagram opens as a fresh page would, not at the last one's zoom.
|
|
2285
|
+
function openText(text) {
|
|
2286
|
+
load(text);
|
|
2287
|
+
zoom.open();
|
|
2288
|
+
sourceBox.focus();
|
|
2289
|
+
sourceBox.setSelectionRange(0, 0);
|
|
2290
|
+
sourceBox.scrollTop = 0;
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
var fileInput = document.getElementById('file-input');
|
|
2294
|
+
openButton.onclick = function () {
|
|
2295
|
+
if (unsavedHere() && !confirm('Your edits have not been saved to a file. Open another file in their place?')) return;
|
|
2296
|
+
fileInput.value = '';
|
|
2297
|
+
fileInput.click();
|
|
2298
|
+
};
|
|
2299
|
+
fileInput.onchange = function () {
|
|
2300
|
+
var picked = fileInput.files[0];
|
|
2301
|
+
if (!picked) return;
|
|
2302
|
+
picked.text().then(function (text) {
|
|
2303
|
+
keep(text, picked.name);
|
|
2304
|
+
openText(text);
|
|
2305
|
+
});
|
|
2306
|
+
};
|
|
2307
|
+
|
|
2308
|
+
// Chrome and Edge ask for the name and the folder; elsewhere the file goes
|
|
2309
|
+
// where the browser puts downloads, under the suggested name.
|
|
2310
|
+
function saveDownload() {
|
|
2311
|
+
var text = sourceBox.value;
|
|
2312
|
+
function download() {
|
|
2313
|
+
saveBlob(new Blob([text], { type: 'text/plain' }), fileName);
|
|
2314
|
+
keep(text, fileName);
|
|
2315
|
+
}
|
|
2316
|
+
if (!window.showSaveFilePicker) return download();
|
|
2317
|
+
window.showSaveFilePicker({ suggestedName: fileName, types: [{ description: 'reladraw source', accept: { 'text/plain': ['.reladraw'] } }] })
|
|
2318
|
+
.then(function (handle) {
|
|
2319
|
+
return handle.createWritable().then(function (out) {
|
|
2320
|
+
return out.write(text).then(function () { return out.close(); }).then(function () {
|
|
2321
|
+
keep(text, handle.name);
|
|
2322
|
+
flash(writeButton, 'Saved');
|
|
2323
|
+
});
|
|
2324
|
+
});
|
|
2325
|
+
})
|
|
2326
|
+
.catch(function (e) {
|
|
2327
|
+
if (e && e.name === 'AbortError') return;
|
|
2328
|
+
// Refused outright, as inside a cross-origin frame.
|
|
2329
|
+
download();
|
|
2330
|
+
});
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
// Served with no file, the first Save names a file in the folder, typed
|
|
2334
|
+
// rather than browsed for, and asks before replacing one that is there.
|
|
2335
|
+
var saveAs = document.getElementById('save-as');
|
|
2336
|
+
function openSaveAs() {
|
|
2337
|
+
var path = document.getElementById('save-path');
|
|
2338
|
+
document.getElementById('save-problem').textContent = '';
|
|
2339
|
+
if (!path.value) path.value = fileName;
|
|
2340
|
+
saveAs.showModal();
|
|
2341
|
+
path.select();
|
|
2342
|
+
}
|
|
2343
|
+
function create(path, replace) {
|
|
2344
|
+
var text = sourceBox.value;
|
|
2345
|
+
var problem = document.getElementById('save-problem');
|
|
2346
|
+
return fetch('/create?file=' + encodeURIComponent(path), {
|
|
2347
|
+
method: 'POST',
|
|
2348
|
+
body: JSON.stringify({ text: text, replace: !!replace }),
|
|
2349
|
+
}).then(function (answer) {
|
|
2350
|
+
return answer.json().then(function (told) {
|
|
2351
|
+
if (answer.status === 409 && told.exists) {
|
|
2352
|
+
if (confirm(told.file + ' is there already. Replace it with this diagram?')) return create(path, true);
|
|
2353
|
+
return;
|
|
2354
|
+
}
|
|
2355
|
+
if (!answer.ok) { problem.textContent = told.error || 'The server said ' + answer.status; return; }
|
|
2356
|
+
saveAs.close();
|
|
2357
|
+
keep(text, told.file.split('/').pop());
|
|
2358
|
+
history.replaceState(null, '', '/' + told.file.split('/').map(encodeURIComponent).join('/'));
|
|
2359
|
+
tie(told.file, text);
|
|
2360
|
+
flash(writeButton, 'Saved');
|
|
2361
|
+
});
|
|
2362
|
+
}).catch(function (error) {
|
|
2363
|
+
problem.textContent = 'Couldn\\u2019t save: ' + error.message + '. Is reladraw serve still running?';
|
|
2364
|
+
});
|
|
2365
|
+
}
|
|
2366
|
+
if (saveAs) {
|
|
2367
|
+
document.getElementById('save-form').addEventListener('submit', function (event) {
|
|
2368
|
+
event.preventDefault();
|
|
2369
|
+
var path = document.getElementById('save-path').value.trim();
|
|
2370
|
+
if (!path) return;
|
|
2371
|
+
if (!/\\.reladraw$/.test(path)) path += '.reladraw';
|
|
2372
|
+
create(path, false);
|
|
2373
|
+
});
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
/* ---- the examples ---- */
|
|
2377
|
+
|
|
2378
|
+
var examplesBox = document.getElementById('examples');
|
|
2379
|
+
var catalog = document.getElementById('catalog');
|
|
2380
|
+
var preview = document.getElementById('preview');
|
|
2381
|
+
var aboutLine = document.getElementById('about');
|
|
2382
|
+
var chosenExample = 0;
|
|
2383
|
+
var previews = {};
|
|
2384
|
+
|
|
2385
|
+
function pickExample(i) {
|
|
2386
|
+
chosenExample = Math.max(0, Math.min(EXAMPLES.length - 1, i));
|
|
2387
|
+
each(catalog.children, function (item, j) { item.setAttribute('aria-selected', String(j === chosenExample)); });
|
|
2388
|
+
var item = catalog.children[chosenExample];
|
|
2389
|
+
catalog.setAttribute('aria-activedescendant', item.id);
|
|
2390
|
+
item.scrollIntoView({ block: 'nearest' });
|
|
2391
|
+
var example = EXAMPLES[chosenExample];
|
|
2392
|
+
if (!(example.name in previews)) {
|
|
2393
|
+
try { previews[example.name] = lib ? lib.compile(example.source) : ''; } catch (e) { previews[example.name] = ''; }
|
|
2394
|
+
}
|
|
2395
|
+
aboutLine.textContent = example.about || '';
|
|
2396
|
+
preview.innerHTML = previews[example.name];
|
|
2397
|
+
}
|
|
2398
|
+
function openExample() {
|
|
2399
|
+
var example = EXAMPLES[chosenExample];
|
|
2400
|
+
examplesBox.close();
|
|
2401
|
+
if (unsavedHere() && !confirm('Your edits have not been saved to a file. Open ' + example.name + ' in their place?')) return;
|
|
2402
|
+
openText(example.source);
|
|
2403
|
+
}
|
|
2404
|
+
// A click on the backdrop lands on the dialog itself, outside its box.
|
|
2405
|
+
function closeOnBackdrop(box) {
|
|
2406
|
+
box.addEventListener('click', function (event) {
|
|
2407
|
+
if (event.target !== box) return;
|
|
2408
|
+
var r = box.getBoundingClientRect();
|
|
2409
|
+
if (event.clientX < r.left || event.clientX > r.right || event.clientY < r.top || event.clientY > r.bottom) box.close();
|
|
2410
|
+
});
|
|
2411
|
+
each(box.querySelectorAll('[data-close]'), function (button) { button.onclick = function () { box.close(); }; });
|
|
2412
|
+
}
|
|
2413
|
+
if (examplesBox) {
|
|
2414
|
+
EXAMPLES.forEach(function (example, i) {
|
|
2415
|
+
var item = element('li', '', example.name);
|
|
2416
|
+
item.id = 'example-' + i;
|
|
2417
|
+
item.setAttribute('role', 'option');
|
|
2418
|
+
item.onclick = function () { pickExample(i); };
|
|
2419
|
+
item.ondblclick = openExample;
|
|
2420
|
+
catalog.append(item);
|
|
2421
|
+
});
|
|
2422
|
+
browseButton.onclick = function () {
|
|
2423
|
+
var at = 0;
|
|
2424
|
+
EXAMPLES.forEach(function (example, i) { if (example.name === current) at = i; });
|
|
2425
|
+
examplesBox.showModal();
|
|
2426
|
+
pickExample(at);
|
|
2427
|
+
catalog.focus();
|
|
2428
|
+
};
|
|
2429
|
+
catalog.addEventListener('keydown', function (event) {
|
|
2430
|
+
var step = { ArrowDown: 1, ArrowUp: -1, PageDown: 5, PageUp: -5 }[event.key];
|
|
2431
|
+
if (step) pickExample(chosenExample + step);
|
|
2432
|
+
else if (event.key === 'Home') pickExample(0);
|
|
2433
|
+
else if (event.key === 'End') pickExample(EXAMPLES.length - 1);
|
|
2434
|
+
else if (event.key === 'Enter') openExample();
|
|
2435
|
+
else return;
|
|
2436
|
+
event.preventDefault();
|
|
2437
|
+
});
|
|
2438
|
+
document.getElementById('choose').onclick = openExample;
|
|
2439
|
+
closeOnBackdrop(examplesBox);
|
|
2440
|
+
// Every drawing names its arrowheads alike, and a page takes the first of
|
|
2441
|
+
// a name it finds: a preview left in the closed dialog would take the
|
|
2442
|
+
// diagram's arrowheads with it.
|
|
2443
|
+
examplesBox.addEventListener('close', function () { preview.innerHTML = ''; });
|
|
2444
|
+
closeOnBackdrop(saveAs);
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
/* ---- format settings, for a page with no file ---- */
|
|
2448
|
+
|
|
2449
|
+
// The settings as \`name: value\` lines, the way a .reladraw-format holds
|
|
2450
|
+
// them, so the panel, the stored copy and the copied file are one text and
|
|
2451
|
+
// the library's own reader refuses what it would refuse in a file.
|
|
2452
|
+
var FORMAT = 'reladraw.format';
|
|
2453
|
+
var formatPanel = document.getElementById('format-settings');
|
|
2454
|
+
var formatValues = {};
|
|
2455
|
+
(stored(FORMAT) || '').split('\\n').forEach(function (line) {
|
|
2456
|
+
var colon = line.indexOf(':');
|
|
2457
|
+
if (colon > 0) formatValues[line.slice(0, colon).trim()] = line.slice(colon + 1).trim();
|
|
2458
|
+
});
|
|
2459
|
+
function formatText() {
|
|
2460
|
+
if (!lib) return '';
|
|
2461
|
+
return lib.SETTINGS.map(function (setting) {
|
|
2462
|
+
var value = formatValues[setting.name];
|
|
2463
|
+
return setting.name + ': ' + (value === undefined || value === '' ? setting.written : value);
|
|
2464
|
+
}).join('\\n');
|
|
2465
|
+
}
|
|
2466
|
+
function checkSettings() {
|
|
2467
|
+
var problem = document.getElementById('format-problem');
|
|
2468
|
+
try { lib.readFormatSettings(formatText()); problem.textContent = ''; } catch (e) { problem.textContent = e.message; }
|
|
2469
|
+
}
|
|
2470
|
+
// A setting whose values are all words gets a list; one that takes a
|
|
2471
|
+
// number or a list gets a field, written as it would be in the file.
|
|
2472
|
+
function buildFormatFields() {
|
|
2473
|
+
var fields = document.getElementById('format-fields');
|
|
2474
|
+
fields.textContent = '';
|
|
2475
|
+
lib.SETTINGS.forEach(function (setting) {
|
|
2476
|
+
var label = element('label', '');
|
|
2477
|
+
label.append(element('code', '', setting.name));
|
|
2478
|
+
var words = setting.values.every(function (value) { return /^[a-z-]+$/.test(value[0]); });
|
|
2479
|
+
var control;
|
|
2480
|
+
if (words) {
|
|
2481
|
+
control = element('select', '');
|
|
2482
|
+
setting.values.forEach(function (value) {
|
|
2483
|
+
var option = element('option', '', value[0]);
|
|
2484
|
+
option.value = value[0];
|
|
2485
|
+
control.append(option);
|
|
2486
|
+
});
|
|
2487
|
+
} else {
|
|
2488
|
+
control = element('input', '');
|
|
2489
|
+
control.type = 'text';
|
|
2490
|
+
control.spellcheck = false;
|
|
2491
|
+
control.placeholder = setting.written;
|
|
2492
|
+
}
|
|
2493
|
+
control.value = formatValues[setting.name] || setting.written;
|
|
2494
|
+
control.addEventListener(words ? 'change' : 'input', function () {
|
|
2495
|
+
formatValues[setting.name] = control.value.trim();
|
|
2496
|
+
checkSettings();
|
|
2497
|
+
store(FORMAT, formatText());
|
|
2498
|
+
});
|
|
2499
|
+
label.append(control, element('span', '', setting.about.replace(/\\n/g, ' ') + ' ' +
|
|
2500
|
+
setting.values.map(function (value) { return value[0] + ': ' + value[1].replace(/\\n/g, ' '); }).join('; ') + '.'));
|
|
2501
|
+
fields.append(label);
|
|
2502
|
+
});
|
|
2503
|
+
checkSettings();
|
|
2504
|
+
}
|
|
2505
|
+
function openFormatSettings() {
|
|
2506
|
+
libReady.then(function () {
|
|
2507
|
+
buildFormatFields();
|
|
2508
|
+
formatPanel.showModal();
|
|
2509
|
+
});
|
|
2510
|
+
}
|
|
2511
|
+
if (formatPanel) {
|
|
2512
|
+
formatOpen.onclick = openFormatSettings;
|
|
2513
|
+
closeOnBackdrop(formatPanel);
|
|
2514
|
+
document.getElementById('format-reset').onclick = function () {
|
|
2515
|
+
formatValues = {};
|
|
2516
|
+
store(FORMAT, formatText());
|
|
2517
|
+
buildFormatFields();
|
|
2518
|
+
};
|
|
2519
|
+
var formatCopy = document.getElementById('format-copy');
|
|
2520
|
+
formatCopy.onclick = function () {
|
|
2521
|
+
var text = '// .reladraw-format: how reladraw format lays out the diagrams in this folder and below\\n' + formatText() + '\\n';
|
|
2522
|
+
toClipboard(text).then(function () { flash(formatCopy, 'Copied'); }, function () { flash(formatCopy, 'Blocked'); });
|
|
2523
|
+
};
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
/* ---- the start ---- */
|
|
2527
|
+
|
|
2528
|
+
if (EDIT) {
|
|
2529
|
+
editButton.hidden = false;
|
|
2530
|
+
document.getElementById('edit-gap').hidden = false;
|
|
2531
|
+
try {
|
|
2532
|
+
var share = parseFloat(localStorage.getItem(SPLIT));
|
|
2533
|
+
if (share > 0 && share < 1) divide(share, false);
|
|
2534
|
+
} catch (e) {}
|
|
2535
|
+
if (EDIT.kind === 'file') {
|
|
2536
|
+
sourceBox.value = DATA.source;
|
|
2537
|
+
tie(EDIT.file, DATA.source);
|
|
2538
|
+
} else {
|
|
2539
|
+
browseButton.hidden = false;
|
|
2540
|
+
openButton.hidden = false;
|
|
2541
|
+
formatOpen.hidden = false;
|
|
2542
|
+
writeButton.querySelector('.name').textContent = 'Save\\u2026';
|
|
2543
|
+
writeButton.title = EDIT.kind === 'local'
|
|
2544
|
+
? 'Save the source as a file in ' + EDIT.folder + ' (\\u2318S or Ctrl+S)'
|
|
2545
|
+
: 'Save the source as a .reladraw file (\\u2318S or Ctrl+S)';
|
|
2546
|
+
formatButton.title = 'Lay the source out again (Shift+Alt+F)';
|
|
2547
|
+
if (saveAs) saveAs.querySelector('.folder').textContent = EDIT.folder || '';
|
|
2548
|
+
keptText = stored(KEPT_TEXT);
|
|
2549
|
+
fileName = stored(KEPT_NAME) || fileName;
|
|
2550
|
+
editorPane.querySelector('.file').textContent = EDIT.kind === 'local' ? 'not saved to a file' : fileName;
|
|
2551
|
+
var opening = stored(STORE);
|
|
2552
|
+
sourceBox.value = opening !== null ? opening : DATA.source;
|
|
2553
|
+
current = named(sourceBox.value);
|
|
2554
|
+
// The playground opens with the source shown; a file's page with it hidden.
|
|
2555
|
+
toggleEditor(true, true);
|
|
2556
|
+
markUnsaved();
|
|
2557
|
+
}
|
|
2558
|
+
libReady.then(function (loaded) {
|
|
2559
|
+
lib = loaded;
|
|
2560
|
+
paintSource();
|
|
2561
|
+
draw(sourceBox.value);
|
|
2562
|
+
}, function (error) {
|
|
2563
|
+
liveNote.textContent = 'The page couldn\\u2019t load the library it draws with: ' + error.message;
|
|
2564
|
+
liveNote.hidden = false;
|
|
2565
|
+
});
|
|
2566
|
+
}
|
|
2567
|
+
|
|
998
2568
|
layoutSide();
|
|
999
2569
|
zoom.show();
|
|
1000
2570
|
})();
|
|
@@ -1002,3 +2572,173 @@ const PAGE = `<!doctype html>
|
|
|
1002
2572
|
</body>
|
|
1003
2573
|
</html>
|
|
1004
2574
|
`;
|
|
2575
|
+
/** What a page with an editor shows under its source: the language on one screen. */
|
|
2576
|
+
const CHEATSHEET = `
|
|
2577
|
+
<details id="cheatsheet">
|
|
2578
|
+
<summary>Cheatsheet</summary>
|
|
2579
|
+
<dl>
|
|
2580
|
+
<dt>Statements</dt>
|
|
2581
|
+
<dd>
|
|
2582
|
+
<code>node <i>name</i> <i>["text"]</i> <i>[placements]</i> <i>[key: value …]</i></code><br />
|
|
2583
|
+
<code>edge <i>a</i> -> <i>b</i> <i>["text"]</i> <i>[key: value …]</i></code>
|
|
2584
|
+
<i>(<code><-</code> to write the target first, <code><-></code> for both ends)</i><br />
|
|
2585
|
+
<code>point <i>name</i> <i>where</i></code> <code>region <i>name</i> <i>a</i> top-left to <i>b</i> bottom-right</code><br />
|
|
2586
|
+
<code>bundle <i>name</i> via: <i>…</i></code> <i>(a path lines share)</i>
|
|
2587
|
+
<code><b>guide</b> node|edge|point|region …</code> <i>(reference only; turn on Guides in View to see them)</i><br />
|
|
2588
|
+
<code>style <i>name</i> <i>key: value …</i></code><br />
|
|
2589
|
+
<code>diagram theme: nord background: #1e2229 text: (color: #e0e0e0)</code><br />
|
|
2590
|
+
<code>default node|leaf|container|edge <i>key: value …</i></code>
|
|
2591
|
+
<i>(a style every one of that kind wears; the more specific wins)</i><br />
|
|
2592
|
+
<i>The picture is drawn exactly as written: nothing is moved to make room, and lines go only
|
|
2593
|
+
where they are told. Anything that overlaps is drawn as written; the findings beside the diagram report it, as <code>reladraw check</code> does.</i>
|
|
2594
|
+
</dd>
|
|
2595
|
+
|
|
2596
|
+
<dt>Labels</dt>
|
|
2597
|
+
<dd>
|
|
2598
|
+
<code>node parser</code> — no text, so the node takes its name as its text.<br />
|
|
2599
|
+
<code>node parser ""</code> — deliberately blank.<br />
|
|
2600
|
+
<code>"two / lines"</code> — <code> / </code> with spaces either side breaks a line.
|
|
2601
|
+
</dd>
|
|
2602
|
+
|
|
2603
|
+
<dt>Containment</dt>
|
|
2604
|
+
<dd>
|
|
2605
|
+
<code>node server.docker "Docker"</code> — a dotted name is a child. Declare the parent first. With no text of its own, it reads "docker".<br />
|
|
2606
|
+
<code>layout: column</code> or <code>row</code> — the children in written order, with
|
|
2607
|
+
<code>(gap: tight, widths: match, align-children: left)</code>.
|
|
2608
|
+
Without a layout, one child is unplaced and the rest are placed against it.<br />
|
|
2609
|
+
<code>layout: deck</code> — a stack of cards, the first child in front.<br />
|
|
2610
|
+
<code>gather-children: left</code> — where the children sit in spare room.
|
|
2611
|
+
<code>shape: none</code> on a container draws no box.
|
|
2612
|
+
</dd>
|
|
2613
|
+
|
|
2614
|
+
<dt>Placement</dt>
|
|
2615
|
+
<dd>
|
|
2616
|
+
<code><b>above</b> X</code> <code><b>below</b> X</code>
|
|
2617
|
+
<code><b>left of</b> X</code> <code><b>right of</b> X</code>
|
|
2618
|
+
<code><b>above-left of</b> X</code> … <code><b>below-right of</b> X</code><br />
|
|
2619
|
+
<code><b>even with</b> X</code> — share a center line;
|
|
2620
|
+
<code><b>top</b>|<b>bottom</b>|<b>left</b>|<b>right</b> <b>even with</b> X</code> shares that edge.<br />
|
|
2621
|
+
<code>right of A <b>and</b> B</code> — against the box that just bounds them both.<br />
|
|
2622
|
+
<code><b>inside</b> X top-right</code> <code><b>outside</b> X right</code>
|
|
2623
|
+
<code><b>on</b> X bottom-left</code> — against a part of X. The parts are
|
|
2624
|
+
<code>text</code>, the sides <code>top</code> <code>bottom</code> <code>left</code> <code>right</code>,
|
|
2625
|
+
and <code>top-left</code> <code>top-center</code> <code>top-right</code>
|
|
2626
|
+
<code>left-center</code> <code>center</code> <code>right-center</code>
|
|
2627
|
+
<code>bottom-left</code> <code>bottom-center</code> <code>bottom-right</code>.<br />
|
|
2628
|
+
<code><b>on</b> between A and B</code> <code>below down from A and left from B</code> — at a point.<br />
|
|
2629
|
+
<i>One node is unplaced; every other is placed against something. One direction on its own
|
|
2630
|
+
also centers on its target. Each axis is said once.</i>
|
|
2631
|
+
</dd>
|
|
2632
|
+
|
|
2633
|
+
<dt>Gaps</dt>
|
|
2634
|
+
<dd>
|
|
2635
|
+
<code>right of X <b>(gap: wide)</b></code> or <code><b>2</b> right of X</code>, on the placement.
|
|
2636
|
+
One of <code>none</code> <code>tight</code> <code>normal</code> <code>wide</code> (0, 2, 4, 8 units),
|
|
2637
|
+
or a number of units; a box is about 4 units tall. A gap is <i>exact</i>.
|
|
2638
|
+
</dd>
|
|
2639
|
+
|
|
2640
|
+
<dt>Sizes</dt>
|
|
2641
|
+
<dd>
|
|
2642
|
+
<code>width: 30</code> <code>height: menu</code> <code>height: 0.5 menu height</code>
|
|
2643
|
+
<code>height: a top to c bottom</code> <code>min-width: 10</code>
|
|
2644
|
+
</dd>
|
|
2645
|
+
|
|
2646
|
+
<dt>Appearance</dt>
|
|
2647
|
+
<dd>
|
|
2648
|
+
<code>style: <i>name</i></code>
|
|
2649
|
+
<code>fill: #191728</code>
|
|
2650
|
+
<code>border: none</code>
|
|
2651
|
+
<code>corners: sharp</code> <i>(a box; rounded unless told)</i>
|
|
2652
|
+
<code>line: #d2904e</code> <i>(an edge)</i>
|
|
2653
|
+
<code>overlap: allow</code>
|
|
2654
|
+
<code>url: "https://example.com"</code>
|
|
2655
|
+
<code>description: "What it does"</code> <i>(shown on the page; never drawn)</i>
|
|
2656
|
+
</dd>
|
|
2657
|
+
|
|
2658
|
+
<dt>Lines</dt>
|
|
2659
|
+
<dd>
|
|
2660
|
+
<code>line: (path: square, corners: rounded)</code> — everything about an edge's line.
|
|
2661
|
+
<code>path:</code> takes <code>straight</code> (the default) <code>square</code> <code>curved</code>;
|
|
2662
|
+
<code>corners:</code> <code>sharp</code> <code>rounded</code>;
|
|
2663
|
+
<code>crossing:</code> <code>gap</code> <code>arc</code> <code>square</code> <code>none</code>;
|
|
2664
|
+
<code>pattern:</code> <code>solid</code> <code>dashed</code> <code>dotted</code> <code>dash-dot</code>;
|
|
2665
|
+
<code>thickness:</code> <code>thin</code> <code>normal</code> <code>thick</code>, a multiple, or <code>3px</code>;
|
|
2666
|
+
<code>turn: 1/3</code> <code>step-out: tight</code> <code>curve: subtle</code>.
|
|
2667
|
+
<i><code>default edge line: (path: square)</code> sets every edge.</i>
|
|
2668
|
+
</dd>
|
|
2669
|
+
|
|
2670
|
+
<dt>Body</dt>
|
|
2671
|
+
<dd>
|
|
2672
|
+
<code>shape: rectangle|document|circle|none</code> — the outline the node is drawn with;
|
|
2673
|
+
<code>none</code> is text with no box, which is what an annotation is.<br />
|
|
2674
|
+
<code>icon: <i>name</i></code> — the node <i>is</i> that picture, with no box.<br />
|
|
2675
|
+
<code>badge: <i>name</i></code> — the picture is part of the label, beside the text, and the node keeps its box.<br />
|
|
2676
|
+
<code>layout: deck cards: "text" <i>["text" …]</i></code> — a stack of cards, front first, one per text.<br />
|
|
2677
|
+
Pictures: <code>disk</code> <code>desktop</code> <code>laptop</code>
|
|
2678
|
+
<code>package</code> <code>cubes</code> <code>cube</code> <code>database</code>.<br />
|
|
2679
|
+
Your own: <code>icon <i>name</i> """<svg …>…</svg>"""</code> declares one,
|
|
2680
|
+
and <code>icon: <i>name</i></code> or <code>badge: <i>name</i></code> uses it.
|
|
2681
|
+
</dd>
|
|
2682
|
+
|
|
2683
|
+
<dt>Text</dt>
|
|
2684
|
+
<dd>
|
|
2685
|
+
Everything a text says about itself goes in brackets after it:
|
|
2686
|
+
<code>(color: theme-muted)</code>
|
|
2687
|
+
<code>(size: small|normal|large)</code>
|
|
2688
|
+
<code>(wrap: 24)</code> <i>(characters, folds the text)</i>.
|
|
2689
|
+
A title says which side it sits against: <code>"Server" (inside top, align: left)</code>,
|
|
2690
|
+
<code>(on top)</code>, <code>(outside top)</code>, <code>(center)</code>.
|
|
2691
|
+
In a style, which has no text of its own, the bracket hangs off the key:
|
|
2692
|
+
<code>text: (color: theme-muted)</code>.<br />
|
|
2693
|
+
<code>" / "</code> in any text is a line break; <code>\\/</code> is a literal slash.<br />
|
|
2694
|
+
<code>"name / <b>[dim]</b>quieter<b>[/dim]</b>"</code> draws those words in the
|
|
2695
|
+
text color of <code>style dim</code>; <code>\\[</code> is a literal bracket.
|
|
2696
|
+
</dd>
|
|
2697
|
+
|
|
2698
|
+
<dt>Links</dt>
|
|
2699
|
+
<dd>
|
|
2700
|
+
A line is straight between its two boxes, through anything in between, unless told otherwise.<br />
|
|
2701
|
+
<code>from: right</code> <code>to: left</code> name a side;
|
|
2702
|
+
<code>to: bottom-center</code> or <code>to: left 3/4 down</code> a point on it.<br />
|
|
2703
|
+
<code>via: c top, between a and b</code> — the places the line passes, in order:
|
|
2704
|
+
just outside a side, a point, a box's center, <code>through <i>region</i> horizontal</code>.<br />
|
|
2705
|
+
<code>"text" (at: start)</code> <code>(at: 1/4)</code> <code>(above edge)</code>
|
|
2706
|
+
<code>(on: longest level)</code> — where a line's text sits; the middle by default.
|
|
2707
|
+
</dd>
|
|
2708
|
+
|
|
2709
|
+
<dt>Comments</dt>
|
|
2710
|
+
<dd><code>// to the end of the line</code>, anywhere, including after a statement.</dd>
|
|
2711
|
+
</dl>
|
|
2712
|
+
</details>
|
|
2713
|
+
`;
|
|
2714
|
+
/** Where a page with no file sends someone who wants more than the cheatsheet. */
|
|
2715
|
+
const LINKS = `<nav class="links"><a href="https://github.com/reladraw/reladraw">GitHub</a><a href="https://github.com/reladraw/reladraw/blob/main/SYNTAX.md">Syntax</a></nav>`;
|
|
2716
|
+
/**
|
|
2717
|
+
* A page with no file's dialogs: the examples, the format settings, which a
|
|
2718
|
+
* file's page takes from its nearest .reladraw-format instead, and, served,
|
|
2719
|
+
* the name of the file a first Save writes.
|
|
2720
|
+
*/
|
|
2721
|
+
const DIALOGS = `
|
|
2722
|
+
<dialog id="examples" aria-label="Examples">
|
|
2723
|
+
<div class="bar"><h2>Examples</h2><button type="button" data-close>Close</button></div>
|
|
2724
|
+
<ul id="catalog" role="listbox" tabindex="0" aria-label="Examples"></ul>
|
|
2725
|
+
<p id="about" aria-live="polite"></p>
|
|
2726
|
+
<div id="preview"></div>
|
|
2727
|
+
<footer><span class="keys"><kbd>↑</kbd> <kbd>↓</kbd> to preview · <kbd>Enter</kbd> to open · <kbd>Esc</kbd> to close</span><span class="gap"></span><button type="button" id="choose">Open in editor</button></footer>
|
|
2728
|
+
</dialog>
|
|
2729
|
+
<dialog id="format-settings" aria-label="Format settings">
|
|
2730
|
+
<div class="bar"><h2>Format settings</h2><button type="button" data-close>Close</button></div>
|
|
2731
|
+
<p class="about">How Format lays the source out. These are remembered in this browser only; for a project, put them in a <code>.reladraw-format</code> file beside its diagrams.</p>
|
|
2732
|
+
<form id="format-fields"></form>
|
|
2733
|
+
<p id="format-problem" role="alert"></p>
|
|
2734
|
+
<footer><button type="button" id="format-reset">Reset to defaults</button><span class="gap"></span><button type="button" id="format-copy" title="Copy these settings as the text of a .reladraw-format file">Copy as .reladraw-format</button></footer>
|
|
2735
|
+
</dialog>
|
|
2736
|
+
<dialog id="save-as" aria-label="Save as a file">
|
|
2737
|
+
<form method="dialog" id="save-form">
|
|
2738
|
+
<div class="bar"><h2>Save as a file in <span class="folder"></span></h2></div>
|
|
2739
|
+
<p class="about">A path inside that folder, such as <code>diagram.reladraw</code> or <code>docs/flow.reladraw</code>. If the file is there already, you'll be asked before it's replaced. From then on this page is tied to the file.</p>
|
|
2740
|
+
<label>Path <input id="save-path" type="text" spellcheck="false" autocomplete="off" required></label>
|
|
2741
|
+
<p id="save-problem" role="alert"></p>
|
|
2742
|
+
<footer><span class="gap"></span><button type="button" data-close>Cancel</button><button type="submit" id="save-go">Save</button></footer>
|
|
2743
|
+
</form>
|
|
2744
|
+
</dialog>`;
|