reladraw 0.20.0 → 0.21.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/README.md +2 -2
- package/SYNTAX.md +31 -4
- package/dist/ast.d.ts +9 -2
- package/dist/ast.js +9 -2
- package/dist/cli.js +35 -12
- package/dist/constants.d.ts +4 -0
- package/dist/constants.js +8 -0
- package/dist/constrain.d.ts +56 -0
- package/dist/constrain.js +95 -0
- package/dist/model.d.ts +13 -0
- package/dist/model.js +7 -0
- package/dist/page.d.ts +14 -0
- package/dist/page.js +488 -0
- package/dist/parser.js +21 -3
- package/dist/place.d.ts +19 -0
- package/dist/place.js +67 -8
- package/dist/render.d.ts +17 -0
- package/dist/render.js +101 -29
- package/dist/resolve.d.ts +14 -0
- package/dist/resolve.js +33 -7
- package/dist/themes.d.ts +1 -0
- package/dist/themes.js +1 -1
- package/package.json +1 -1
package/dist/page.js
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
import { fileChecks, findings } from './checks.js';
|
|
2
|
+
import { SourceError } from './errors.js';
|
|
3
|
+
import { blame, parse } from './parser.js';
|
|
4
|
+
import { renderTagged, themeOf } from './render.js';
|
|
5
|
+
import { resolve } from './resolve.js';
|
|
6
|
+
import { luminance } from './themes.js';
|
|
7
|
+
/**
|
|
8
|
+
* `reladraw render --page`: one diagram in one self-contained HTML page, made
|
|
9
|
+
* for looking at rather than embedding. The page is the diagram's own colors,
|
|
10
|
+
* headed with its title, and it pans, zooms, goes full screen, saves the SVG
|
|
11
|
+
* or a PNG, lists the findings, and names a box under the pointer. It needs
|
|
12
|
+
* nothing running and fetches nothing, so it can be attached, mailed or
|
|
13
|
+
* committed. `name` is the file's name, the title when the file states none.
|
|
14
|
+
*/
|
|
15
|
+
export function drawPage(source, name, options = {}) {
|
|
16
|
+
const run = (text) => {
|
|
17
|
+
const layout = resolve(parse(text), options);
|
|
18
|
+
const { svg, tagged } = renderTagged(layout, options);
|
|
19
|
+
const found = options.checks === false ? [] : findings(layout, options.checks ? options.checks(fileChecks(layout)) : fileChecks(layout));
|
|
20
|
+
const title = layout.diagram['title'] ?? name;
|
|
21
|
+
return { html: page({ title, name, svg, tagged, findings: found, theme: themeOf(layout, options) }), findings: found };
|
|
22
|
+
};
|
|
23
|
+
try {
|
|
24
|
+
return run(source);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
throw error instanceof SourceError ? blame(source, error, run) : error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function page({ title, name, svg, tagged, findings, theme }) {
|
|
31
|
+
const hex = (color) => /^#[0-9a-f]{6}$/i.test(color);
|
|
32
|
+
// The browser's own controls (scroll bars, focus rings) follow the page, so
|
|
33
|
+
// a dark diagram doesn't get a light one's. A color written by hand in a
|
|
34
|
+
// form that can't be measured leaves the choice to the browser.
|
|
35
|
+
const scheme = hex(theme.text) && hex(theme.background) ? (luminance(theme.text) > luminance(theme.background) ? 'dark' : 'light') : 'light dark';
|
|
36
|
+
const data = {
|
|
37
|
+
svg,
|
|
38
|
+
name,
|
|
39
|
+
findings: findings.map((finding) => ({ kind: finding.kind, message: finding.message, line: finding.line })),
|
|
40
|
+
};
|
|
41
|
+
// Replaced by functions, so a `$` in a title or a drawing is taken as written.
|
|
42
|
+
return PAGE.replace('{{scheme}}', () => scheme)
|
|
43
|
+
.replace('{{title}}', () => escapeHtml(title))
|
|
44
|
+
.replace('{{heading}}', () => escapeHtml(title))
|
|
45
|
+
.replace('{{colors}}', () => [
|
|
46
|
+
`--page: ${theme.background};`,
|
|
47
|
+
`--ink: ${theme.text};`,
|
|
48
|
+
`--muted: ${theme.mutedText};`,
|
|
49
|
+
`--rule: ${theme.boxStroke};`,
|
|
50
|
+
`--accent: ${theme.primary.color};`,
|
|
51
|
+
`--on-accent: ${theme.primary.on};`,
|
|
52
|
+
].join(' '))
|
|
53
|
+
.replace('{{drawing}}', () => tagged)
|
|
54
|
+
.replace('{{data}}', () => scriptJson(data));
|
|
55
|
+
}
|
|
56
|
+
function escapeHtml(text) {
|
|
57
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
58
|
+
}
|
|
59
|
+
/** JSON that can sit inside a `<script>` without ending it. */
|
|
60
|
+
function scriptJson(value) {
|
|
61
|
+
return JSON.stringify(value)
|
|
62
|
+
.replace(/</g, '\\u003c');
|
|
63
|
+
}
|
|
64
|
+
const PAGE = `<!doctype html>
|
|
65
|
+
<html lang="en">
|
|
66
|
+
<head>
|
|
67
|
+
<meta charset="utf-8">
|
|
68
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
69
|
+
<meta name="color-scheme" content="{{scheme}}">
|
|
70
|
+
<meta name="generator" content="reladraw">
|
|
71
|
+
<title>{{title}}</title>
|
|
72
|
+
<style>
|
|
73
|
+
:root { {{colors}} }
|
|
74
|
+
* { box-sizing: border-box; }
|
|
75
|
+
html, body { margin: 0; height: 100%; }
|
|
76
|
+
body {
|
|
77
|
+
display: flex; flex-direction: column; height: 100vh; height: 100dvh;
|
|
78
|
+
background: var(--page); color: var(--ink);
|
|
79
|
+
font: 14px/1.4 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
|
80
|
+
}
|
|
81
|
+
header {
|
|
82
|
+
display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px;
|
|
83
|
+
padding: 10px 16px;
|
|
84
|
+
border-bottom: 1px solid color-mix(in srgb, var(--rule) 45%, transparent);
|
|
85
|
+
}
|
|
86
|
+
h1 { flex: 1 1 16rem; margin: 0; font-size: 16px; font-weight: 600; overflow-wrap: anywhere; }
|
|
87
|
+
.tools { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
|
88
|
+
.tools .gap { width: 8px; }
|
|
89
|
+
button {
|
|
90
|
+
font: inherit; font-size: 13px; color: var(--ink); background: transparent;
|
|
91
|
+
border: 1px solid color-mix(in srgb, var(--rule) 70%, transparent); border-radius: 6px;
|
|
92
|
+
padding: 4px 10px; cursor: pointer; white-space: nowrap;
|
|
93
|
+
}
|
|
94
|
+
button:hover { background: color-mix(in srgb, var(--ink) 10%, transparent); }
|
|
95
|
+
button:disabled { opacity: 0.45; cursor: default; background: transparent; }
|
|
96
|
+
button[aria-pressed="true"] { background: color-mix(in srgb, var(--accent) 25%, transparent); border-color: var(--accent); }
|
|
97
|
+
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
98
|
+
#scale { min-width: 3.5em; text-align: center; color: var(--muted); font-variant-numeric: tabular-nums; font-size: 13px; }
|
|
99
|
+
main { position: relative; flex: 1; display: flex; min-height: 0; }
|
|
100
|
+
#view { position: relative; flex: 1; overflow: hidden; touch-action: none; cursor: grab; }
|
|
101
|
+
#view.dragging { cursor: grabbing; }
|
|
102
|
+
#stage { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
|
|
103
|
+
#stage svg { display: block; }
|
|
104
|
+
#view g[data-name].hover { filter: drop-shadow(0 0 3px var(--accent)); }
|
|
105
|
+
#view g[data-name].lit { filter: drop-shadow(0 0 2px var(--accent)) drop-shadow(0 0 6px var(--accent)); animation: lit 0.6s ease-in-out 3 alternate; }
|
|
106
|
+
@keyframes lit { from { opacity: 1; } to { opacity: 0.45; } }
|
|
107
|
+
#tip {
|
|
108
|
+
position: absolute; pointer-events: none; display: none; z-index: 2;
|
|
109
|
+
padding: 2px 8px; border-radius: 4px; font-size: 12px; white-space: nowrap;
|
|
110
|
+
background: var(--accent); color: var(--on-accent);
|
|
111
|
+
}
|
|
112
|
+
aside {
|
|
113
|
+
width: min(26rem, 38vw); display: flex; flex-direction: column; min-height: 0;
|
|
114
|
+
border-left: 1px solid color-mix(in srgb, var(--rule) 45%, transparent);
|
|
115
|
+
}
|
|
116
|
+
aside[hidden] { display: none; }
|
|
117
|
+
aside h2 { margin: 0; padding: 10px 16px 6px; font-size: 13px; font-weight: 600; }
|
|
118
|
+
aside h2 .what { display: block; font-weight: 400; font-size: 12px; color: var(--muted); }
|
|
119
|
+
aside ol { list-style: none; margin: 0; padding: 0 8px 12px; overflow: auto; }
|
|
120
|
+
aside li button {
|
|
121
|
+
display: block; width: 100%; text-align: left; white-space: normal; border-color: transparent;
|
|
122
|
+
padding: 6px 8px; margin: 2px 0;
|
|
123
|
+
}
|
|
124
|
+
aside li button.chosen { border-color: var(--accent); }
|
|
125
|
+
.where { color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
126
|
+
.kind { color: var(--accent); }
|
|
127
|
+
.note { display: block; color: var(--muted); font-size: 12px; margin-top: 2px; }
|
|
128
|
+
@media (max-width: 700px) {
|
|
129
|
+
main { flex-direction: column; }
|
|
130
|
+
aside { width: auto; max-height: 40%; border-left: 0; border-top: 1px solid color-mix(in srgb, var(--rule) 45%, transparent); }
|
|
131
|
+
}
|
|
132
|
+
</style>
|
|
133
|
+
</head>
|
|
134
|
+
<body>
|
|
135
|
+
<header>
|
|
136
|
+
<h1>{{heading}}</h1>
|
|
137
|
+
<div class="tools">
|
|
138
|
+
<button id="out" type="button" title="Zoom out (-)" aria-label="Zoom out">−</button>
|
|
139
|
+
<span id="scale" aria-live="polite">100%</span>
|
|
140
|
+
<button id="in" type="button" title="Zoom in (+)" aria-label="Zoom in">+</button>
|
|
141
|
+
<button id="fit" type="button" title="The whole diagram in view (0)">Fit</button>
|
|
142
|
+
<button id="actual" type="button" title="One pixel of the drawing to one of the screen (1)">Actual size</button>
|
|
143
|
+
<span class="gap"></span>
|
|
144
|
+
<button id="full" type="button" title="Full screen (f)">Full screen</button>
|
|
145
|
+
<button id="save-svg" type="button">Save SVG</button>
|
|
146
|
+
<button id="save-png" type="button">Save PNG</button>
|
|
147
|
+
<button id="toggle-findings" type="button" aria-controls="findings"></button>
|
|
148
|
+
</div>
|
|
149
|
+
</header>
|
|
150
|
+
<main>
|
|
151
|
+
<div id="view">
|
|
152
|
+
<div id="stage">
|
|
153
|
+
{{drawing}}
|
|
154
|
+
</div>
|
|
155
|
+
<div id="tip"></div>
|
|
156
|
+
</div>
|
|
157
|
+
<aside id="findings" aria-label="Findings">
|
|
158
|
+
<h2>Findings <span class="what">where the picture, drawn as written, may not read as meant</span></h2>
|
|
159
|
+
<ol id="finding-list"></ol>
|
|
160
|
+
</aside>
|
|
161
|
+
</main>
|
|
162
|
+
<script>
|
|
163
|
+
(function () {
|
|
164
|
+
var DATA = {{data}};
|
|
165
|
+
var view = document.getElementById('view');
|
|
166
|
+
var stage = document.getElementById('stage');
|
|
167
|
+
var svg = stage.querySelector('svg');
|
|
168
|
+
var tip = document.getElementById('tip');
|
|
169
|
+
var scaleLabel = document.getElementById('scale');
|
|
170
|
+
var box = svg.viewBox.baseVal;
|
|
171
|
+
var natural = { w: box.width, h: box.height };
|
|
172
|
+
|
|
173
|
+
// The drawing is resized rather than scaled with a transform, so the
|
|
174
|
+
// browser draws it afresh at every size and it stays sharp.
|
|
175
|
+
var at = { x: 0, y: 0, s: 1 };
|
|
176
|
+
// Until the reader pans or zooms, a change of window size redraws the view
|
|
177
|
+
// the way it was asked for: as it opened, or fitted.
|
|
178
|
+
var fitted = 'open';
|
|
179
|
+
var MIN = 0.05;
|
|
180
|
+
var MAX = 20;
|
|
181
|
+
|
|
182
|
+
function apply() {
|
|
183
|
+
svg.setAttribute('width', natural.w * at.s);
|
|
184
|
+
svg.setAttribute('height', natural.h * at.s);
|
|
185
|
+
stage.style.transform = 'translate(' + at.x + 'px,' + at.y + 'px)';
|
|
186
|
+
scaleLabel.textContent = Math.round(at.s * 100) + '%';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function size() {
|
|
190
|
+
return { w: view.clientWidth, h: view.clientHeight };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function center(s) {
|
|
194
|
+
var room = size();
|
|
195
|
+
at = { s: s, x: (room.w - natural.w * s) / 2, y: (room.h - natural.h * s) / 2 };
|
|
196
|
+
apply();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function fitScale() {
|
|
200
|
+
var room = size();
|
|
201
|
+
return Math.max(MIN, Math.min((room.w - 32) / natural.w, (room.h - 32) / natural.h));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function fit() {
|
|
205
|
+
fitted = 'fit';
|
|
206
|
+
center(fitScale());
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// A diagram smaller than the window opens at its own size; a larger one
|
|
210
|
+
// opens fitted.
|
|
211
|
+
function open() {
|
|
212
|
+
fitted = 'open';
|
|
213
|
+
center(Math.min(1, fitScale()));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function refit() {
|
|
217
|
+
if (fitted === 'fit') fit();
|
|
218
|
+
else if (fitted === 'open') open();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Zoom about a point of the view, which stays where it is on screen.
|
|
222
|
+
function zoomAt(factor, px, py) {
|
|
223
|
+
var s = Math.min(MAX, Math.max(MIN, at.s * factor));
|
|
224
|
+
var k = s / at.s;
|
|
225
|
+
at = { s: s, x: px - (px - at.x) * k, y: py - (py - at.y) * k };
|
|
226
|
+
fitted = false;
|
|
227
|
+
apply();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function zoomMiddle(factor) {
|
|
231
|
+
var room = size();
|
|
232
|
+
zoomAt(factor, room.w / 2, room.h / 2);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
document.getElementById('in').onclick = function () { zoomMiddle(1.25); };
|
|
236
|
+
document.getElementById('out').onclick = function () { zoomMiddle(0.8); };
|
|
237
|
+
document.getElementById('fit').onclick = fit;
|
|
238
|
+
document.getElementById('actual').onclick = function () {
|
|
239
|
+
fitted = false;
|
|
240
|
+
center(1);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
view.addEventListener('wheel', function (event) {
|
|
244
|
+
event.preventDefault();
|
|
245
|
+
var rect = view.getBoundingClientRect();
|
|
246
|
+
// A pinch on a trackpad arrives as a wheel with ctrl held, in small steps.
|
|
247
|
+
var step = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
|
|
248
|
+
zoomAt(Math.exp(-step * (event.ctrlKey ? 0.01 : 0.0015)), event.clientX - rect.left, event.clientY - rect.top);
|
|
249
|
+
}, { passive: false });
|
|
250
|
+
|
|
251
|
+
// Dragging pans; two fingers pinch.
|
|
252
|
+
var pointers = new Map();
|
|
253
|
+
var pinch = null;
|
|
254
|
+
view.addEventListener('pointerdown', function (event) {
|
|
255
|
+
if (event.button !== 0) return;
|
|
256
|
+
if (event.target.closest('a')) return;
|
|
257
|
+
view.setPointerCapture(event.pointerId);
|
|
258
|
+
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
259
|
+
if (pointers.size === 2) {
|
|
260
|
+
var two = Array.from(pointers.values());
|
|
261
|
+
pinch = Math.hypot(two[0].x - two[1].x, two[0].y - two[1].y);
|
|
262
|
+
}
|
|
263
|
+
view.classList.add('dragging');
|
|
264
|
+
});
|
|
265
|
+
view.addEventListener('pointermove', function (event) {
|
|
266
|
+
var last = pointers.get(event.pointerId);
|
|
267
|
+
if (!last) return;
|
|
268
|
+
var now = { x: event.clientX, y: event.clientY };
|
|
269
|
+
pointers.set(event.pointerId, now);
|
|
270
|
+
if (pointers.size === 2 && pinch) {
|
|
271
|
+
var two = Array.from(pointers.values());
|
|
272
|
+
var apart = Math.hypot(two[0].x - two[1].x, two[0].y - two[1].y);
|
|
273
|
+
var rect = view.getBoundingClientRect();
|
|
274
|
+
zoomAt(apart / pinch, (two[0].x + two[1].x) / 2 - rect.left, (two[0].y + two[1].y) / 2 - rect.top);
|
|
275
|
+
pinch = apart;
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
at.x += now.x - last.x;
|
|
279
|
+
at.y += now.y - last.y;
|
|
280
|
+
fitted = false;
|
|
281
|
+
apply();
|
|
282
|
+
});
|
|
283
|
+
function release(event) {
|
|
284
|
+
pointers.delete(event.pointerId);
|
|
285
|
+
if (pointers.size < 2) pinch = null;
|
|
286
|
+
if (pointers.size === 0) view.classList.remove('dragging');
|
|
287
|
+
}
|
|
288
|
+
view.addEventListener('pointerup', release);
|
|
289
|
+
view.addEventListener('pointercancel', release);
|
|
290
|
+
view.addEventListener('dblclick', function (event) {
|
|
291
|
+
var rect = view.getBoundingClientRect();
|
|
292
|
+
zoomAt(event.shiftKey ? 0.5 : 2, event.clientX - rect.left, event.clientY - rect.top);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
window.addEventListener('resize', refit);
|
|
296
|
+
|
|
297
|
+
/* ---- full screen ---- */
|
|
298
|
+
|
|
299
|
+
var full = document.getElementById('full');
|
|
300
|
+
if (!document.fullscreenEnabled) full.hidden = true;
|
|
301
|
+
function toggleFull() {
|
|
302
|
+
if (document.fullscreenElement) document.exitFullscreen();
|
|
303
|
+
else document.documentElement.requestFullscreen();
|
|
304
|
+
}
|
|
305
|
+
full.onclick = toggleFull;
|
|
306
|
+
document.addEventListener('fullscreenchange', function () {
|
|
307
|
+
full.textContent = document.fullscreenElement ? 'Exit full screen' : 'Full screen';
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
/* ---- saving ---- */
|
|
311
|
+
|
|
312
|
+
function saveBlob(blob, name) {
|
|
313
|
+
var url = URL.createObjectURL(blob);
|
|
314
|
+
var link = document.createElement('a');
|
|
315
|
+
link.href = url;
|
|
316
|
+
link.download = name;
|
|
317
|
+
document.body.appendChild(link);
|
|
318
|
+
link.click();
|
|
319
|
+
link.remove();
|
|
320
|
+
// A tick, so the navigation has the URL before it is taken back.
|
|
321
|
+
setTimeout(function () { URL.revokeObjectURL(url); }, 0);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function flash(button, text) {
|
|
325
|
+
var was = button.textContent;
|
|
326
|
+
button.textContent = text;
|
|
327
|
+
setTimeout(function () { button.textContent = was; }, 1200);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
document.getElementById('save-svg').onclick = function () {
|
|
331
|
+
saveBlob(new Blob([DATA.svg], { type: 'image/svg+xml' }), DATA.name + '.svg');
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
// The browser draws the SVG onto a canvas with the fonts the page shows it
|
|
335
|
+
// in, twice the size so it stays sharp on a high-density screen. A data URL
|
|
336
|
+
// rather than a blob one, which some browsers count as foreign and refuse
|
|
337
|
+
// to let the canvas be read back.
|
|
338
|
+
var savePng = document.getElementById('save-png');
|
|
339
|
+
savePng.onclick = function () {
|
|
340
|
+
var image = new Image();
|
|
341
|
+
image.onload = function () {
|
|
342
|
+
var canvas = document.createElement('canvas');
|
|
343
|
+
canvas.width = natural.w * 2;
|
|
344
|
+
canvas.height = natural.h * 2;
|
|
345
|
+
canvas.getContext('2d').drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
346
|
+
canvas.toBlob(function (blob) {
|
|
347
|
+
blob ? saveBlob(blob, DATA.name + '.png') : flash(savePng, 'Failed');
|
|
348
|
+
}, 'image/png');
|
|
349
|
+
};
|
|
350
|
+
image.onerror = function () { flash(savePng, 'Failed'); };
|
|
351
|
+
image.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(DATA.svg);
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
/* ---- a thing's name under the pointer ---- */
|
|
355
|
+
|
|
356
|
+
var hovered = null;
|
|
357
|
+
view.addEventListener('pointerover', function (event) {
|
|
358
|
+
var group = event.target.closest && event.target.closest('g[data-name]');
|
|
359
|
+
if (group === hovered) return;
|
|
360
|
+
if (hovered) hovered.classList.remove('hover');
|
|
361
|
+
hovered = group;
|
|
362
|
+
if (!group) {
|
|
363
|
+
tip.style.display = 'none';
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
group.classList.add('hover');
|
|
367
|
+
tip.textContent = group.getAttribute('data-name');
|
|
368
|
+
tip.style.display = 'block';
|
|
369
|
+
});
|
|
370
|
+
view.addEventListener('pointermove', function (event) {
|
|
371
|
+
if (!hovered) return;
|
|
372
|
+
var rect = view.getBoundingClientRect();
|
|
373
|
+
var x = event.clientX - rect.left + 14;
|
|
374
|
+
var y = event.clientY - rect.top + 16;
|
|
375
|
+
// Kept inside the view, so a name near the right edge isn't cut off.
|
|
376
|
+
x = Math.min(x, rect.width - tip.offsetWidth - 4);
|
|
377
|
+
y = Math.min(y, rect.height - tip.offsetHeight - 4);
|
|
378
|
+
tip.style.left = x + 'px';
|
|
379
|
+
tip.style.top = y + 'px';
|
|
380
|
+
});
|
|
381
|
+
view.addEventListener('pointerleave', function () {
|
|
382
|
+
if (hovered) hovered.classList.remove('hover');
|
|
383
|
+
hovered = null;
|
|
384
|
+
tip.style.display = 'none';
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
/* ---- findings ---- */
|
|
388
|
+
|
|
389
|
+
var panel = document.getElementById('findings');
|
|
390
|
+
var list = document.getElementById('finding-list');
|
|
391
|
+
var toggle = document.getElementById('toggle-findings');
|
|
392
|
+
var count = DATA.findings.length;
|
|
393
|
+
|
|
394
|
+
function showPanel(open) {
|
|
395
|
+
panel.hidden = !open;
|
|
396
|
+
toggle.setAttribute('aria-pressed', String(open));
|
|
397
|
+
refit();
|
|
398
|
+
}
|
|
399
|
+
if (count === 0) {
|
|
400
|
+
toggle.textContent = 'No findings';
|
|
401
|
+
toggle.disabled = true;
|
|
402
|
+
panel.hidden = true;
|
|
403
|
+
} else {
|
|
404
|
+
toggle.textContent = count === 1 ? '1 finding' : count + ' findings';
|
|
405
|
+
toggle.onclick = function () { showPanel(panel.hidden); };
|
|
406
|
+
showPanel(true);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
var lit = [];
|
|
410
|
+
var chosen = null;
|
|
411
|
+
function unlight() {
|
|
412
|
+
lit.forEach(function (group) { group.classList.remove('lit'); });
|
|
413
|
+
lit = [];
|
|
414
|
+
if (chosen) chosen.classList.remove('chosen');
|
|
415
|
+
chosen = null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// A finding names the line of the statement at fault; what that statement
|
|
419
|
+
// drew lights up, and the view moves to it if it is out of sight.
|
|
420
|
+
function light(finding, button) {
|
|
421
|
+
unlight();
|
|
422
|
+
chosen = button;
|
|
423
|
+
button.classList.add('chosen');
|
|
424
|
+
lit = Array.from(svg.querySelectorAll('g[data-line="' + finding.line + '"]'));
|
|
425
|
+
lit.forEach(function (group) {
|
|
426
|
+
group.classList.remove('lit');
|
|
427
|
+
void group.getBoundingClientRect();
|
|
428
|
+
group.classList.add('lit');
|
|
429
|
+
});
|
|
430
|
+
if (lit.length === 0) return;
|
|
431
|
+
var room = view.getBoundingClientRect();
|
|
432
|
+
var r = null;
|
|
433
|
+
lit.forEach(function (group) {
|
|
434
|
+
var b = group.getBoundingClientRect();
|
|
435
|
+
r = r ? { l: Math.min(r.l, b.left), t: Math.min(r.t, b.top), r: Math.max(r.r, b.right), b: Math.max(r.b, b.bottom) }
|
|
436
|
+
: { l: b.left, t: b.top, r: b.right, b: b.bottom };
|
|
437
|
+
});
|
|
438
|
+
var inside = r.l >= room.left && r.r <= room.right && r.t >= room.top && r.b <= room.bottom;
|
|
439
|
+
if (inside) return;
|
|
440
|
+
at.x += (room.left + room.width / 2) - (r.l + r.r) / 2;
|
|
441
|
+
at.y += (room.top + room.height / 2) - (r.t + r.b) / 2;
|
|
442
|
+
fitted = false;
|
|
443
|
+
apply();
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
DATA.findings.forEach(function (finding) {
|
|
447
|
+
var item = document.createElement('li');
|
|
448
|
+
var button = document.createElement('button');
|
|
449
|
+
button.type = 'button';
|
|
450
|
+
var where = document.createElement('span');
|
|
451
|
+
where.className = 'where';
|
|
452
|
+
where.textContent = 'line ' + finding.line + ' ';
|
|
453
|
+
var kind = document.createElement('span');
|
|
454
|
+
kind.className = 'kind';
|
|
455
|
+
kind.textContent = finding.kind;
|
|
456
|
+
button.append(where, kind, document.createElement('br'), document.createTextNode(finding.message));
|
|
457
|
+
if (!svg.querySelector('g[data-line="' + finding.line + '"]')) {
|
|
458
|
+
var note = document.createElement('span');
|
|
459
|
+
note.className = 'note';
|
|
460
|
+
note.textContent = 'Nothing drawn comes from that line.';
|
|
461
|
+
button.append(note);
|
|
462
|
+
}
|
|
463
|
+
button.onclick = function () { light(finding, button); };
|
|
464
|
+
item.append(button);
|
|
465
|
+
list.append(item);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
/* ---- keys ---- */
|
|
469
|
+
|
|
470
|
+
document.addEventListener('keydown', function (event) {
|
|
471
|
+
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
|
472
|
+
if (event.target.closest && event.target.closest('input, textarea')) return;
|
|
473
|
+
if (event.key === '+' || event.key === '=') zoomMiddle(1.25);
|
|
474
|
+
else if (event.key === '-' || event.key === '_') zoomMiddle(0.8);
|
|
475
|
+
else if (event.key === '0') fit();
|
|
476
|
+
else if (event.key === '1') { fitted = false; center(1); }
|
|
477
|
+
else if (event.key === 'f' && !full.hidden) toggleFull();
|
|
478
|
+
else if (event.key === 'Escape') unlight();
|
|
479
|
+
else return;
|
|
480
|
+
event.preventDefault();
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
open();
|
|
484
|
+
})();
|
|
485
|
+
</script>
|
|
486
|
+
</body>
|
|
487
|
+
</html>
|
|
488
|
+
`;
|
package/dist/parser.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BUNDLE_KEYS, COLOR_KEYS, POINT_KEYS, REGION_KEYS, WAYS, describePoint, fractionAxis, DEFAULT_KEYS, DEFAULT_TARGETS, DIAGRAM_KEYS, DIRECTIONS, describePlacement, SIDE_AXIS, SIDES, TEXT_KEYS, MARK_TEXT_KEYS, POINT_TEXT_SIDES, LINE_KEYS, LINE_VALUES, CONTENTS_KEYS, EDGE_ORDER_KEYS, LAYOUT_KEYS, PLACEMENT_KEYS, BOUNDARY_PARTS, INWARD, MARKS, MARK_GLYPHS, OPPOSITE, isDirection, isPart, isPosition, listTargets, nameTarget, } from './ast.js';
|
|
1
|
+
import { BUNDLE_KEYS, COLOR_KEYS, POINT_KEYS, REGION_KEYS, WAYS, describePoint, fractionAxis, DEFAULT_KEYS, DEFAULT_TARGETS, DIAGRAM_KEYS, DIRECTIONS, describePlacement, SIDE_AXIS, SIDES, TEXT_KEYS, MARK_TEXT_KEYS, POINT_TEXT_SIDES, LINE_KEYS, LINE_VALUES, BORDER_KEYS, CONTENTS_KEYS, EDGE_ORDER_KEYS, LAYOUT_KEYS, PLACEMENT_KEYS, BOUNDARY_PARTS, INWARD, MARKS, MARK_GLYPHS, OPPOSITE, isDirection, isPart, isPosition, listTargets, nameTarget, } from './ast.js';
|
|
2
2
|
import { CHECK_DEFAULTS, CHECK_KINDS, setCheck } from './checks.js';
|
|
3
3
|
import { SourceError } from './errors.js';
|
|
4
4
|
import { STATEMENT_KEYWORDS } from './grammar.js';
|
|
@@ -309,6 +309,7 @@ const BRACKET_KEYS = {
|
|
|
309
309
|
text: TEXT_KEYS,
|
|
310
310
|
contents: CONTENTS_KEYS,
|
|
311
311
|
line: LINE_KEYS,
|
|
312
|
+
border: BORDER_KEYS,
|
|
312
313
|
checks: CHECK_KINDS,
|
|
313
314
|
};
|
|
314
315
|
/** How each bracketed key's error quotes itself back, and what it is about. */
|
|
@@ -316,6 +317,7 @@ const BRACKET_ABOUT = {
|
|
|
316
317
|
text: { kind: 'a text', example: 'color: theme-muted' },
|
|
317
318
|
contents: { kind: 'a `contents:` bracket', example: 'widths: match' },
|
|
318
319
|
line: { kind: 'a line', example: 'path: square' },
|
|
320
|
+
border: { kind: 'a border', example: 'thickness: thick' },
|
|
319
321
|
checks: { kind: 'a `checks:` bracket', example: 'edge-crossings: on' },
|
|
320
322
|
};
|
|
321
323
|
/**
|
|
@@ -702,7 +704,8 @@ function readAttr(tokens, at, attrs, line, subject) {
|
|
|
702
704
|
// A line's color is stored where `line: red` puts it, so the short form
|
|
703
705
|
// and the bracket are one key: a style's `line: red` merges under an
|
|
704
706
|
// edge's `line: (color: blue)`, and writing both on one line is a repeat.
|
|
705
|
-
|
|
707
|
+
// A border's color the same.
|
|
708
|
+
const stored = (key === 'line' || key === 'border') && inner === 'color' ? key : `${key}.${inner}`;
|
|
706
709
|
setOnce(attrs, stored, value, subject, line, { key: `${key}: (${inner}: …)`, value: (v) => v });
|
|
707
710
|
}
|
|
708
711
|
return read.next;
|
|
@@ -720,6 +723,16 @@ function readAttr(tokens, at, attrs, line, subject) {
|
|
|
720
723
|
setOnce(attrs, key, value.text, subject, line, { key, value: quoteOf });
|
|
721
724
|
return at + 2;
|
|
722
725
|
}
|
|
726
|
+
if (key === 'title') {
|
|
727
|
+
// A title is a text, and every text is quoted. Unquoted, its first word
|
|
728
|
+
// would be the title and the rest read as stray words.
|
|
729
|
+
const value = tokens[at + 1];
|
|
730
|
+
if (!value || !value.quoted) {
|
|
731
|
+
throw new SourceError(`${subject}: a title is written in quotes — \`title: "How a login reaches the database"\``, line);
|
|
732
|
+
}
|
|
733
|
+
setOnce(attrs, key, value.text, subject, line, { key, value: quoteOf });
|
|
734
|
+
return at + 2;
|
|
735
|
+
}
|
|
723
736
|
if (key === 'style') {
|
|
724
737
|
// `style: base, critical and alarm` — several bundles, applied in the order
|
|
725
738
|
// written, a later one winning where two set the same key. The list reads
|
|
@@ -768,7 +781,7 @@ function readAttr(tokens, at, attrs, line, subject) {
|
|
|
768
781
|
});
|
|
769
782
|
return next;
|
|
770
783
|
}
|
|
771
|
-
if (bracketKeys !== undefined && key !== 'text' && key !== 'line') {
|
|
784
|
+
if (bracketKeys !== undefined && key !== 'text' && key !== 'line' && key !== 'border') {
|
|
772
785
|
// `contents: match` names the part and then says one of its two properties
|
|
773
786
|
// without saying which. The brackets are what make the level shift visible,
|
|
774
787
|
// so there is no unbracketed spelling to fall back to.
|
|
@@ -821,6 +834,11 @@ function readAttr(tokens, at, attrs, line, subject) {
|
|
|
821
834
|
`\`line: (${property}: ${valueToken.text})\``, line);
|
|
822
835
|
}
|
|
823
836
|
}
|
|
837
|
+
if (key === 'border' && !valueToken.quoted && /^(thin|normal|thick|\d*\.?\d+(px)?)$/.test(valueToken.text)) {
|
|
838
|
+
// `border: red` is the short form of the color, as `line: red` is.
|
|
839
|
+
throw new SourceError(`\`border: ${valueToken.text}\` — \`border:\` on its own takes a color. Its thickness goes in its bracket: ` +
|
|
840
|
+
`\`border: (thickness: ${valueToken.text})\``, line);
|
|
841
|
+
}
|
|
824
842
|
if (key === 'align' && valueToken.text === 'widths') {
|
|
825
843
|
// Removed in 0.3.0. It was a size operation wearing an alignment's name,
|
|
826
844
|
// and its value set had one member — a flag in a property's clothes. Its
|
package/dist/place.d.ts
CHANGED
|
@@ -153,6 +153,8 @@ export declare class Placer {
|
|
|
153
153
|
private readonly origins;
|
|
154
154
|
/** Containers whose anchor is being placed on the page, so a box outside them is measured on the page rather than in their frame. */
|
|
155
155
|
private readonly onPage;
|
|
156
|
+
/** Boxes that anchor their frame on one axis only, when no box in it says nothing at all (`splitAnchors`). */
|
|
157
|
+
private readonly axisAnchors;
|
|
156
158
|
/** Named points and regions being worked out, innermost last, to refuse one defined through itself. */
|
|
157
159
|
private readonly expanding;
|
|
158
160
|
constructor(roots: LayoutNode[], byName: Map<string, LayoutNode>, facts: Map<LayoutNode, NodeFacts>,
|
|
@@ -534,6 +536,23 @@ export declare class Placer {
|
|
|
534
536
|
* title something is placed against anchors the frame instead.
|
|
535
537
|
*/
|
|
536
538
|
private checkAnchors;
|
|
539
|
+
/**
|
|
540
|
+
* Every box in a frame says something about where it goes, so the frame is
|
|
541
|
+
* anchored one axis at a time (design.org 5.3): across by the one box that
|
|
542
|
+
* says nothing across, up and down by the one that says nothing up and
|
|
543
|
+
* down. A lone direction's centering counts as saying nothing when its
|
|
544
|
+
* target's place on that axis is worked out from the box itself, as with
|
|
545
|
+
* `b right of a` and `a even with b.1`: the centering gives way to what was
|
|
546
|
+
* written. An axis nobody leaves free is a circle, reported when placing,
|
|
547
|
+
* and an axis a container is placed on by what it holds needs no anchor.
|
|
548
|
+
*/
|
|
549
|
+
private splitAnchors;
|
|
550
|
+
/** Whether a box with nothing stated on this axis is left free there: no lone direction centers it, or the one that would is placed from the box itself. */
|
|
551
|
+
private loneGivesWay;
|
|
552
|
+
/** The boxes a placement names, through points and regions. */
|
|
553
|
+
private namedFrom;
|
|
554
|
+
/** Whether a box's place on one axis is worked out, however indirectly, from `from` or something inside it. */
|
|
555
|
+
private placedFrom;
|
|
537
556
|
/**
|
|
538
557
|
* A box in a venn's part may be placed against a box in another part, and
|
|
539
558
|
* then it says where its whole part goes up and down: so it must be the
|