justskill-design 0.0.1 → 3.2.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.en.md +235 -0
- package/README.md +184 -11
- package/SKILL.md +435 -0
- package/canvas/app.mjs +2870 -0
- package/canvas/groups.mjs +204 -0
- package/canvas/index.html +141 -0
- package/canvas/styles.css +1032 -0
- package/canvas/tree.mjs +249 -0
- package/cordis.patch.yml +8 -0
- package/design.mjs +172 -0
- package/docs/dsh-workbench.png +0 -0
- package/dsh/client.js +864 -0
- package/dsh/index.js +354 -0
- package/dsh/scan.js +139 -0
- package/dsh/skill.js +41 -0
- package/lib/base-css.mjs +92 -0
- package/lib/detect.mjs +123 -0
- package/lib/diagnostics.mjs +382 -0
- package/lib/export-html.mjs +309 -0
- package/lib/export-svg.mjs +111 -0
- package/lib/fragment.mjs +702 -0
- package/lib/geometry.mjs +827 -0
- package/lib/icons.mjs +106 -0
- package/lib/levenshtein.mjs +133 -0
- package/lib/node/cli.mjs +387 -0
- package/lib/node/export-svg.mjs +75 -0
- package/lib/node/fs.mjs +309 -0
- package/lib/node/op-json.mjs +75 -0
- package/lib/node/raster-run.mjs +115 -0
- package/lib/node/server.mjs +1264 -0
- package/lib/node/static.mjs +206 -0
- package/lib/node/verbs/add.mjs +220 -0
- package/lib/node/verbs/export.mjs +161 -0
- package/lib/node/verbs/format.mjs +106 -0
- package/lib/node/verbs/import.mjs +126 -0
- package/lib/node/verbs/ls.mjs +98 -0
- package/lib/node/verbs/migrate.mjs +236 -0
- package/lib/node/verbs/move.mjs +76 -0
- package/lib/node/verbs/new.mjs +284 -0
- package/lib/node/verbs/remove.mjs +41 -0
- package/lib/node/verbs/rename.mjs +87 -0
- package/lib/node/verbs/serve.mjs +164 -0
- package/lib/node/verbs/set.mjs +150 -0
- package/lib/node/verbs/show.mjs +122 -0
- package/lib/node/verbs/tokens.mjs +167 -0
- package/lib/node/verbs/validate.mjs +130 -0
- package/lib/node/write-verbs.mjs +215 -0
- package/lib/normalize.mjs +288 -0
- package/lib/ops.mjs +2063 -0
- package/lib/parse.mjs +997 -0
- package/lib/raster.mjs +186 -0
- package/lib/readers/pen.mjs +1684 -0
- package/lib/receipt.mjs +203 -0
- package/lib/render.mjs +503 -0
- package/lib/serialize.mjs +441 -0
- package/lib/spans.mjs +177 -0
- package/lib/svg-path.mjs +483 -0
- package/lib/table.mjs +1047 -0
- package/lib/tokens.mjs +295 -0
- package/lib/validate.mjs +929 -0
- package/package.json +68 -6
- package/references/cookbook.md +156 -0
- package/references/images.md +171 -0
- package/references/product-flow.md +408 -0
- package/references/schema.md +90 -0
- package/references/styling.md +181 -0
- package/scripts/install.mjs +95 -0
package/canvas/app.mjs
ADDED
|
@@ -0,0 +1,2870 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canvas.
|
|
3
|
+
*
|
|
4
|
+
* Three rules hold this together, and each one is a decision:
|
|
5
|
+
*
|
|
6
|
+
* 1. The browser parses and renders the file itself. It imports the same
|
|
7
|
+
* `lib/parse.mjs` and `lib/render.mjs` the CLI validates with, so the canvas
|
|
8
|
+
* cannot disagree with the tool about what a file means. The service sends
|
|
9
|
+
* text, never a document tree.
|
|
10
|
+
*
|
|
11
|
+
* 2. Each page mounts inside its own shadow root. A page's tokens and page-frame
|
|
12
|
+
* rule get exactly one scope, so a design cannot restyle this chrome and this
|
|
13
|
+
* chrome cannot restyle a design. It is also why the canvas never has to
|
|
14
|
+
* write its own styles into the design file.
|
|
15
|
+
*
|
|
16
|
+
* 3. The canvas only ever sends ops, and the service only ever accepts them
|
|
17
|
+
* through the same write funnel the CLI uses. A drag is a `set` of
|
|
18
|
+
* `style:left` and `style:top`; there is no second write path to keep in
|
|
19
|
+
* sync, and no way for a drag to write something the validator would refuse.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { parse, getAttr, pagesOf } from "/lib/parse.mjs";
|
|
23
|
+
import {
|
|
24
|
+
renderModel, canvasMountCss, pagePaperRadius, renderNodeHtml, styleMapOf, styleStringOf,
|
|
25
|
+
arrangePages, pageTree, resolveTokens, webfontLinks,
|
|
26
|
+
} from "/lib/render.mjs";
|
|
27
|
+
import { collectTokens, lintContrast } from "/lib/validate.mjs";
|
|
28
|
+
import { lintGeometry, overhangOf, MAX_REPORTED_PER_CODE } from "/lib/geometry.mjs";
|
|
29
|
+
import {
|
|
30
|
+
newView, pageRoots, isOpen, toggleNode, openPath, visibleNodes, hasChildren, pathTo,
|
|
31
|
+
expandAll, collapseAll, rungAt, selectedPage,
|
|
32
|
+
} from "./tree.mjs";
|
|
33
|
+
import {
|
|
34
|
+
groupedFields, controlFor, valuesFor, hintFor, rangeFor, scrubFor, isNumericValue,
|
|
35
|
+
} from "./groups.mjs";
|
|
36
|
+
|
|
37
|
+
// -- wire --------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
const TOKEN = new URLSearchParams(location.search).get("t") ?? "";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Embedded mode. The DSH plugin iframes the canvas in a narrow right-sidebar
|
|
43
|
+
* tab; there, the layer tree and the inspector are chrome the reader does not
|
|
44
|
+
* need, and the bar has to fit a fraction of the width. The mode changes only
|
|
45
|
+
* presentation: the file, the ops and ⌘C keep working exactly as they do in a
|
|
46
|
+
* normal browser tab. It is opt-in with `?embed=1`, so opening the URL by hand
|
|
47
|
+
* is unchanged.
|
|
48
|
+
*/
|
|
49
|
+
const EMBED = new URLSearchParams(location.search).get("embed") === "1";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One call to the service.
|
|
53
|
+
*
|
|
54
|
+
* The response object comes back with the payload, because the revision lives in
|
|
55
|
+
* a header and the canvas has to record it: it is how the canvas recognises its
|
|
56
|
+
* own write when the change arrives over the event stream, and how it knows the
|
|
57
|
+
* other writer got there first.
|
|
58
|
+
*/
|
|
59
|
+
async function api(path, options = {}) {
|
|
60
|
+
const url = `/api/${path}?t=${encodeURIComponent(TOKEN)}`;
|
|
61
|
+
const init = { headers: {}, ...options };
|
|
62
|
+
if (options.body !== undefined) {
|
|
63
|
+
init.method = options.method ?? "POST";
|
|
64
|
+
init.headers["Content-Type"] = "application/json";
|
|
65
|
+
init.body = JSON.stringify(options.body);
|
|
66
|
+
}
|
|
67
|
+
const response = await fetch(url, init);
|
|
68
|
+
const type = response.headers.get("content-type") ?? "";
|
|
69
|
+
let payload = null;
|
|
70
|
+
try {
|
|
71
|
+
payload = type.includes("application/json") ? await response.json() : await response.text();
|
|
72
|
+
} catch {
|
|
73
|
+
payload = null;
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
status: response.status,
|
|
77
|
+
ok: response.ok,
|
|
78
|
+
payload,
|
|
79
|
+
revision: response.headers.get("X-Design-Revision"),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// -- state -------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
const state = {
|
|
86
|
+
file: "",
|
|
87
|
+
path: "",
|
|
88
|
+
text: "",
|
|
89
|
+
revision: "",
|
|
90
|
+
readOnly: false,
|
|
91
|
+
readOnlyReason: "",
|
|
92
|
+
doc: null,
|
|
93
|
+
model: null,
|
|
94
|
+
diagnostics: [],
|
|
95
|
+
selection: null,
|
|
96
|
+
hovering: null,
|
|
97
|
+
// No `mode`. There was a `mode: "select"` here with no reader anywhere in the canvas —
|
|
98
|
+
// a toggle nothing toggled and nothing consulted, which reads as if the canvas had
|
|
99
|
+
// states it does not have.
|
|
100
|
+
camera: { x: 40, y: 40, zoom: 1 },
|
|
101
|
+
boxes: new Map(),
|
|
102
|
+
canUndo: false,
|
|
103
|
+
canRedo: false,
|
|
104
|
+
busy: false,
|
|
105
|
+
// What the pointer is over, and which page it is over — ids repeat across
|
|
106
|
+
// pages, so the id alone does not name an element. The selection remembers its
|
|
107
|
+
// page for the same reason: the box has to be drawn around the copy the user
|
|
108
|
+
// picked, not around the first copy with that id in the document.
|
|
109
|
+
hoveringPage: null,
|
|
110
|
+
selectionPage: null,
|
|
111
|
+
// The layer tree: the nested nodes, what is open, and which row has the keyboard.
|
|
112
|
+
tree: [],
|
|
113
|
+
treeView: newView(),
|
|
114
|
+
treeFocus: null,
|
|
115
|
+
revealedFor: null,
|
|
116
|
+
lastSelection: null,
|
|
117
|
+
// Set by a selection made on the canvas: the row has to be opened to and
|
|
118
|
+
// scrolled to even when the selection did not change.
|
|
119
|
+
expandRow: false,
|
|
120
|
+
/*
|
|
121
|
+
* The rung the canvas is standing on, as a depth: 1 is "the page's own
|
|
122
|
+
* children", 2 is "their children", and 0 happens when someone selects a page
|
|
123
|
+
* itself. A depth rather than an element, because the solid box follows the
|
|
124
|
+
* pointer — hovering the next section along has to land on *that* section, and
|
|
125
|
+
* an element-shaped level could only ever name one of them.
|
|
126
|
+
*/
|
|
127
|
+
levelDepth: 1,
|
|
128
|
+
// The inspector sections the user folded shut, by section id.
|
|
129
|
+
folded: new Set(),
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/** Counts mounts, so a font callback from a replaced mount can be ignored. */
|
|
133
|
+
let mountGeneration = 0;
|
|
134
|
+
|
|
135
|
+
const ui = {
|
|
136
|
+
viewport: document.getElementById("viewport"),
|
|
137
|
+
canvas: document.getElementById("canvas"),
|
|
138
|
+
overlay: document.getElementById("overlay"),
|
|
139
|
+
layerTree: document.getElementById("layer-tree"),
|
|
140
|
+
treeExpand: document.getElementById("tree-expand"),
|
|
141
|
+
treeCollapse: document.getElementById("tree-collapse"),
|
|
142
|
+
panelLeft: document.getElementById("panel-left"),
|
|
143
|
+
panelRight: document.getElementById("panel-right"),
|
|
144
|
+
dividerLeft: document.getElementById("divider-left"),
|
|
145
|
+
dividerRight: document.getElementById("divider-right"),
|
|
146
|
+
toggleLeft: document.getElementById("toggle-left"),
|
|
147
|
+
toggleRight: document.getElementById("toggle-right"),
|
|
148
|
+
edgeLeft: document.getElementById("edge-left"),
|
|
149
|
+
edgeRight: document.getElementById("edge-right"),
|
|
150
|
+
inspector: document.getElementById("inspector"),
|
|
151
|
+
selectionName: document.getElementById("selection-name"),
|
|
152
|
+
fileName: document.getElementById("file-name"),
|
|
153
|
+
bar: document.querySelector(".bar"),
|
|
154
|
+
barLeft: document.querySelector(".bar-left"),
|
|
155
|
+
barRight: document.querySelector(".bar-right"),
|
|
156
|
+
revision: document.getElementById("revision"),
|
|
157
|
+
readonly: document.getElementById("readonly"),
|
|
158
|
+
zoomLabel: document.getElementById("zoom-label"),
|
|
159
|
+
problemCount: document.getElementById("problem-count"),
|
|
160
|
+
problemSummary: document.getElementById("problem-summary"),
|
|
161
|
+
problems: document.getElementById("problems"),
|
|
162
|
+
fixPane: document.getElementById("fix-pane"),
|
|
163
|
+
fixHint: document.getElementById("fix-hint"),
|
|
164
|
+
fixText: document.getElementById("fix-textarea"),
|
|
165
|
+
problemList: document.getElementById("problem-list"),
|
|
166
|
+
toast: document.getElementById("toast"),
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
let toastTimer = null;
|
|
170
|
+
function toast(message, kind = "") {
|
|
171
|
+
ui.toast.textContent = message;
|
|
172
|
+
ui.toast.className = `toast ${kind}`.trim();
|
|
173
|
+
ui.toast.hidden = false;
|
|
174
|
+
clearTimeout(toastTimer);
|
|
175
|
+
toastTimer = setTimeout(() => { ui.toast.hidden = true; }, kind === "refused" ? 7000 : 2600);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// -- camera ------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
function applyCamera() {
|
|
181
|
+
const { x, y, zoom } = state.camera;
|
|
182
|
+
ui.canvas.style.transform = `translate(${x}px, ${y}px) scale(${zoom})`;
|
|
183
|
+
// The reciprocal of the zoom, so the chrome that lives *inside* the scaled
|
|
184
|
+
// element can put itself back at its own size: the page labels read it, and
|
|
185
|
+
// styles.css says why they have to. Written here rather than in a per-label
|
|
186
|
+
// loop because the camera is the only thing it depends on.
|
|
187
|
+
ui.canvas.style.setProperty("--chrome-unzoom", String(1 / zoom));
|
|
188
|
+
// The field's dots, in screen pixels: the pitch follows the camera between
|
|
189
|
+
// 14px and 44px, and the position follows the camera offset, so panning moves
|
|
190
|
+
// the paper under the pages instead of sliding them over a fixed texture.
|
|
191
|
+
// Below 30% a 26px dot grid would be 7.8px apart and read as noise, so the
|
|
192
|
+
// pitch stops shrinking and the dots go away entirely.
|
|
193
|
+
const pitch = Math.min(44, Math.max(14, 26 * zoom));
|
|
194
|
+
ui.viewport.style.backgroundSize = `${pitch}px ${pitch}px`;
|
|
195
|
+
const offsetX = ((x % pitch) + pitch) % pitch;
|
|
196
|
+
const offsetY = ((y % pitch) + pitch) % pitch;
|
|
197
|
+
ui.viewport.style.backgroundPosition = `${offsetX}px ${offsetY}px`;
|
|
198
|
+
ui.viewport.dataset.zoom = zoom < 0.3 ? "far" : "";
|
|
199
|
+
ui.zoomLabel.textContent = `${Math.round(zoom * 100)}%`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function zoomAt(clientX, clientY, factor) {
|
|
203
|
+
const rect = ui.viewport.getBoundingClientRect();
|
|
204
|
+
const px = clientX - rect.left;
|
|
205
|
+
const py = clientY - rect.top;
|
|
206
|
+
const { x, y, zoom } = state.camera;
|
|
207
|
+
const raw = Math.min(4, Math.max(0.1, zoom * factor));
|
|
208
|
+
const next = crossesOne(zoom, raw) ? 1 : raw;
|
|
209
|
+
const scale = next / zoom;
|
|
210
|
+
state.camera = { zoom: next, x: px - (px - x) * scale, y: py - (py - y) * scale };
|
|
211
|
+
applyCamera();
|
|
212
|
+
drawOverlay();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 100% is a stop on the ladder, not a step that happens to land nearby.
|
|
217
|
+
*
|
|
218
|
+
* The zoom steps multiply by 1.15, so from the 10% a fresh canvas opens at they
|
|
219
|
+
* run ... 81% -> 94% -> 108% and step straight over 1:1. A design tool whose
|
|
220
|
+
* zoom cannot reach 1:1 is a tool where every look at the drawing is a scaled
|
|
221
|
+
* raster, and scaled text is soft text. A step that would cross 100% stops on it
|
|
222
|
+
* instead, in both directions.
|
|
223
|
+
*/
|
|
224
|
+
function crossesOne(from, to) {
|
|
225
|
+
return (from < 1 && to > 1) || (from > 1 && to < 1);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function fitAll() {
|
|
229
|
+
const pages = state.model?.pages ?? [];
|
|
230
|
+
if (pages.length === 0) return;
|
|
231
|
+
const rect = ui.viewport.getBoundingClientRect();
|
|
232
|
+
let minX = Infinity;
|
|
233
|
+
let minY = Infinity;
|
|
234
|
+
let maxX = -Infinity;
|
|
235
|
+
let maxY = -Infinity;
|
|
236
|
+
for (const page of pages) {
|
|
237
|
+
const box = state.boxes.get(page.id);
|
|
238
|
+
const position = box?.position ?? { x: 0, y: 0 };
|
|
239
|
+
const width = box?.width ?? (parseFloat(page.width) || 390);
|
|
240
|
+
const height = box?.height ?? (parseFloat(page.height) || 844);
|
|
241
|
+
minX = Math.min(minX, position.x);
|
|
242
|
+
minY = Math.min(minY, position.y);
|
|
243
|
+
maxX = Math.max(maxX, position.x + width);
|
|
244
|
+
maxY = Math.max(maxY, position.y + height);
|
|
245
|
+
}
|
|
246
|
+
// The top of the viewport is chrome now: the bar floats over it and a page
|
|
247
|
+
// label sits above the page it names. Fitting the drawing into the whole
|
|
248
|
+
// viewport put the labels under the pills, so the usable area starts below
|
|
249
|
+
// them — 48px of bar plus one 8px gap.
|
|
250
|
+
const pad = 60;
|
|
251
|
+
const topPad = pad + 56;
|
|
252
|
+
const usableW = rect.width - pad * 2;
|
|
253
|
+
const usableH = rect.height - topPad - pad;
|
|
254
|
+
const zoom = Math.min(
|
|
255
|
+
1.5,
|
|
256
|
+
Math.max(0.1, Math.min(usableW / (maxX - minX || 1), usableH / (maxY - minY || 1))),
|
|
257
|
+
);
|
|
258
|
+
/*
|
|
259
|
+
* The middle of every page, put in the middle of the viewport.
|
|
260
|
+
*
|
|
261
|
+
* This used to anchor the drawing's top-left corner `pad` pixels from the
|
|
262
|
+
* viewport's top-left, which is only centred when the pages and the window have
|
|
263
|
+
* the same shape. They rarely do — a phone-shaped column of artboards in a wide
|
|
264
|
+
* window left the whole right half empty and every page pinned to the corner,
|
|
265
|
+
* which reads as a canvas that failed to fit rather than one that fitted the
|
|
266
|
+
* wrong way.
|
|
267
|
+
*/
|
|
268
|
+
state.camera = {
|
|
269
|
+
zoom,
|
|
270
|
+
x: rect.width / 2 - ((minX + maxX) / 2) * zoom,
|
|
271
|
+
y: topPad + usableH / 2 - ((minY + maxY) / 2) * zoom,
|
|
272
|
+
};
|
|
273
|
+
applyCamera();
|
|
274
|
+
drawOverlay();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// -- panels ------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Panel geometry: which panels are open, and how wide.
|
|
281
|
+
*
|
|
282
|
+
* This is chrome state and it stays chrome state. It is not written into the
|
|
283
|
+
* design file, it is not sent to the service, and it takes no part in a commit:
|
|
284
|
+
* opening a panel is not an edit, and a design file that changed because someone
|
|
285
|
+
* resized a sidebar would break the one promise this whole tool is built on.
|
|
286
|
+
*
|
|
287
|
+
* It is remembered in `localStorage`, keyed per browser rather than per file —
|
|
288
|
+
* which panel you like open is a fact about you, not about the document. A
|
|
289
|
+
* browser that refuses (private mode throws on write) gets the defaults every
|
|
290
|
+
* time: mildly annoying, never broken.
|
|
291
|
+
*/
|
|
292
|
+
const PANEL_KEY = "justskill-design.canvas.panels";
|
|
293
|
+
const PANEL_MIN = 160;
|
|
294
|
+
const PANEL_GUTTER = 6; // must match the `--gutter-*` default in styles.css
|
|
295
|
+
const PANEL_DEFAULT = { left: 240, right: 260 };
|
|
296
|
+
|
|
297
|
+
const panels = {
|
|
298
|
+
left: { open: true, width: PANEL_DEFAULT.left },
|
|
299
|
+
right: { open: true, width: PANEL_DEFAULT.right },
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
function loadPanels() {
|
|
303
|
+
let saved = null;
|
|
304
|
+
try {
|
|
305
|
+
saved = JSON.parse(localStorage.getItem(PANEL_KEY) ?? "null");
|
|
306
|
+
} catch {
|
|
307
|
+
saved = null; // no storage, or something else owns that key
|
|
308
|
+
}
|
|
309
|
+
for (const side of ["left", "right"]) {
|
|
310
|
+
const entry = saved?.[side];
|
|
311
|
+
if (!entry) continue;
|
|
312
|
+
if (typeof entry.open === "boolean") panels[side].open = entry.open;
|
|
313
|
+
if (Number.isFinite(entry.width)) panels[side].width = entry.width;
|
|
314
|
+
}
|
|
315
|
+
// Read first, clamp after: a remembered width is clamped against the *other*
|
|
316
|
+
// remembered width, whichever order the two happen to be read in.
|
|
317
|
+
reclampPanels();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function savePanels() {
|
|
321
|
+
try {
|
|
322
|
+
localStorage.setItem(PANEL_KEY, JSON.stringify(panels));
|
|
323
|
+
} catch {
|
|
324
|
+
/* the canvas works without remembering anything */
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** The narrowest centre column that is still a canvas. */
|
|
329
|
+
const CANVAS_MIN = 160;
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Never narrower than a usable column, and never so wide that the canvas — or
|
|
333
|
+
* the other panel — is squeezed out of the window.
|
|
334
|
+
*
|
|
335
|
+
* The ceiling used to be half the window per side, which is a rule that only
|
|
336
|
+
* holds while one panel is open. Two panels at half the window add up to more
|
|
337
|
+
* than the window: the centre track resolves to zero, and `overflow: hidden`
|
|
338
|
+
* then hides the canvas and the two buttons that live on it, with the state
|
|
339
|
+
* written to `localStorage` for the next visit. Measured against the space that
|
|
340
|
+
* is actually left, the two sides cannot starve the middle.
|
|
341
|
+
*/
|
|
342
|
+
function clampPanel(side, width) {
|
|
343
|
+
const other = side === "left" ? "right" : "left";
|
|
344
|
+
const otherTrack = panels[other].open ? panels[other].width + PANEL_GUTTER : 0;
|
|
345
|
+
const ceiling = Math.max(PANEL_MIN, window.innerWidth - otherTrack - PANEL_GUTTER - CANVAS_MIN);
|
|
346
|
+
return Math.round(Math.min(Math.max(width, PANEL_MIN), ceiling));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Bring both panels back inside the window.
|
|
351
|
+
*
|
|
352
|
+
* Two passes, because the ceiling of one side is computed from the other: a
|
|
353
|
+
* shrink on the right can be what makes room on the left.
|
|
354
|
+
*/
|
|
355
|
+
function reclampPanels() {
|
|
356
|
+
withPanelGeometry(() => {
|
|
357
|
+
for (const side of ["left", "right"]) panels[side].width = clampPanel(side, panels[side].width);
|
|
358
|
+
for (const side of ["right", "left"]) panels[side].width = clampPanel(side, panels[side].width);
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** The horizontal space the left panel takes from the viewport, in pixels. */
|
|
363
|
+
function leftTrackWidth() {
|
|
364
|
+
return panels.left.open ? panels.left.width + PANEL_GUTTER : 0;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Run a geometry change and keep the drawing where it was on screen.
|
|
369
|
+
*
|
|
370
|
+
* The canvas is anchored to the viewport's top-left corner, and collapsing or
|
|
371
|
+
* resizing the left panel moves exactly that corner — so the whole design slides
|
|
372
|
+
* sideways with it. A panel is chrome: changing one must not move the thing
|
|
373
|
+
* being designed. The correction is the change in the left track, measured
|
|
374
|
+
* rather than assumed, and the right panel needs none because it moves no
|
|
375
|
+
* corner.
|
|
376
|
+
*/
|
|
377
|
+
function withPanelGeometry(change) {
|
|
378
|
+
const before = leftTrackWidth();
|
|
379
|
+
change();
|
|
380
|
+
applyPanels();
|
|
381
|
+
const after = leftTrackWidth();
|
|
382
|
+
if (after !== before) {
|
|
383
|
+
state.camera.x -= after - before;
|
|
384
|
+
applyCamera();
|
|
385
|
+
}
|
|
386
|
+
drawOverlay();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Write the geometry into the four variables the layout reads.
|
|
391
|
+
*
|
|
392
|
+
* `#stage` sizes its tracks from them and `.drawer` anchors its edges to them.
|
|
393
|
+
* A collapsed panel sets its own to 0, gutter included: a hidden panel that
|
|
394
|
+
* leaves a 6px slot behind is a hidden panel you can still see.
|
|
395
|
+
*/
|
|
396
|
+
function applyPanels() {
|
|
397
|
+
const root = document.documentElement.style;
|
|
398
|
+
for (const side of ["left", "right"]) {
|
|
399
|
+
const { open, width } = panels[side];
|
|
400
|
+
root.setProperty(`--panel-${side}`, open ? `${width}px` : "0px");
|
|
401
|
+
root.setProperty(`--gutter-${side}`, open ? `${PANEL_GUTTER}px` : "0px");
|
|
402
|
+
}
|
|
403
|
+
// The bar leaves a 36px slot for a folded panel's tab (28px + the 8px gap the
|
|
404
|
+
// pills already use), so the tab and the pills share the top row instead of
|
|
405
|
+
// stacking. One variable per side; 0 when the panel is open and there is no tab.
|
|
406
|
+
root.setProperty("--edge-left", panels.left.open ? "0px" : "36px");
|
|
407
|
+
root.setProperty("--edge-right", panels.right.open ? "0px" : "36px");
|
|
408
|
+
ui.panelLeft.hidden = !panels.left.open;
|
|
409
|
+
ui.panelRight.hidden = !panels.right.open;
|
|
410
|
+
ui.dividerLeft.hidden = !panels.left.open;
|
|
411
|
+
ui.dividerRight.hidden = !panels.right.open;
|
|
412
|
+
// A focusable `separator` is a widget, and a widget reports its value. Written
|
|
413
|
+
// here rather than in the HTML so the bounds have one definition: `PANEL_MIN`
|
|
414
|
+
// and the same ceiling `clampPanel` uses.
|
|
415
|
+
for (const [side, divider] of [["left", ui.dividerLeft], ["right", ui.dividerRight]]) {
|
|
416
|
+
divider.setAttribute("aria-valuenow", String(panels[side].width));
|
|
417
|
+
divider.setAttribute("aria-valuemin", String(PANEL_MIN));
|
|
418
|
+
divider.setAttribute("aria-valuemax", String(Math.max(PANEL_MIN, Math.round(window.innerWidth / 2))));
|
|
419
|
+
}
|
|
420
|
+
// One panel, two homes for its control: the header when the panel is open,
|
|
421
|
+
// the edge tab when it is closed. Exactly one of them is visible, and both
|
|
422
|
+
// say what the same click will do.
|
|
423
|
+
applyBarFit();
|
|
424
|
+
for (const [side, toggle, edge] of [
|
|
425
|
+
["left", ui.toggleLeft, ui.edgeLeft],
|
|
426
|
+
["right", ui.toggleRight, ui.edgeRight],
|
|
427
|
+
]) {
|
|
428
|
+
const name = side === "left" ? "layers panel" : "inspector";
|
|
429
|
+
const key = side === "left" ? "⌘\\" : "⇧⌘\\";
|
|
430
|
+
toggle.setAttribute("aria-pressed", String(panels[side].open));
|
|
431
|
+
toggle.title = `Hide the ${name} (${key})`;
|
|
432
|
+
edge.hidden = panels[side].open;
|
|
433
|
+
edge.title = `Show the ${name} (${key})`;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The widest bar that fits the room left between the panels.
|
|
439
|
+
*
|
|
440
|
+
* `styles.css` defines what each step hides. Measured rather than guessed: the
|
|
441
|
+
* groups are `flex: none`, so their widths are the content widths, and the only
|
|
442
|
+
* unknowns are the labels a step already hid. Five reads on a panel change is a
|
|
443
|
+
* fair price for "it never overflows at any window size".
|
|
444
|
+
*/
|
|
445
|
+
const BAR_FIT_STEPS = ["full", "tight", "narrow", "mini", "micro"];
|
|
446
|
+
function applyBarFit() {
|
|
447
|
+
const rightTrack = panels.right.open ? panels.right.width + PANEL_GUTTER : 0;
|
|
448
|
+
// A closed panel leaves a 36px tab slot on its side (see `--edge-*` in
|
|
449
|
+
// applyPanels); the bar is inset by it, so the room for the pills is smaller
|
|
450
|
+
// than the window minus the panel tracks. Counting only the tracks made a
|
|
451
|
+
// closed pair look roomier than it is and let the bar overflow in embed mode.
|
|
452
|
+
const edge = (panels.left.open ? 0 : 36) + (panels.right.open ? 0 : 36);
|
|
453
|
+
const room = window.innerWidth - leftTrackWidth() - rightTrack - edge - 24; // the bar's two 12px insets
|
|
454
|
+
for (const fit of BAR_FIT_STEPS) {
|
|
455
|
+
ui.bar.dataset.fit = fit;
|
|
456
|
+
const needed = ui.barLeft.getBoundingClientRect().width + ui.barRight.getBoundingClientRect().width + 12;
|
|
457
|
+
if (needed <= room) return;
|
|
458
|
+
}
|
|
459
|
+
// Nothing fits: the narrowest step stays, and `.bar` is allowed to clip rather
|
|
460
|
+
// than to overlap the tabs. A window this narrow cannot draw a canvas anyway.
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function setPanelWidth(side, width, { remember = true } = {}) {
|
|
464
|
+
withPanelGeometry(() => { panels[side].width = clampPanel(side, width); });
|
|
465
|
+
if (remember) savePanels();
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function togglePanel(side) {
|
|
469
|
+
withPanelGeometry(() => { panels[side].open = !panels[side].open; });
|
|
470
|
+
savePanels();
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Put focus on whichever control is still on screen.
|
|
475
|
+
*
|
|
476
|
+
* Closing a panel hides the header the button lives in, so the click would land
|
|
477
|
+
* focus on nothing and the next Tab would start from the top of the document.
|
|
478
|
+
*/
|
|
479
|
+
function focusPanelControl(side) {
|
|
480
|
+
const target = panels[side].open
|
|
481
|
+
? (side === "left" ? ui.toggleLeft : ui.toggleRight)
|
|
482
|
+
: (side === "left" ? ui.edgeLeft : ui.edgeRight);
|
|
483
|
+
target.focus();
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* A panel edge, dragged.
|
|
488
|
+
*
|
|
489
|
+
* Pointer capture is what makes this feel like an edge instead of a 6px strip:
|
|
490
|
+
* the pointer is free to leave the divider, and the width still follows it. The
|
|
491
|
+
* keyboard gets the same two keys the divider's `separator` role implies, so the
|
|
492
|
+
* layout is not mouse-only.
|
|
493
|
+
*/
|
|
494
|
+
function initDivider(side, element) {
|
|
495
|
+
const outward = side === "left" ? 1 : -1; // which way grows this panel
|
|
496
|
+
let dragging = null;
|
|
497
|
+
|
|
498
|
+
element.addEventListener("pointerdown", (event) => {
|
|
499
|
+
if (event.button !== 0) return;
|
|
500
|
+
dragging = { pointerId: event.pointerId, startX: event.clientX, startWidth: panels[side].width };
|
|
501
|
+
capturePointer(element, event.pointerId);
|
|
502
|
+
element.classList.add("dragging");
|
|
503
|
+
event.preventDefault();
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
element.addEventListener("pointermove", (event) => {
|
|
507
|
+
if (!dragging || event.pointerId !== dragging.pointerId) return;
|
|
508
|
+
setPanelWidth(side, dragging.startWidth + (event.clientX - dragging.startX) * outward, { remember: false });
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
const end = (event) => {
|
|
512
|
+
if (!dragging || event.pointerId !== dragging.pointerId) return;
|
|
513
|
+
dragging = null;
|
|
514
|
+
element.classList.remove("dragging");
|
|
515
|
+
try { element.releasePointerCapture(event.pointerId); } catch { /* already released */ }
|
|
516
|
+
savePanels();
|
|
517
|
+
};
|
|
518
|
+
element.addEventListener("pointerup", end);
|
|
519
|
+
element.addEventListener("pointercancel", end);
|
|
520
|
+
|
|
521
|
+
element.addEventListener("dblclick", () => setPanelWidth(side, PANEL_DEFAULT[side]));
|
|
522
|
+
|
|
523
|
+
element.addEventListener("keydown", (event) => {
|
|
524
|
+
const step = event.shiftKey ? 40 : 8;
|
|
525
|
+
if (event.key === "ArrowLeft") setPanelWidth(side, panels[side].width - step * outward);
|
|
526
|
+
else if (event.key === "ArrowRight") setPanelWidth(side, panels[side].width + step * outward);
|
|
527
|
+
else return;
|
|
528
|
+
event.preventDefault();
|
|
529
|
+
// And it stops here. The canvas nudges the selected element on the same two
|
|
530
|
+
// keys, so a resize keypress that was allowed to bubble resized a panel *and*
|
|
531
|
+
// committed a one-pixel move — a write from a gesture that is supposed to
|
|
532
|
+
// touch nothing but chrome.
|
|
533
|
+
event.stopPropagation();
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function wirePanels() {
|
|
538
|
+
loadPanels();
|
|
539
|
+
applyPanels();
|
|
540
|
+
initDivider("left", ui.dividerLeft);
|
|
541
|
+
initDivider("right", ui.dividerRight);
|
|
542
|
+
const flip = (side) => {
|
|
543
|
+
togglePanel(side);
|
|
544
|
+
focusPanelControl(side);
|
|
545
|
+
};
|
|
546
|
+
ui.toggleLeft.addEventListener("click", () => flip("left"));
|
|
547
|
+
ui.toggleRight.addEventListener("click", () => flip("right"));
|
|
548
|
+
for (const [side, edge] of [["left", ui.edgeLeft], ["right", ui.edgeRight]]) {
|
|
549
|
+
edge.addEventListener("click", () => flip(side));
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// -- load and mount ----------------------------------------------------------
|
|
554
|
+
|
|
555
|
+
async function loadDocument({ keepCamera = false } = {}) {
|
|
556
|
+
const [stateResponse, fileResponse] = await Promise.all([api("state"), api("file")]);
|
|
557
|
+
if (fileResponse.status !== 200) {
|
|
558
|
+
toast(`Cannot read the file: ${fileResponse.status}`, "refused");
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
state.file = stateResponse.payload?.file ?? state.file;
|
|
562
|
+
state.path = stateResponse.payload?.path ?? state.path;
|
|
563
|
+
state.readOnly = Boolean(stateResponse.payload?.readOnly);
|
|
564
|
+
state.readOnlyReason = stateResponse.payload?.readOnlyReason ?? "";
|
|
565
|
+
state.canUndo = Boolean(stateResponse.payload?.canUndo);
|
|
566
|
+
state.canRedo = Boolean(stateResponse.payload?.canRedo);
|
|
567
|
+
state.text = fileResponse.payload;
|
|
568
|
+
state.revision = fileResponse.revision ?? stateResponse.payload?.revision ?? "";
|
|
569
|
+
|
|
570
|
+
document.title = `${state.file} — JustSkill Design`;
|
|
571
|
+
|
|
572
|
+
mountAll();
|
|
573
|
+
if (!keepCamera) fitAll();
|
|
574
|
+
buildTree();
|
|
575
|
+
renderChrome();
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* The geometric findings, in the shape the drawer already renders.
|
|
580
|
+
*
|
|
581
|
+
* The service sends the file text and its metadata, never a document tree, so
|
|
582
|
+
* the browser is the one that decides what the file means. This check is pure,
|
|
583
|
+
* so it runs here on the same parse the canvas renders — the arithmetic and the
|
|
584
|
+
* drawing cannot disagree about what the file says.
|
|
585
|
+
*
|
|
586
|
+
* It is deliberately the only validator family the drawer shows. The rest of the
|
|
587
|
+
* report belongs to `node design.mjs validate`, which is where a writer reads it.
|
|
588
|
+
*/
|
|
589
|
+
function geometryDiagnostics(doc) {
|
|
590
|
+
const { tokens } = collectTokens(doc);
|
|
591
|
+
return lintGeometry(doc, tokens).map((entry) => ({
|
|
592
|
+
severity: entry.severity,
|
|
593
|
+
code: entry.code,
|
|
594
|
+
message: entry.message,
|
|
595
|
+
target: entry.target,
|
|
596
|
+
path: entry.path,
|
|
597
|
+
hint: entry.hint,
|
|
598
|
+
}));
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Contrast, as the drawer wants it.
|
|
603
|
+
*
|
|
604
|
+
* Contrast is in the drawer because of what the count on the Problems button
|
|
605
|
+
* means. It is read as "this file has this many problems", and the canvas is the
|
|
606
|
+
* only place a reader sees the drawing they are being told about. A file the
|
|
607
|
+
* canvas called clean and `design.mjs validate` called 22 warnings is the canvas
|
|
608
|
+
* lying by omission — the same disagreement between two readers of one result that
|
|
609
|
+
* the `add` receipt used to have.
|
|
610
|
+
*
|
|
611
|
+
* The rest of `validateDesign` is deliberately not run. Structure and property
|
|
612
|
+
* findings are about the file's shape, not the drawing, and a file being edited is
|
|
613
|
+
* allowed to be half-written; 43 rows of `structure/missing-id` in a layout tool is
|
|
614
|
+
* noise that buries the layout. `design.mjs validate` is the full report, and it is
|
|
615
|
+
* the one the write funnel consults.
|
|
616
|
+
*/
|
|
617
|
+
function contrastDiagnostics(doc) {
|
|
618
|
+
const { tokens } = collectTokens(doc);
|
|
619
|
+
return lintContrast(doc, tokens).map((entry) => ({
|
|
620
|
+
severity: entry.severity ?? "warning",
|
|
621
|
+
code: entry.code,
|
|
622
|
+
message: entry.message,
|
|
623
|
+
target: entry.target,
|
|
624
|
+
path: entry.path,
|
|
625
|
+
hint: entry.hint,
|
|
626
|
+
}));
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* The box an absolutely positioned element is really laid out against.
|
|
631
|
+
*
|
|
632
|
+
* A child in flow is laid out by its parent, so the parent is the box to compare
|
|
633
|
+
* it with. An absolutely positioned child is not: it resolves against its nearest
|
|
634
|
+
* positioned ancestor, and a static parent in between is not that box. Comparing
|
|
635
|
+
* with it reported boxes as leaving a parent they were never inside — and the
|
|
636
|
+
* arithmetic pass in `lib/geometry.mjs` applies the same rule, so both passes name
|
|
637
|
+
* the same box and the merge can still collapse them.
|
|
638
|
+
*
|
|
639
|
+
* A page counts, the same way it does there, because a page may not be positioned.
|
|
640
|
+
*/
|
|
641
|
+
function containingBlockOf(element) {
|
|
642
|
+
let node = element.parentElement;
|
|
643
|
+
while (node) {
|
|
644
|
+
const position = getComputedStyle(node).position;
|
|
645
|
+
if ((position && position !== "static") || node.getAttribute?.("data-type") === "page") return node;
|
|
646
|
+
node = node.parentElement;
|
|
647
|
+
}
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* What the browser can prove and the arithmetic cannot.
|
|
653
|
+
*
|
|
654
|
+
* `lib/geometry.mjs` works on the file, so it is blind to anything that needs a
|
|
655
|
+
* measurement: a run of text, the intrinsic size of an image. Here the page is
|
|
656
|
+
* mounted, so every one of those has a real box. This pass reads them.
|
|
657
|
+
*
|
|
658
|
+
* Three traps, each one a way to report something untrue:
|
|
659
|
+
*
|
|
660
|
+
* - **Zoom.** `applyCamera` puts `transform: scale(zoom)` on `ui.canvas`, so
|
|
661
|
+
* every rectangle is in screen pixels. Both rectangles are scaled the same way,
|
|
662
|
+
* so the comparison itself does not care — but the numbers in the message do, so
|
|
663
|
+
* everything is divided back to CSS pixels first.
|
|
664
|
+
* - **A repeated id.** A file can carry the same `data-id` twice — that is a
|
|
665
|
+
* validation error, and the canvas goes read-only for it, but the pages still
|
|
666
|
+
* mount and an op could not tell the two apart. A finding about one of them
|
|
667
|
+
* would name an element the writer cannot address, so a repeated id is skipped.
|
|
668
|
+
* - **Sub-pixel layout.** A fraction of a pixel of overhang is rounding, not a
|
|
669
|
+
* defect. `lib/geometry.mjs` holds the tolerance.
|
|
670
|
+
*/
|
|
671
|
+
function measuredGeometryDiagnostics() {
|
|
672
|
+
const zoom = state.camera.zoom || 1;
|
|
673
|
+
const mounted = [];
|
|
674
|
+
const counts = new Map();
|
|
675
|
+
|
|
676
|
+
for (const host of ui.canvas.querySelectorAll(".page-box .page-host")) {
|
|
677
|
+
const root = host.shadowRoot;
|
|
678
|
+
if (!root) continue;
|
|
679
|
+
for (const element of root.querySelectorAll("[data-id]")) {
|
|
680
|
+
const id = element.getAttribute("data-id");
|
|
681
|
+
counts.set(id, (counts.get(id) || 0) + 1);
|
|
682
|
+
mounted.push([element, id]);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const worstByParent = new Map();
|
|
687
|
+
for (const [element, id] of mounted) {
|
|
688
|
+
if (counts.get(id) > 1) continue;
|
|
689
|
+
// A turned box has no axis-aligned answer: its painted rect is the bounding
|
|
690
|
+
// box, not the box. Saying anything about it would be a guess dressed as a
|
|
691
|
+
// measurement, so this pass says nothing — the same abstention
|
|
692
|
+
// `lib/geometry.mjs` makes for the arithmetic one.
|
|
693
|
+
if (rotationOfElement(element) !== 0) continue;
|
|
694
|
+
// Which box this element is measured against. A positioned element is the one
|
|
695
|
+
// case where the parent is the wrong answer; for everything else it is right,
|
|
696
|
+
// and it is also the element the finding has to name.
|
|
697
|
+
const selfStyle = getComputedStyle(element);
|
|
698
|
+
const parent = selfStyle.position === "absolute"
|
|
699
|
+
? containingBlockOf(element)
|
|
700
|
+
: element.parentElement?.closest?.("[data-id]") ?? null;
|
|
701
|
+
if (!parent) continue;
|
|
702
|
+
|
|
703
|
+
const self = element.getBoundingClientRect();
|
|
704
|
+
if (self.width < 1 || self.height < 1) continue;
|
|
705
|
+
const outer = parent.getBoundingClientRect();
|
|
706
|
+
if (outer.width < 1 || outer.height < 1) continue;
|
|
707
|
+
|
|
708
|
+
// Back to CSS pixels, where `getComputedStyle` also lives.
|
|
709
|
+
const style = getComputedStyle(parent);
|
|
710
|
+
const num = (value) => parseFloat(value) || 0;
|
|
711
|
+
const padLeft = num(style.paddingLeft) + num(style.borderLeftWidth);
|
|
712
|
+
const padTop = num(style.paddingTop) + num(style.borderTopWidth);
|
|
713
|
+
const contentW = outer.width / zoom - padLeft - num(style.paddingRight) - num(style.borderRightWidth);
|
|
714
|
+
const contentH = outer.height / zoom - padTop - num(style.paddingBottom) - num(style.borderBottomWidth);
|
|
715
|
+
|
|
716
|
+
const left = (self.left - outer.left) / zoom - padLeft;
|
|
717
|
+
const top = (self.top - outer.top) / zoom - padTop;
|
|
718
|
+
const width = self.width / zoom;
|
|
719
|
+
const height = self.height / zoom;
|
|
720
|
+
|
|
721
|
+
const clipped = ["hidden", "auto", "scroll"].includes(style.overflow);
|
|
722
|
+
const measured = overhangOf(
|
|
723
|
+
{ left, top, width, height },
|
|
724
|
+
{ width: contentW, height: contentH },
|
|
725
|
+
{ positioned: selfStyle.position === "absolute", clips: clipped },
|
|
726
|
+
);
|
|
727
|
+
if (!measured) continue;
|
|
728
|
+
const { over, code } = measured;
|
|
729
|
+
|
|
730
|
+
const parentId = parent.getAttribute("data-id");
|
|
731
|
+
|
|
732
|
+
// Who the finding is about, and how many of them to emit.
|
|
733
|
+
//
|
|
734
|
+
// `geometry/absolute-outside` is about the node that is out of place, so it
|
|
735
|
+
// is reported per node, exactly as `lib/geometry.mjs` reports it. The other
|
|
736
|
+
// two are about the frame that is too small: a row of three children that do
|
|
737
|
+
// not fit produces the same fact three times, and three copies of one fact is
|
|
738
|
+
// how a report stops being read. One per frame is also what the writer has to
|
|
739
|
+
// change, and it is what the arithmetic pass names, so the two passes
|
|
740
|
+
// describe the same defect the same way and the merge can collapse them.
|
|
741
|
+
const aboutChild = code === "geometry/absolute-outside";
|
|
742
|
+
const key = aboutChild ? `${code}|${id}` : `${code}|${parentId}`;
|
|
743
|
+
const previous = worstByParent.get(key);
|
|
744
|
+
if (previous && previous.over >= over) continue;
|
|
745
|
+
worstByParent.set(key, {
|
|
746
|
+
severity: "warning",
|
|
747
|
+
code,
|
|
748
|
+
target: aboutChild ? id : parentId,
|
|
749
|
+
over,
|
|
750
|
+
message: code === "geometry/absolute-outside"
|
|
751
|
+
? `A positioned child ${Math.round(width)}x${Math.round(height)} at (${Math.round(left)}, ${Math.round(top)}) is not fully inside this box's content area of ${Math.round(contentW)}x${Math.round(contentH)}`
|
|
752
|
+
: code === "geometry/clipped"
|
|
753
|
+
? `A child overhangs this box's content area of ${Math.round(contentW)}x${Math.round(contentH)} by ${Math.round(over)}px, and the box hides the rest`
|
|
754
|
+
: `A child overhangs this box's content area of ${Math.round(contentW)}x${Math.round(contentH)} by ${Math.round(over)}px`,
|
|
755
|
+
hint: `measured on the canvas: the widest child is ${Math.round(width)}x${Math.round(height)}, so this is the box the browser drew, not an estimate`,
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
return [...worstByParent.values()].map(({ over, ...entry }) => entry);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* The arithmetic findings, with the measured ones given precedence.
|
|
763
|
+
*
|
|
764
|
+
* Both passes can name the same element and the same code. The measured one
|
|
765
|
+
* carries the box the browser actually drew, so it is the one worth reading.
|
|
766
|
+
*
|
|
767
|
+
* The per-code limit is applied **after** the merge, because the merge is where
|
|
768
|
+
* the findings the reader will see are finally assembled. `lintGeometry` keeps the
|
|
769
|
+
* limit on its own half, and the measured half can add more of a code the
|
|
770
|
+
* arithmetic half never saw at all — 25 frames whose text overflows are invisible
|
|
771
|
+
* to the arithmetic and 25 rows in the drawer is exactly the report §6 promises
|
|
772
|
+
* not to hand anyone.
|
|
773
|
+
*/
|
|
774
|
+
function mergeGeometry(arithmetic, measured) {
|
|
775
|
+
const measuredKeys = new Set(measured.map((entry) => `${entry.code}|${entry.target}`));
|
|
776
|
+
const merged = [
|
|
777
|
+
...arithmetic.filter((entry) => !measuredKeys.has(`${entry.code}|${entry.target}`)),
|
|
778
|
+
...measured,
|
|
779
|
+
];
|
|
780
|
+
const counts = new Map();
|
|
781
|
+
return merged.filter((entry) => {
|
|
782
|
+
const seen = counts.get(entry.code) || 0;
|
|
783
|
+
counts.set(entry.code, seen + 1);
|
|
784
|
+
return seen < MAX_REPORTED_PER_CODE;
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function recomputeDiagnostics() {
|
|
789
|
+
if (!state.doc) return;
|
|
790
|
+
const arithmetic = geometryDiagnostics(state.doc);
|
|
791
|
+
state.diagnostics = [
|
|
792
|
+
...(state.doc.diagnostics ?? []),
|
|
793
|
+
...mergeGeometry(arithmetic, measuredGeometryDiagnostics()),
|
|
794
|
+
...contrastDiagnostics(state.doc),
|
|
795
|
+
];
|
|
796
|
+
// `renderProblems` writes the badge as well as the list, so the two cannot
|
|
797
|
+
// disagree. The badge used to be written only by `renderChrome`, which runs
|
|
798
|
+
// before the measured pass finishes: the drawer filled in and the badge went on
|
|
799
|
+
// showing the arithmetic count.
|
|
800
|
+
renderProblems();
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Wait until the mounted pages can be measured.
|
|
805
|
+
*
|
|
806
|
+
* Text laid out in a fallback family is a different width, and an image with no
|
|
807
|
+
* bytes yet is 0x0. A box measured before either arrives is a box the reader never
|
|
808
|
+
* sees, reported as overflow that is not there. Fonts first, then the images that
|
|
809
|
+
* are still loading.
|
|
810
|
+
*
|
|
811
|
+
* The images are given a deadline. An image that never answers — a host that
|
|
812
|
+
* accepts the connection and then says nothing — must not leave the drawer
|
|
813
|
+
* showing a half-finished report for the rest of the session. Two seconds is
|
|
814
|
+
* longer than a local file takes and shorter than a reader's patience; an image
|
|
815
|
+
* that misses it is measured as it stands, and the next mount measures it again.
|
|
816
|
+
*/
|
|
817
|
+
const IMAGE_WAIT_MS = 2000;
|
|
818
|
+
|
|
819
|
+
function layoutSettled() {
|
|
820
|
+
const fonts = typeof document.fonts?.ready?.then === "function" ? document.fonts.ready : Promise.resolve();
|
|
821
|
+
return fonts.then(() => {
|
|
822
|
+
const pending = [];
|
|
823
|
+
for (const host of ui.canvas.querySelectorAll(".page-box .page-host")) {
|
|
824
|
+
for (const image of host.shadowRoot?.querySelectorAll("img") ?? []) {
|
|
825
|
+
if (image.complete) continue;
|
|
826
|
+
pending.push(
|
|
827
|
+
new Promise((resolve) => {
|
|
828
|
+
const done = () => resolve();
|
|
829
|
+
image.addEventListener("load", done, { once: true });
|
|
830
|
+
image.addEventListener("error", done, { once: true });
|
|
831
|
+
}),
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
if (pending.length === 0) return undefined;
|
|
836
|
+
return Promise.race([
|
|
837
|
+
Promise.all(pending),
|
|
838
|
+
new Promise((resolve) => setTimeout(resolve, IMAGE_WAIT_MS)),
|
|
839
|
+
]);
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function mountAll() {
|
|
844
|
+
const started = performance.now();
|
|
845
|
+
const generation = ++mountGeneration;
|
|
846
|
+
state.doc = parse(state.text);
|
|
847
|
+
state.model = renderModel(state.doc);
|
|
848
|
+
// The parser's syntax findings, plus what the layout arithmetic can prove.
|
|
849
|
+
// The parser's syntax findings, plus what the file itself can be held to. The
|
|
850
|
+
// measured pass adds the rest once the page has settled.
|
|
851
|
+
state.diagnostics = [
|
|
852
|
+
...(state.doc.diagnostics ?? []),
|
|
853
|
+
...geometryDiagnostics(state.doc),
|
|
854
|
+
...contrastDiagnostics(state.doc),
|
|
855
|
+
];
|
|
856
|
+
state.boxes = new Map();
|
|
857
|
+
const positions = arrangePages(state.model.pages, { gap: 48, perRow: 4 });
|
|
858
|
+
state.model.pages.forEach((page, index) => {
|
|
859
|
+
const position = positions[index] ?? { x: 0, y: 0 };
|
|
860
|
+
const declaredHeight = parseFloat(page.height) || 0;
|
|
861
|
+
state.boxes.set(page.id, {
|
|
862
|
+
position,
|
|
863
|
+
width: parseFloat(page.width) || 390,
|
|
864
|
+
// The content decides, unless the page declares a height. The number here
|
|
865
|
+
// is provisional and `measurePageBoxes` replaces it before the camera
|
|
866
|
+
// fits the view.
|
|
867
|
+
height: declaredHeight || 844,
|
|
868
|
+
declaredHeight,
|
|
869
|
+
});
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
applyWebfonts();
|
|
873
|
+
|
|
874
|
+
ui.canvas.textContent = "";
|
|
875
|
+
for (const page of state.model.pages) {
|
|
876
|
+
ui.canvas.appendChild(buildPageBox(page));
|
|
877
|
+
}
|
|
878
|
+
measurePageBoxes();
|
|
879
|
+
applyCamera();
|
|
880
|
+
drawOverlay();
|
|
881
|
+
renderProblems();
|
|
882
|
+
|
|
883
|
+
// The measured pass has to wait until the page has settled. Text laid out in a
|
|
884
|
+
// fallback family is a different width, and an image with no bytes yet is 0x0; a
|
|
885
|
+
// box measured before either arrives is a box the reader never sees, reported as
|
|
886
|
+
// overflow that is not there. The arithmetic pass above needs no waiting, so the
|
|
887
|
+
// drawer is never empty while this runs.
|
|
888
|
+
//
|
|
889
|
+
// It runs once per mount. A later change — a resize, an edit, a font that
|
|
890
|
+
// arrives after its deadline — is measured by the next mount, not by this one.
|
|
891
|
+
layoutSettled().then(() => {
|
|
892
|
+
// A newer mount has already replaced this one; its own pass will run.
|
|
893
|
+
if (generation !== mountGeneration) return;
|
|
894
|
+
recomputeDiagnostics();
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
const ms = Math.round(performance.now() - started);
|
|
898
|
+
ui.revision.textContent = `${state.revision} · ${ms}ms`;
|
|
899
|
+
ui.readonly.hidden = !state.readOnly;
|
|
900
|
+
if (state.readOnly) ui.readonly.title = state.readOnlyReason;
|
|
901
|
+
// Ops need a document to apply to, so a file that does not parse can only be repaired
|
|
902
|
+
// by writing its text back. That is the one thing `/api/save` is for, and this button
|
|
903
|
+
// is the only client that calls it.
|
|
904
|
+
document.getElementById("fix-text").hidden = !state.readOnly;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Ask for the same fonts `export` declares.
|
|
909
|
+
*
|
|
910
|
+
* A design that wraps differently on the canvas than in the deliverable is a
|
|
911
|
+
* design the author approved without seeing. The canvas cannot inline a font, so
|
|
912
|
+
* it requests the same stylesheets the deliverable will, and a machine with no
|
|
913
|
+
* network falls back exactly as the deliverable would.
|
|
914
|
+
*/
|
|
915
|
+
function applyWebfonts() {
|
|
916
|
+
for (const link of document.querySelectorAll("link[data-canvas-font]")) link.remove();
|
|
917
|
+
// One request per family for the whole file. One per PAGE is a request per
|
|
918
|
+
// page for the same family — ten stylesheets for two families, and the same
|
|
919
|
+
// face declared more than once.
|
|
920
|
+
const hrefs = new Set(webfontLinks(state.model));
|
|
921
|
+
for (const href of hrefs) {
|
|
922
|
+
const link = document.createElement("link");
|
|
923
|
+
link.rel = "stylesheet";
|
|
924
|
+
link.href = href;
|
|
925
|
+
link.dataset.canvasFont = "";
|
|
926
|
+
document.head.appendChild(link);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function buildPageBox(page) {
|
|
931
|
+
const box = state.boxes.get(page.id);
|
|
932
|
+
const element = document.createElement("div");
|
|
933
|
+
element.className = "page-box";
|
|
934
|
+
element.dataset.pageId = page.id;
|
|
935
|
+
element.style.left = `${box.position.x}px`;
|
|
936
|
+
element.style.top = `${box.position.y}px`;
|
|
937
|
+
element.style.width = `${box.width}px`;
|
|
938
|
+
// A page that declares no height is as tall as its content. Pinning it to a
|
|
939
|
+
// guessed 844 is how a page gets a scrollbar in the file and a hard cut on the
|
|
940
|
+
// canvas, with the bottom third of the design simply not drawn.
|
|
941
|
+
if (box.declaredHeight) element.style.height = `${box.declaredHeight}px`;
|
|
942
|
+
|
|
943
|
+
// The paper is the page's own shape. A page can round its own corners — a card
|
|
944
|
+
// artboard is a 24px card, not a screen — and the paper sits behind
|
|
945
|
+
// it: a paper with the chrome's own 10px radius under a 24px page shows a white
|
|
946
|
+
// crescent in every corner, a shape the page never drew. The page's radius
|
|
947
|
+
// reaches the paper and the clip, so a page that declares a height is cut at
|
|
948
|
+
// its own shape and not at the paper's.
|
|
949
|
+
const pageRadius = pagePaperRadius(page, state.model.tokens);
|
|
950
|
+
if (pageRadius) element.style.borderRadius = pageRadius;
|
|
951
|
+
|
|
952
|
+
const label = document.createElement("div");
|
|
953
|
+
label.className = "page-label";
|
|
954
|
+
label.textContent = page.name;
|
|
955
|
+
element.appendChild(label);
|
|
956
|
+
|
|
957
|
+
const host = document.createElement("div");
|
|
958
|
+
host.className = "page-host";
|
|
959
|
+
host.style.width = "100%";
|
|
960
|
+
host.style.height = box.declaredHeight ? "100%" : "auto";
|
|
961
|
+
host.style.overflow = box.declaredHeight ? "hidden" : "visible";
|
|
962
|
+
host.style.borderRadius = pageRadius || "9px";
|
|
963
|
+
const root = host.attachShadow({ mode: "open" });
|
|
964
|
+
const style = document.createElement("style");
|
|
965
|
+
// `canvasMountCss` is the file's rules plus the one rule the canvas owns: a
|
|
966
|
+
// page's outer margin is the document flow's business, and on the canvas the
|
|
967
|
+
// paper is exactly the page. See that function for what goes wrong without it.
|
|
968
|
+
style.textContent = canvasMountCss(state.model);
|
|
969
|
+
root.appendChild(style);
|
|
970
|
+
|
|
971
|
+
// The mount IS the page: same tag, same declarations, same `data-id`. The
|
|
972
|
+
// canvas used to mount the page's children into a plain block `<div>`, which
|
|
973
|
+
// threw away `display:flex`, `align-items`, `gap`, `padding` and the
|
|
974
|
+
// background in one go. Every child with no width of its own then filled the
|
|
975
|
+
// page instead of hugging its content, and every padded child grew by its own
|
|
976
|
+
// padding. The file said one thing; the canvas drew another.
|
|
977
|
+
const mount = document.createElement("section");
|
|
978
|
+
mount.setAttribute("data-id", page.id);
|
|
979
|
+
mount.setAttribute("data-type", "page");
|
|
980
|
+
mount.setAttribute("data-name", page.name);
|
|
981
|
+
mount.style.cssText = styleStringOf(page.declarations);
|
|
982
|
+
mount.innerHTML = page.markup;
|
|
983
|
+
root.appendChild(mount);
|
|
984
|
+
element.appendChild(host);
|
|
985
|
+
|
|
986
|
+
return element;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/** The height a page takes on the canvas: its own, or what its content needs. */
|
|
990
|
+
function measurePageBoxes() {
|
|
991
|
+
for (const page of state.model.pages) {
|
|
992
|
+
const box = state.boxes.get(page.id);
|
|
993
|
+
if (!box || box.declaredHeight) continue;
|
|
994
|
+
const host = ui.canvas.querySelector(`.page-box[data-page-id="${CSS.escape(page.id)}"] .page-host`);
|
|
995
|
+
const mount = host?.shadowRoot?.querySelector("section[data-type=\"page\"]");
|
|
996
|
+
if (!mount) continue;
|
|
997
|
+
const height = mount.getBoundingClientRect().height;
|
|
998
|
+
if (Number.isFinite(height) && height > 0) box.height = height;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// -- mapping a click back to a data-id --------------------------------------
|
|
1003
|
+
|
|
1004
|
+
/** The nearest ancestor-or-self that carries a `data-id`. */
|
|
1005
|
+
function addressOf(node) {
|
|
1006
|
+
let current = node;
|
|
1007
|
+
while (current && current.nodeType === 1) {
|
|
1008
|
+
const id = current.getAttribute?.("data-id");
|
|
1009
|
+
if (id) return { id, element: current };
|
|
1010
|
+
current = current.parentNode;
|
|
1011
|
+
}
|
|
1012
|
+
return null;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* What a canvas event is over, and *which page* it is over.
|
|
1017
|
+
*
|
|
1018
|
+
* The page is not decoration. The canvas mounts every page of the document, and a
|
|
1019
|
+
* page is a separate shadow root, so an id names one element *per page it appears
|
|
1020
|
+
* in* — the same id in two artboards is two mounts, and a lookup that does not say
|
|
1021
|
+
* which artboard to look in answers with the one somewhere else on the canvas.
|
|
1022
|
+
* The composed path knows: the page box is in it, after the element.
|
|
1023
|
+
*/
|
|
1024
|
+
function targetFromEvent(event) {
|
|
1025
|
+
const path = event.composedPath ? event.composedPath() : [];
|
|
1026
|
+
const pageBox = path.find((entry) => entry?.classList?.contains("page-box")) ?? null;
|
|
1027
|
+
const pageId = pageBox?.dataset.pageId ?? null;
|
|
1028
|
+
for (const entry of path) {
|
|
1029
|
+
if (entry?.nodeType !== 1) continue;
|
|
1030
|
+
if (entry === pageBox) {
|
|
1031
|
+
const pageNode = state.model.pages.find((p) => p.id === pageId)?.node;
|
|
1032
|
+
return pageNode ? { id: pageId, element: entry, kind: "page", pageId } : null;
|
|
1033
|
+
}
|
|
1034
|
+
if (entry.hasAttribute?.("data-id")) {
|
|
1035
|
+
return { id: entry.getAttribute("data-id"), element: entry, kind: "element", pageId };
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
return null;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/** The live shadow-root element for a data-id, so the overlay can measure it. */
|
|
1042
|
+
function elementForId(id) {
|
|
1043
|
+
for (const host of ui.canvas.querySelectorAll("*")) {
|
|
1044
|
+
const root = host.shadowRoot;
|
|
1045
|
+
if (!root) continue;
|
|
1046
|
+
const found = root.querySelector(`[data-id="${CSS.escape(id)}"]`);
|
|
1047
|
+
if (found) return found;
|
|
1048
|
+
}
|
|
1049
|
+
const pageBox = ui.canvas.querySelector(`.page-box[data-page-id="${CSS.escape(id)}"]`);
|
|
1050
|
+
return pageBox ?? null;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// -- overlay -----------------------------------------------------------------
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* The boxes on the canvas, in the order they are painted.
|
|
1057
|
+
*
|
|
1058
|
+
* the selection solid, the saturated blue, with the four resize handles
|
|
1059
|
+
* the level solid, the same two pixels, lighter, no handles — what the
|
|
1060
|
+
* pointer is standing on
|
|
1061
|
+
* its children dashed, one pixel — what a double-click steps into
|
|
1062
|
+
* the one under the pointer dashed, two pixels and brighter, so "what will I
|
|
1063
|
+
* get" is not a guess between two boxes that overlap
|
|
1064
|
+
*
|
|
1065
|
+
* Weight says what kind of box it is — a level, or a child of one — and the
|
|
1066
|
+
* colour says *which* level: the committed one or the one the pointer is standing
|
|
1067
|
+
* on. The other way round made the hover box thinner than the children it was
|
|
1068
|
+
* framing, which is backwards; `styles.css` carries the measurements.
|
|
1069
|
+
*
|
|
1070
|
+
* The level is drawn as a box of its own only when it is not already the
|
|
1071
|
+
* selection, because two borders on one element reads as a mistake.
|
|
1072
|
+
*/
|
|
1073
|
+
function drawOverlay() {
|
|
1074
|
+
ui.overlay.textContent = "";
|
|
1075
|
+
const rect = ui.viewport.getBoundingClientRect();
|
|
1076
|
+
|
|
1077
|
+
if (state.selection) {
|
|
1078
|
+
// The page it was picked in, when that is known; otherwise the id is all
|
|
1079
|
+
// there is and the first match is the answer, as it always was.
|
|
1080
|
+
const element = (state.selectionPage ? elementInPage(state.selectionPage, state.selection) : null)
|
|
1081
|
+
?? elementForId(state.selection);
|
|
1082
|
+
if (element) ui.overlay.appendChild(boxOver(element, rect, "outline", { handles: true, id: state.selection }));
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
const picture = hoverPicture();
|
|
1086
|
+
if (!picture) return;
|
|
1087
|
+
|
|
1088
|
+
if (picture.levelId !== state.selection) {
|
|
1089
|
+
const element = elementInPage(picture.pageId, picture.levelId);
|
|
1090
|
+
if (element) ui.overlay.appendChild(boxOver(element, rect, "outline level", { id: picture.levelId }));
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
for (const child of picture.children) {
|
|
1094
|
+
const element = elementInPage(picture.pageId, child.id);
|
|
1095
|
+
if (!element) continue;
|
|
1096
|
+
const className = child.id === picture.underId ? "outline child under" : "outline child";
|
|
1097
|
+
ui.overlay.appendChild(boxOver(element, rect, className, { id: child.id }));
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* The angle, in degrees, that an element and its ancestors are turned by.
|
|
1103
|
+
*
|
|
1104
|
+
* The format writes a rotation as `transform: rotate(<deg>)` and nothing else, so
|
|
1105
|
+
* the inline style is the whole truth: no computed matrix to parse, no camera
|
|
1106
|
+
* scale to divide out, no transition to race. The chain stops at the page,
|
|
1107
|
+
* because a page cannot be rotated (`style/transform-on-page`).
|
|
1108
|
+
*/
|
|
1109
|
+
const INLINE_ROTATE = /rotate\(\s*(-?\d+(?:\.\d+)?)\s*deg\s*\)/;
|
|
1110
|
+
|
|
1111
|
+
function ownRotationOf(element) {
|
|
1112
|
+
const raw = element?.style?.transform;
|
|
1113
|
+
if (!raw) return 0;
|
|
1114
|
+
const match = INLINE_ROTATE.exec(raw);
|
|
1115
|
+
return match ? Number(match[1]) : 0;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function rotationOfElement(element) {
|
|
1119
|
+
let total = 0;
|
|
1120
|
+
for (let node = element; node; node = node.parentElement) {
|
|
1121
|
+
total += ownRotationOf(node);
|
|
1122
|
+
if (node.getAttribute?.("data-type") === "page") break;
|
|
1123
|
+
}
|
|
1124
|
+
return total;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/** Turn a vector by `-degrees`, which is how a screen delta becomes a local one. */
|
|
1128
|
+
function unturn(dx, dy, degrees) {
|
|
1129
|
+
if (!degrees) return [dx, dy];
|
|
1130
|
+
const radians = (degrees * Math.PI) / 180;
|
|
1131
|
+
const cos = Math.cos(radians);
|
|
1132
|
+
const sin = Math.sin(radians);
|
|
1133
|
+
return [dx * cos + dy * sin, -dx * sin + dy * cos];
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* A screen-space delta, expressed in the axes of the element's parent.
|
|
1138
|
+
*
|
|
1139
|
+
* `left` and `top` live in the parent's coordinate system, so a drag inside a
|
|
1140
|
+
* rotated card has to be unturned before it is written. The element's own
|
|
1141
|
+
* rotation is not part of this: turning a card does not change which way its
|
|
1142
|
+
* parent's axes point.
|
|
1143
|
+
*/
|
|
1144
|
+
function parentDelta(element, dx, dy) {
|
|
1145
|
+
return unturn(dx, dy, rotationOfElement(element) - ownRotationOf(element));
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
function boxOver(element, rect, className, { handles = false, id = null } = {}) {
|
|
1149
|
+
const box = element.getBoundingClientRect();
|
|
1150
|
+
const angle = rotationOfElement(element);
|
|
1151
|
+
const node = document.createElement("div");
|
|
1152
|
+
node.className = className;
|
|
1153
|
+
// Which element this box is around. Nothing reads it but a test and a
|
|
1154
|
+
// console; a box that cannot say what it is a box around is a box nobody can
|
|
1155
|
+
// debug.
|
|
1156
|
+
if (id) node.dataset.id = id;
|
|
1157
|
+
let width = box.width;
|
|
1158
|
+
let height = box.height;
|
|
1159
|
+
if (angle === 0) {
|
|
1160
|
+
node.style.left = `${box.left - rect.left}px`;
|
|
1161
|
+
node.style.top = `${box.top - rect.top}px`;
|
|
1162
|
+
} else {
|
|
1163
|
+
// A turned box has an axis-aligned bounding rect, which is bigger than the
|
|
1164
|
+
// box. Its centre is the same point either way, so the outline is drawn at
|
|
1165
|
+
// the element's real size around that centre and turned with it — otherwise
|
|
1166
|
+
// every tilted card would wear a box with four corners of air in it.
|
|
1167
|
+
const zoom = state.camera.zoom || 1;
|
|
1168
|
+
width = element.offsetWidth * zoom;
|
|
1169
|
+
height = element.offsetHeight * zoom;
|
|
1170
|
+
node.style.left = `${box.left - rect.left + box.width / 2 - width / 2}px`;
|
|
1171
|
+
node.style.top = `${box.top - rect.top + box.height / 2 - height / 2}px`;
|
|
1172
|
+
node.style.transform = `rotate(${angle}deg)`;
|
|
1173
|
+
}
|
|
1174
|
+
node.style.width = `${width}px`;
|
|
1175
|
+
node.style.height = `${height}px`;
|
|
1176
|
+
// At 10% a phone page is 39px wide and a card inside it is 32×15: four 9px
|
|
1177
|
+
// handles on that box cover the thing they are supposed to be grabbing, and a
|
|
1178
|
+
// corner drag at that size cannot be aimed anyway. The ring stays; the handles
|
|
1179
|
+
// wait until the box is big enough to have corners.
|
|
1180
|
+
if (width < 32 || height < 24) node.classList.add("small");
|
|
1181
|
+
if (!handles) return node;
|
|
1182
|
+
for (const [hx, hy] of [[0, 0], [1, 0], [0, 1], [1, 1]]) {
|
|
1183
|
+
const handle = document.createElement("div");
|
|
1184
|
+
handle.className = "handle";
|
|
1185
|
+
handle.dataset.handle = `${hx}${hy}`;
|
|
1186
|
+
handle.style.left = `${hx ? width : 0}px`;
|
|
1187
|
+
handle.style.top = `${hy ? height : 0}px`;
|
|
1188
|
+
node.appendChild(handle);
|
|
1189
|
+
}
|
|
1190
|
+
return node;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// -- chrome ------------------------------------------------------------------
|
|
1194
|
+
|
|
1195
|
+
function renderChrome() {
|
|
1196
|
+
ui.fileName.textContent = state.file;
|
|
1197
|
+
renderLayerTree();
|
|
1198
|
+
|
|
1199
|
+
document.getElementById("undo").disabled = !state.canUndo || state.readOnly;
|
|
1200
|
+
document.getElementById("redo").disabled = !state.canRedo || state.readOnly;
|
|
1201
|
+
// A file that does not parse has nothing to export, and the service would answer 409.
|
|
1202
|
+
// A disabled button says so before the click.
|
|
1203
|
+
//
|
|
1204
|
+
// `Export` writes one page, so it needs a page to name. A selected element cannot name
|
|
1205
|
+
// one — a canvas shows every page at once, each a separate shadow root, so an id is not
|
|
1206
|
+
// unique — and the service's answer to a missing `page` is the first page, which is how
|
|
1207
|
+
// a canvas showing page four came to write page one. `Export all` asks nothing of the
|
|
1208
|
+
// selection.
|
|
1209
|
+
const page = selectedPage(state.model?.pages, state.selection, state.selectionPage);
|
|
1210
|
+
const exportButton = document.getElementById("export-page");
|
|
1211
|
+
exportButton.disabled = state.readOnly || !page;
|
|
1212
|
+
// A disabled button cannot be asked why, so its title carries the reason, and there
|
|
1213
|
+
// are two: a file that does not parse has nothing to export at all, while an
|
|
1214
|
+
// exportable file is only waiting for a page to be selected. "Select a page first" on
|
|
1215
|
+
// a file that cannot be exported is the one answer that would be wrong.
|
|
1216
|
+
exportButton.title = state.readOnly
|
|
1217
|
+
? "The file does not parse, so there is nothing to export."
|
|
1218
|
+
: page
|
|
1219
|
+
? `Write ${page.name} as a standalone HTML file`
|
|
1220
|
+
: "Select a page first — its row in the Layers panel, or its label on the canvas";
|
|
1221
|
+
document.getElementById("export-all").disabled = state.readOnly;
|
|
1222
|
+
|
|
1223
|
+
renderProblems();
|
|
1224
|
+
renderInspector();
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// -- the layer tree ----------------------------------------------------------
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* Rebuild the nested tree from the model.
|
|
1231
|
+
*
|
|
1232
|
+
* Called when the document changes, not when the selection does: `pageTree()`
|
|
1233
|
+
* walks every element of every page, and doing that on each selection change
|
|
1234
|
+
* (which the first version of this panel did, at more than ten times the data)
|
|
1235
|
+
* is a walk whose result nobody edited.
|
|
1236
|
+
*/
|
|
1237
|
+
function buildTree() {
|
|
1238
|
+
state.tree = state.model ? pageRoots(state.model.pages, (page) => pageTree(page.node)) : [];
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* The two buttons in the Layers header.
|
|
1243
|
+
*
|
|
1244
|
+
* They are the standing intent — "everything open" or "everything closed" — not
|
|
1245
|
+
* a loop over the rows that are open right now. That is what makes them O(1) on
|
|
1246
|
+
* a file with three thousand rows, and what makes an expanded branch survive:
|
|
1247
|
+
* the exceptions a person made with a chevron stay exceptions.
|
|
1248
|
+
*/
|
|
1249
|
+
ui.treeExpand.addEventListener("click", () => {
|
|
1250
|
+
expandAll(state.treeView);
|
|
1251
|
+
renderLayerTree();
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
ui.treeCollapse.addEventListener("click", () => {
|
|
1255
|
+
collapseAll(state.treeView);
|
|
1256
|
+
renderLayerTree();
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
/** id -> <li>, rebuilt with the tree, so focus and scrolling need no selector. */
|
|
1260
|
+
let treeRows = new Map();
|
|
1261
|
+
|
|
1262
|
+
function renderLayerTree() {
|
|
1263
|
+
const hadFocus = ui.layerTree.contains(document.activeElement);
|
|
1264
|
+
// A row selected from the canvas has to be reachable — a selection hidden
|
|
1265
|
+
// inside a closed ancestor looks like a panel that lost it. Opened on a
|
|
1266
|
+
// *change* of selection, and on an explicit `expand` from the canvas, but not
|
|
1267
|
+
// on every render: doing that re-opened an ancestor the user had just
|
|
1268
|
+
// collapsed, because the chevron's own click renders the tree it folded, and
|
|
1269
|
+
// the chevron became a control that did nothing.
|
|
1270
|
+
const pending = state.expandRow;
|
|
1271
|
+
if (state.selection && (pending || state.selection !== state.lastSelection)) {
|
|
1272
|
+
openPath(state.tree, state.selection, state.treeView);
|
|
1273
|
+
}
|
|
1274
|
+
state.lastSelection = state.selection;
|
|
1275
|
+
state.expandRow = false;
|
|
1276
|
+
|
|
1277
|
+
ui.layerTree.textContent = "";
|
|
1278
|
+
treeRows = new Map();
|
|
1279
|
+
|
|
1280
|
+
if (state.tree.length === 0) {
|
|
1281
|
+
const empty = document.createElement("li");
|
|
1282
|
+
empty.className = "tree-empty";
|
|
1283
|
+
empty.textContent = state.model ? "No pages." : "Loading…";
|
|
1284
|
+
ui.layerTree.appendChild(empty);
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
const visible = visibleNodes(state.tree, state.treeView);
|
|
1289
|
+
// Roving tabindex: exactly one row carries the tab stop, and it is the row the
|
|
1290
|
+
// arrow keys are standing on.
|
|
1291
|
+
if (!state.treeFocus || !visible.some((node) => node.id === state.treeFocus)) {
|
|
1292
|
+
state.treeFocus = state.selection && visible.some((node) => node.id === state.selection)
|
|
1293
|
+
? state.selection
|
|
1294
|
+
: visible[0]?.id ?? null;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// Rendered by recursing from the roots, not by walking the flat visible list:
|
|
1298
|
+
// the list contains a node *and* its descendants, so drawing from it would
|
|
1299
|
+
// draw every subtree once for the node and once for each of its ancestors.
|
|
1300
|
+
appendTreeItems(ui.layerTree, state.tree);
|
|
1301
|
+
|
|
1302
|
+
const selected = state.selection ? treeRows.get(state.selection) : null;
|
|
1303
|
+
if (selected && (pending || state.revealedFor !== state.selection)) {
|
|
1304
|
+
state.revealedFor = state.selection;
|
|
1305
|
+
selected.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
1306
|
+
}
|
|
1307
|
+
// Focus is only taken back if the tree had it: after an edit the canvas
|
|
1308
|
+
// re-renders, and stealing focus out of an inspector field mid-typing would
|
|
1309
|
+
// close the field the user is working in.
|
|
1310
|
+
if (hadFocus && state.treeFocus) treeRows.get(state.treeFocus)?.focus({ preventScroll: true });
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/**
|
|
1314
|
+
* Append a list of nodes, and the children of the ones that are open.
|
|
1315
|
+
*
|
|
1316
|
+
* The rows are a real nesting — a `<ul>` inside the `<li>` it belongs to — not
|
|
1317
|
+
* the flat list with `padding-left: depth * 13px` this panel used to draw. A flat
|
|
1318
|
+
* list looks the same and cannot be collapsed, and collapse is the reason the
|
|
1319
|
+
* panel exists.
|
|
1320
|
+
*/
|
|
1321
|
+
function appendTreeItems(list, nodes) {
|
|
1322
|
+
for (const node of nodes) {
|
|
1323
|
+
const open = isOpen(node, state.treeView);
|
|
1324
|
+
const item = document.createElement("li");
|
|
1325
|
+
item.className = "tree-item";
|
|
1326
|
+
item.setAttribute("role", "treeitem");
|
|
1327
|
+
item.setAttribute("aria-level", String(node.depth + 1));
|
|
1328
|
+
item.setAttribute("aria-selected", String(state.selection === node.id));
|
|
1329
|
+
if (hasChildren(node)) item.setAttribute("aria-expanded", String(open));
|
|
1330
|
+
if (node.hidden) item.dataset.hidden = "true";
|
|
1331
|
+
item.dataset.nodeId = node.id;
|
|
1332
|
+
item.tabIndex = state.treeFocus === node.id ? 0 : -1;
|
|
1333
|
+
item.appendChild(treeRow(node));
|
|
1334
|
+
|
|
1335
|
+
if (open) {
|
|
1336
|
+
const children = document.createElement("ul");
|
|
1337
|
+
children.className = "tree-children";
|
|
1338
|
+
// A `treeitem`'s children are a group, not a list: the role is what makes
|
|
1339
|
+
// the nesting legible to anything that reads the tree rather than sees it.
|
|
1340
|
+
children.setAttribute("role", "group");
|
|
1341
|
+
appendTreeItems(children, node.children);
|
|
1342
|
+
item.appendChild(children);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
treeRows.set(node.id, item);
|
|
1346
|
+
list.appendChild(item);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
function treeRow(node) {
|
|
1351
|
+
const row = document.createElement("div");
|
|
1352
|
+
row.className = "tree-row";
|
|
1353
|
+
|
|
1354
|
+
// The chevron is decorative: the state it shows is `aria-expanded` on the
|
|
1355
|
+
// item, which is what the keyboard and the screen reader read.
|
|
1356
|
+
const chevron = document.createElement("span");
|
|
1357
|
+
if (hasChildren(node)) chevron.className = "chevron";
|
|
1358
|
+
else chevron.className = "chevron leaf";
|
|
1359
|
+
chevron.setAttribute("aria-hidden", "true");
|
|
1360
|
+
row.appendChild(chevron);
|
|
1361
|
+
|
|
1362
|
+
const label = document.createElement("span");
|
|
1363
|
+
label.className = "tree-label";
|
|
1364
|
+
label.textContent = node.name || node.id || `(${node.tag})`;
|
|
1365
|
+
row.appendChild(label);
|
|
1366
|
+
|
|
1367
|
+
const type = document.createElement("span");
|
|
1368
|
+
type.className = "tree-type";
|
|
1369
|
+
type.textContent = node.type ?? node.tag;
|
|
1370
|
+
row.appendChild(type);
|
|
1371
|
+
|
|
1372
|
+
if (node.kind === "page") {
|
|
1373
|
+
const badge = document.createElement("span");
|
|
1374
|
+
badge.className = "badge";
|
|
1375
|
+
badge.textContent = pageBadge(node);
|
|
1376
|
+
row.appendChild(badge);
|
|
1377
|
+
}
|
|
1378
|
+
return row;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
function pageBadge(node) {
|
|
1382
|
+
const box = state.boxes.get(node.id);
|
|
1383
|
+
return box ? `${box.width}×${Math.round(box.height)}` : "";
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/**
|
|
1387
|
+
* The nodes from a page down to `id`, or an empty list if it is not in the tree.
|
|
1388
|
+
*
|
|
1389
|
+
* One walk, because everything the pointer needs is a *chain*: which rung to
|
|
1390
|
+
* stand on, which child is under the pointer, and what a click would select are
|
|
1391
|
+
* three readings of the same chain.
|
|
1392
|
+
*/
|
|
1393
|
+
function pathNodesOf(id, pageId = null) {
|
|
1394
|
+
// Scoped to a page when the caller knows one, because a chain found in the
|
|
1395
|
+
// wrong page is a chain of the wrong elements.
|
|
1396
|
+
const root = pageId ? state.tree.filter((node) => node.id === pageId) : state.tree;
|
|
1397
|
+
const trail = [];
|
|
1398
|
+
const descend = (nodes) => {
|
|
1399
|
+
for (const node of nodes) {
|
|
1400
|
+
trail.push(node);
|
|
1401
|
+
if (node.id === id) return true;
|
|
1402
|
+
if (descend(node.children)) return true;
|
|
1403
|
+
trail.pop();
|
|
1404
|
+
}
|
|
1405
|
+
return false;
|
|
1406
|
+
};
|
|
1407
|
+
return descend(root) ? [...trail] : [];
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
function treeNodeFor(id) {
|
|
1411
|
+
const path = pathNodesOf(id);
|
|
1412
|
+
return path.length > 0 ? path[path.length - 1] : null;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
/**
|
|
1416
|
+
* The element for `id`, inside the page it belongs to.
|
|
1417
|
+
*
|
|
1418
|
+
* Ids are unique in the file, but the canvas mounts every page of it, so the same
|
|
1419
|
+
* id can be on screen more than once when a file carries a duplicate. A lookup that
|
|
1420
|
+
* does not say *which page* can answer with the element in a different artboard,
|
|
1421
|
+
* and the box is then drawn around something off screen. Scoping the search to the
|
|
1422
|
+
* page the pointer is in is what keeps the boxes on the thing they name.
|
|
1423
|
+
*/
|
|
1424
|
+
function elementInPage(pageId, id) {
|
|
1425
|
+
const pageBox = ui.canvas.querySelector(`.page-box[data-page-id="${CSS.escape(pageId)}"]`);
|
|
1426
|
+
if (!pageBox) return null;
|
|
1427
|
+
if (id === pageId) return pageBox;
|
|
1428
|
+
for (const host of pageBox.querySelectorAll("*")) {
|
|
1429
|
+
const root = host.shadowRoot;
|
|
1430
|
+
if (!root) continue;
|
|
1431
|
+
const found = root.querySelector(`[data-id="${CSS.escape(id)}"]`);
|
|
1432
|
+
if (found) return found;
|
|
1433
|
+
}
|
|
1434
|
+
return null;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/**
|
|
1438
|
+
* What the pointer is offering: the level it is standing on, and the children
|
|
1439
|
+
* of that level — one of which a click will select.
|
|
1440
|
+
*/
|
|
1441
|
+
function hoverPicture() {
|
|
1442
|
+
if (!state.hovering) return null;
|
|
1443
|
+
const path = pathNodesOf(state.hovering, state.hoveringPage);
|
|
1444
|
+
if (path.length === 0) return null;
|
|
1445
|
+
const rung = rungAt(path.map((node) => node.id), state.levelDepth);
|
|
1446
|
+
const level = path[rung.depth];
|
|
1447
|
+
return {
|
|
1448
|
+
pageId: path[0].id,
|
|
1449
|
+
levelId: rung.levelId,
|
|
1450
|
+
level,
|
|
1451
|
+
children: level.children ?? [],
|
|
1452
|
+
underId: rung.underId,
|
|
1453
|
+
insideChild: rung.insideChild,
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/**
|
|
1458
|
+
* The element a gesture would select.
|
|
1459
|
+
*
|
|
1460
|
+
* A plain press takes the **level** — the box the pointer is standing on, the
|
|
1461
|
+
* same one the solid hover box draws — so clicking anywhere inside a card picks
|
|
1462
|
+
* the card and the drag that may follow moves the card. Stepping into the card
|
|
1463
|
+
* is a deliberate second gesture: `drill` takes the child under the pointer.
|
|
1464
|
+
*/
|
|
1465
|
+
/**
|
|
1466
|
+
* Is this press the second of a double-click?
|
|
1467
|
+
*
|
|
1468
|
+
* The `dblclick` event cannot answer this: the press captures the pointer on the
|
|
1469
|
+
* viewport so a drag keeps receiving moves, and the spec sends `click` and
|
|
1470
|
+
* `dblclick` to the capture target — measured: the event arrives at the viewport
|
|
1471
|
+
* with no page in its composed path, so the element under the pointer is not in
|
|
1472
|
+
* it any more. Two presses close together in time and place are what a
|
|
1473
|
+
* double-click is, and here both are still measurable.
|
|
1474
|
+
*
|
|
1475
|
+
* The record is kept across presses rather than reset, so a third quick click
|
|
1476
|
+
* steps in again — each extra click goes one rung deeper, which is what the
|
|
1477
|
+
* gesture means everywhere else.
|
|
1478
|
+
*/
|
|
1479
|
+
const DRILL_INTERVAL_MS = 400;
|
|
1480
|
+
const DRILL_SLOP_PX = 4;
|
|
1481
|
+
let lastPress = null;
|
|
1482
|
+
|
|
1483
|
+
function isRepeatPress(event, id) {
|
|
1484
|
+
const now = performance.now();
|
|
1485
|
+
const repeat = lastPress !== null
|
|
1486
|
+
&& lastPress.id === id
|
|
1487
|
+
&& now - lastPress.time <= DRILL_INTERVAL_MS
|
|
1488
|
+
&& Math.abs(event.clientX - lastPress.x) <= DRILL_SLOP_PX
|
|
1489
|
+
&& Math.abs(event.clientY - lastPress.y) <= DRILL_SLOP_PX;
|
|
1490
|
+
lastPress = { id, x: event.clientX, y: event.clientY, time: now };
|
|
1491
|
+
return repeat;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
function pressTarget(hoveredId, pageId = null, { drill = false } = {}) {
|
|
1495
|
+
const path = pathNodesOf(hoveredId, pageId);
|
|
1496
|
+
if (path.length === 0) return null;
|
|
1497
|
+
const rung = rungAt(path.map((node) => node.id), state.levelDepth);
|
|
1498
|
+
const index = drill && rung.insideChild ? rung.depth + 1 : rung.depth;
|
|
1499
|
+
return { pageId: path[0].id, node: path[index] };
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
function setHovering(id, pageId = null) {
|
|
1503
|
+
if (state.hovering === id && state.hoveringPage === pageId) return;
|
|
1504
|
+
state.hovering = id;
|
|
1505
|
+
state.hoveringPage = pageId;
|
|
1506
|
+
drawOverlay();
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
/** The page a tree row belongs to: the outermost row it sits in. */
|
|
1510
|
+
function pageIdOfTreeRow(item) {
|
|
1511
|
+
let root = item;
|
|
1512
|
+
for (let el = item?.parentElement; el && el.id !== "layer-tree"; el = el.parentElement) {
|
|
1513
|
+
if (el.classList.contains("tree-item")) root = el;
|
|
1514
|
+
}
|
|
1515
|
+
return root?.dataset.nodeId ?? null;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/**
|
|
1519
|
+
* Nothing in the canvas is text to select while the canvas is being dragged.
|
|
1520
|
+
*
|
|
1521
|
+
* Two guards, because this event cannot do the whole job: `selectstart` does not
|
|
1522
|
+
* reach the viewport from inside a page's shadow root, so a listener here only
|
|
1523
|
+
* covers the chrome. The design's own text is covered by `.moving` — the page
|
|
1524
|
+
* hosts resolve their `user-select: auto` against the viewport's `none` — and by
|
|
1525
|
+
* cancelling the press itself in the drag branches. Scoped to a drag in progress:
|
|
1526
|
+
* outside one, selecting a string in a page still works the way it did.
|
|
1527
|
+
*/
|
|
1528
|
+
ui.viewport.addEventListener("selectstart", (event) => {
|
|
1529
|
+
if (drag || ui.viewport.classList.contains("panning")) event.preventDefault();
|
|
1530
|
+
});
|
|
1531
|
+
|
|
1532
|
+
/**
|
|
1533
|
+
* The item a pointer event is really over.
|
|
1534
|
+
*
|
|
1535
|
+
* A child list is a `<ul>` inside the parent `<li>`, so the 13px indent band
|
|
1536
|
+
* beside a nested row belongs to the *parent's* item. Resolving `.tree-item`
|
|
1537
|
+
* alone therefore selected the parent when someone clicked just left of a child
|
|
1538
|
+
* — the canvas jumping to a different element than the one under the pointer —
|
|
1539
|
+
* and hover outlined the parent too. A row is what a row is; the band between
|
|
1540
|
+
* rows is nobody's, and does nothing.
|
|
1541
|
+
*/
|
|
1542
|
+
function rowItemOf(target) {
|
|
1543
|
+
return target?.closest?.(".tree-row")?.closest(".tree-item") ?? null;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
/**
|
|
1547
|
+
* Rows are one delegated listener, not one per row.
|
|
1548
|
+
*
|
|
1549
|
+
* An imported design can be thousands of rows; two listeners each is a cost paid
|
|
1550
|
+
* on every render, and the row identity is already in `data-node-id`.
|
|
1551
|
+
*/
|
|
1552
|
+
ui.layerTree.addEventListener("mouseover", (event) => {
|
|
1553
|
+
const item = rowItemOf(event.target);
|
|
1554
|
+
setHovering(item?.dataset.nodeId ?? null, item ? pageIdOfTreeRow(item) : null);
|
|
1555
|
+
});
|
|
1556
|
+
ui.layerTree.addEventListener("mouseleave", () => setHovering(null));
|
|
1557
|
+
|
|
1558
|
+
ui.layerTree.addEventListener("click", (event) => {
|
|
1559
|
+
const item = rowItemOf(event.target);
|
|
1560
|
+
const id = item?.dataset.nodeId;
|
|
1561
|
+
if (!id) return;
|
|
1562
|
+
const node = treeNodeFor(id);
|
|
1563
|
+
if (!node) return;
|
|
1564
|
+
state.treeFocus = id;
|
|
1565
|
+
|
|
1566
|
+
if (event.target.closest?.(".chevron") && hasChildren(node)) {
|
|
1567
|
+
// The chevron opens; it does not select. Selecting on the way to a chevron
|
|
1568
|
+
// is how a tree jumps the canvas to a different element than the one asked for.
|
|
1569
|
+
toggleNode(node, state.treeView);
|
|
1570
|
+
renderLayerTree();
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
item.focus({ preventScroll: true });
|
|
1574
|
+
select(id, { reveal: true, pageId: pageIdOfTreeRow(item) });
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
/**
|
|
1578
|
+
* The keys the tree owns, and only those.
|
|
1579
|
+
*
|
|
1580
|
+
* The canvas already listens on `window` for the arrow keys — they nudge the
|
|
1581
|
+
* selected element one pixel, and that is a write to the file — so a key the
|
|
1582
|
+
* tree handles has to stop here. Declaring the set is what makes that true in
|
|
1583
|
+
* both directions: `⌘Z`, `Delete` and `Escape` still mean what they mean
|
|
1584
|
+
* everywhere else, and an arrow key pressed on a leaf, where the tree can do
|
|
1585
|
+
* nothing with it, is still an arrow key the tree consumed. Letting that one
|
|
1586
|
+
* fall through moved an element in the design by one pixel — a write nobody
|
|
1587
|
+
* asked for, from a keypress that looked inert.
|
|
1588
|
+
*/
|
|
1589
|
+
const TREE_KEYS = new Set(["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight", "Home", "End", "Enter", " "]);
|
|
1590
|
+
|
|
1591
|
+
ui.layerTree.addEventListener("keydown", (event) => {
|
|
1592
|
+
if (!TREE_KEYS.has(event.key)) return;
|
|
1593
|
+
event.preventDefault();
|
|
1594
|
+
event.stopPropagation();
|
|
1595
|
+
|
|
1596
|
+
const visible = visibleNodes(state.tree, state.treeView);
|
|
1597
|
+
if (visible.length === 0) return;
|
|
1598
|
+
const at = Math.max(0, visible.findIndex((node) => node.id === state.treeFocus));
|
|
1599
|
+
const node = visible[at];
|
|
1600
|
+
const standOn = (next) => {
|
|
1601
|
+
state.treeFocus = next;
|
|
1602
|
+
renderLayerTree();
|
|
1603
|
+
treeRows.get(next)?.focus({ preventScroll: true });
|
|
1604
|
+
};
|
|
1605
|
+
|
|
1606
|
+
switch (event.key) {
|
|
1607
|
+
case "ArrowDown": standOn(visible[Math.min(visible.length - 1, at + 1)].id); break;
|
|
1608
|
+
case "ArrowUp": standOn(visible[Math.max(0, at - 1)].id); break;
|
|
1609
|
+
case "ArrowRight":
|
|
1610
|
+
if (hasChildren(node) && !isOpen(node, state.treeView)) { toggleNode(node, state.treeView); renderLayerTree(); }
|
|
1611
|
+
else if (hasChildren(node)) standOn(visible[at + 1].id);
|
|
1612
|
+
break;
|
|
1613
|
+
case "ArrowLeft": {
|
|
1614
|
+
const path = pathTo(state.tree, node.id);
|
|
1615
|
+
if (hasChildren(node) && isOpen(node, state.treeView)) { toggleNode(node, state.treeView); renderLayerTree(); }
|
|
1616
|
+
else if (path.length > 1) standOn(path[path.length - 2]);
|
|
1617
|
+
break;
|
|
1618
|
+
}
|
|
1619
|
+
case "Home": standOn(visible[0].id); break;
|
|
1620
|
+
case "End": standOn(visible[visible.length - 1].id); break;
|
|
1621
|
+
case "Enter":
|
|
1622
|
+
case " ":
|
|
1623
|
+
select(node.id, { reveal: true, pageId: pageIdOfTreeRow(treeRows.get(node.id) ?? ui.layerTree) });
|
|
1624
|
+
break;
|
|
1625
|
+
}
|
|
1626
|
+
});
|
|
1627
|
+
|
|
1628
|
+
/**
|
|
1629
|
+
* The count on the Problems button.
|
|
1630
|
+
*
|
|
1631
|
+
* It is a summary of the list beside it, so it is written wherever the list is.
|
|
1632
|
+
* Keeping it inside `renderChrome` meant the measured pass — which only redrew the
|
|
1633
|
+
* list — left a badge reading 2 above a drawer holding 7 rows.
|
|
1634
|
+
*/
|
|
1635
|
+
function renderProblemCount() {
|
|
1636
|
+
const errors = state.diagnostics.filter((d) => d.severity === "error").length;
|
|
1637
|
+
ui.problemCount.textContent = String(state.diagnostics.length);
|
|
1638
|
+
ui.problemCount.classList.toggle("has-errors", errors > 0);
|
|
1639
|
+
// The drawer's own subtitle: the same count, said in words. The button keeps
|
|
1640
|
+
// the number because a button is a shape you glance at; the sheet has room for
|
|
1641
|
+
// "2 errors · 1 warning".
|
|
1642
|
+
const warnings = state.diagnostics.length - errors;
|
|
1643
|
+
const parts = [];
|
|
1644
|
+
if (errors > 0) parts.push(`${errors} error${errors === 1 ? "" : "s"}`);
|
|
1645
|
+
if (warnings > 0) parts.push(`${warnings} warning${warnings === 1 ? "" : "s"}`);
|
|
1646
|
+
ui.problemSummary.textContent = parts.join(" · ");
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
function renderProblems() {
|
|
1650
|
+
renderProblemCount();
|
|
1651
|
+
ui.problemList.textContent = "";
|
|
1652
|
+
if (state.diagnostics.length === 0) {
|
|
1653
|
+
const row = document.createElement("div");
|
|
1654
|
+
row.className = "issue empty";
|
|
1655
|
+
row.textContent = "No problems. Every check passed on this file.";
|
|
1656
|
+
ui.problemList.appendChild(row);
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1659
|
+
for (const diagnostic of state.diagnostics) {
|
|
1660
|
+
const row = document.createElement("div");
|
|
1661
|
+
row.className = "issue";
|
|
1662
|
+
const sev = document.createElement("span");
|
|
1663
|
+
sev.className = `sev ${diagnostic.severity}`;
|
|
1664
|
+
sev.textContent = diagnostic.severity;
|
|
1665
|
+
const body = document.createElement("div");
|
|
1666
|
+
body.className = "issue-body";
|
|
1667
|
+
const code = document.createElement("span");
|
|
1668
|
+
code.className = "code";
|
|
1669
|
+
code.textContent = diagnostic.code;
|
|
1670
|
+
const message = document.createElement("p");
|
|
1671
|
+
message.className = "msg";
|
|
1672
|
+
message.textContent = diagnostic.message;
|
|
1673
|
+
body.appendChild(code);
|
|
1674
|
+
body.appendChild(message);
|
|
1675
|
+
if (diagnostic.hint) {
|
|
1676
|
+
const hint = document.createElement("p");
|
|
1677
|
+
hint.className = "hint";
|
|
1678
|
+
hint.textContent = diagnostic.hint;
|
|
1679
|
+
body.appendChild(hint);
|
|
1680
|
+
}
|
|
1681
|
+
row.appendChild(sev);
|
|
1682
|
+
row.appendChild(body);
|
|
1683
|
+
if (diagnostic.target) {
|
|
1684
|
+
// A row that jumps to the element is a control, so the keyboard gets it
|
|
1685
|
+
// too: focusable, announced as a button, and Enter/Space do what a click
|
|
1686
|
+
// does. The ring it gets is the same one every other control has.
|
|
1687
|
+
row.tabIndex = 0;
|
|
1688
|
+
row.setAttribute("role", "button");
|
|
1689
|
+
row.addEventListener("click", () => select(diagnostic.target, { reveal: true, expand: true }));
|
|
1690
|
+
row.addEventListener("keydown", (event) => {
|
|
1691
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
1692
|
+
event.preventDefault();
|
|
1693
|
+
row.click();
|
|
1694
|
+
});
|
|
1695
|
+
}
|
|
1696
|
+
ui.problemList.appendChild(row);
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
// -- inspector ---------------------------------------------------------------
|
|
1701
|
+
|
|
1702
|
+
/**
|
|
1703
|
+
* The inspector: the selected element's own properties, in sections.
|
|
1704
|
+
*
|
|
1705
|
+
* Every row here edits one declaration of the selected element, and every edit
|
|
1706
|
+
* goes through `commit` — the same funnel the CLI writes through. There is no
|
|
1707
|
+
* second write path, so a value typed here is refused exactly when the CLI would
|
|
1708
|
+
* refuse it, and the refusal names the same reason.
|
|
1709
|
+
*
|
|
1710
|
+
* Only the properties the element actually carries are drawn. A property that is
|
|
1711
|
+
* not in the file is not a row: an empty field would be a promise that writing
|
|
1712
|
+
* it adds the declaration and an empty value is the same as no value, which is
|
|
1713
|
+
* not true of this format. (Adding a property is a separate feature.)
|
|
1714
|
+
*/
|
|
1715
|
+
function renderInspector() {
|
|
1716
|
+
ui.inspector.textContent = "";
|
|
1717
|
+
const id = state.selection;
|
|
1718
|
+
if (!id) {
|
|
1719
|
+
ui.selectionName.textContent = "Nothing selected";
|
|
1720
|
+
const empty = document.createElement("p");
|
|
1721
|
+
empty.className = "empty";
|
|
1722
|
+
empty.textContent = "Click an element on the canvas, or a row in the layer tree.";
|
|
1723
|
+
ui.inspector.appendChild(empty);
|
|
1724
|
+
// The copy handshake is the way a change gets back to the agent, and an
|
|
1725
|
+
// empty inspector is exactly where a user has not heard of it yet.
|
|
1726
|
+
const copyHint = document.createElement("p");
|
|
1727
|
+
copyHint.className = "empty";
|
|
1728
|
+
copyHint.textContent = "Select an element and press ⌘C (Ctrl+C on Windows/Linux) to copy a pointer to it for the agent.";
|
|
1729
|
+
ui.inspector.appendChild(copyHint);
|
|
1730
|
+
return;
|
|
1731
|
+
}
|
|
1732
|
+
const node = findNode(id);
|
|
1733
|
+
if (!node) {
|
|
1734
|
+
ui.selectionName.textContent = id;
|
|
1735
|
+
const empty = document.createElement("p");
|
|
1736
|
+
empty.className = "empty";
|
|
1737
|
+
empty.textContent = "This element is no longer in the file.";
|
|
1738
|
+
ui.inspector.appendChild(empty);
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
ui.selectionName.textContent = `${getAttr(node, "data-name") || id}`;
|
|
1743
|
+
const copyHint = document.createElement("p");
|
|
1744
|
+
copyHint.className = "empty";
|
|
1745
|
+
copyHint.textContent = "Press ⌘C (Ctrl+C on Windows/Linux) to copy a pointer to this element for the agent.";
|
|
1746
|
+
ui.inspector.appendChild(copyHint);
|
|
1747
|
+
ui.inspector.appendChild(identityBlock(id, node));
|
|
1748
|
+
|
|
1749
|
+
const styleMap = styleMapOf(node);
|
|
1750
|
+
for (const section of groupedFields(styleMap.keys())) {
|
|
1751
|
+
ui.inspector.appendChild(sectionBlock(id, section, styleMap));
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
if (node.kind === "element" && ["p", "h1", "h2", "h3", "h4", "h5", "h6", "span"].includes(node.tag)) {
|
|
1755
|
+
ui.inspector.appendChild(contentBlock(id, node));
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
function sectionHead(id, label, folded) {
|
|
1760
|
+
const head = document.createElement("button");
|
|
1761
|
+
head.type = "button";
|
|
1762
|
+
head.className = "section-head";
|
|
1763
|
+
head.dataset.section = id;
|
|
1764
|
+
head.setAttribute("aria-expanded", String(!folded));
|
|
1765
|
+
head.textContent = label;
|
|
1766
|
+
return head;
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
function focusSectionHead(id) {
|
|
1770
|
+
ui.inspector.querySelector(`.section-head[data-section="${id}"]`)?.focus();
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
function identityBlock(id, node) {
|
|
1774
|
+
const identity = document.createElement("dl");
|
|
1775
|
+
identity.className = "attrs";
|
|
1776
|
+
for (const [label, value] of [
|
|
1777
|
+
["data-id", id],
|
|
1778
|
+
["data-type", getAttr(node, "data-type") ?? node.tag],
|
|
1779
|
+
["tag", node.tag],
|
|
1780
|
+
]) {
|
|
1781
|
+
const dt = document.createElement("dt");
|
|
1782
|
+
dt.textContent = label;
|
|
1783
|
+
const dd = document.createElement("dd");
|
|
1784
|
+
dd.textContent = value ?? "—";
|
|
1785
|
+
identity.appendChild(dt);
|
|
1786
|
+
identity.appendChild(dd);
|
|
1787
|
+
}
|
|
1788
|
+
return identity;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
/**
|
|
1792
|
+
* One section: a heading that folds it, and a body of rows.
|
|
1793
|
+
*
|
|
1794
|
+
* Folding is remembered in `state.folded`, not in the DOM: the inspector is
|
|
1795
|
+
* rebuilt on every selection, and a `<details>` that closed itself again on the
|
|
1796
|
+
* next click would be a control that forgets what it was told.
|
|
1797
|
+
*/
|
|
1798
|
+
function sectionBlock(id, section, styleMap) {
|
|
1799
|
+
const wrap = document.createElement("div");
|
|
1800
|
+
wrap.className = "section";
|
|
1801
|
+
const folded = state.folded.has(section.id);
|
|
1802
|
+
if (folded) wrap.classList.add("folded");
|
|
1803
|
+
|
|
1804
|
+
const head = sectionHead(section.id, section.label, folded);
|
|
1805
|
+
head.addEventListener("click", () => {
|
|
1806
|
+
if (state.folded.has(section.id)) state.folded.delete(section.id);
|
|
1807
|
+
else state.folded.add(section.id);
|
|
1808
|
+
renderInspector();
|
|
1809
|
+
// The rebuild replaced the button that was just pressed, so a keyboard user
|
|
1810
|
+
// would lose their place on every fold. The tree restores focus the same way.
|
|
1811
|
+
focusSectionHead(section.id);
|
|
1812
|
+
});
|
|
1813
|
+
wrap.appendChild(head);
|
|
1814
|
+
|
|
1815
|
+
const body = document.createElement("div");
|
|
1816
|
+
body.className = "section-body";
|
|
1817
|
+
for (const row of section.rows) {
|
|
1818
|
+
const line = document.createElement("div");
|
|
1819
|
+
// A row holds one property or a pair. A lone property gets the whole row:
|
|
1820
|
+
// half a row is 105px in this panel, and a four-value `padding` does not fit
|
|
1821
|
+
// in it — the value was there, the field just cut it off. A pair still
|
|
1822
|
+
// shares the row, because the two numbers are read against each other.
|
|
1823
|
+
line.className = row.length === 1 ? "field-grid single" : "field-grid";
|
|
1824
|
+
for (const property of row) line.appendChild(buildField(id, property, styleMap.get(property)));
|
|
1825
|
+
body.appendChild(line);
|
|
1826
|
+
}
|
|
1827
|
+
wrap.appendChild(body);
|
|
1828
|
+
return wrap;
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
/**
|
|
1832
|
+
* The text of a text element, which is not a declaration and so is its own row.
|
|
1833
|
+
*
|
|
1834
|
+
* Foldable like any other section, under the id `content` — not `text`, which is
|
|
1835
|
+
* the Text section's id and would fold two different things with one click.
|
|
1836
|
+
*/
|
|
1837
|
+
function contentBlock(id, node) {
|
|
1838
|
+
const wrap = document.createElement("div");
|
|
1839
|
+
wrap.className = "section";
|
|
1840
|
+
const folded = state.folded.has("content");
|
|
1841
|
+
if (folded) wrap.classList.add("folded");
|
|
1842
|
+
|
|
1843
|
+
const head = sectionHead("content", "Content", folded);
|
|
1844
|
+
head.addEventListener("click", () => {
|
|
1845
|
+
if (state.folded.has("content")) state.folded.delete("content");
|
|
1846
|
+
else state.folded.add("content");
|
|
1847
|
+
renderInspector();
|
|
1848
|
+
focusSectionHead("content");
|
|
1849
|
+
});
|
|
1850
|
+
wrap.appendChild(head);
|
|
1851
|
+
|
|
1852
|
+
const body = document.createElement("div");
|
|
1853
|
+
body.className = "section-body";
|
|
1854
|
+
const field = document.createElement("div");
|
|
1855
|
+
field.className = "field";
|
|
1856
|
+
const label = document.createElement("label");
|
|
1857
|
+
label.textContent = "text";
|
|
1858
|
+
label.htmlFor = "field-content";
|
|
1859
|
+
field.appendChild(label);
|
|
1860
|
+
|
|
1861
|
+
const input = document.createElement("input");
|
|
1862
|
+
input.type = "text";
|
|
1863
|
+
input.id = "field-content";
|
|
1864
|
+
input.value = (node.children ?? []).filter((child) => child.kind === "text").map((child) => child.value).join("");
|
|
1865
|
+
input.disabled = state.readOnly;
|
|
1866
|
+
input.addEventListener("change", () => {
|
|
1867
|
+
commit([{ op: "set", target: id, prop: "text", value: input.value }]);
|
|
1868
|
+
});
|
|
1869
|
+
field.appendChild(input);
|
|
1870
|
+
body.appendChild(field);
|
|
1871
|
+
wrap.appendChild(body);
|
|
1872
|
+
return wrap;
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
/**
|
|
1876
|
+
* One property, with the editor its grammar asks for.
|
|
1877
|
+
*
|
|
1878
|
+
* `controlFor` reads the property's declared kind out of `lib/table.mjs`; this
|
|
1879
|
+
* function is the only place that turns a kind into an element. The rule for
|
|
1880
|
+
* every branch is the same: never lose a value. A `select` that does not list the
|
|
1881
|
+
* current value gets the current value added to it, so an unusual spelling stays
|
|
1882
|
+
* visible and stays selectable instead of silently snapping to the first option.
|
|
1883
|
+
*/
|
|
1884
|
+
function buildField(id, property, value) {
|
|
1885
|
+
const field = document.createElement("div");
|
|
1886
|
+
field.className = "field";
|
|
1887
|
+
const label = document.createElement("label");
|
|
1888
|
+
label.textContent = property;
|
|
1889
|
+
label.title = hintFor(property);
|
|
1890
|
+
field.appendChild(label);
|
|
1891
|
+
|
|
1892
|
+
const bare = String(value);
|
|
1893
|
+
const control = controlFor(property);
|
|
1894
|
+
|
|
1895
|
+
if (control === "segmented" || control === "select") {
|
|
1896
|
+
const values = valuesFor(property);
|
|
1897
|
+
const options = values.includes(bare) ? values : [...values, bare];
|
|
1898
|
+
if (control === "segmented") {
|
|
1899
|
+
field.appendChild(segmentedControl(id, property, bare, options));
|
|
1900
|
+
} else {
|
|
1901
|
+
// A segmented control is a group and names itself; a `select` is a control
|
|
1902
|
+
// and needs the label to point at it.
|
|
1903
|
+
const select = selectControl(id, property, bare, options);
|
|
1904
|
+
label.htmlFor = select.id;
|
|
1905
|
+
field.appendChild(select);
|
|
1906
|
+
}
|
|
1907
|
+
return field;
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
if (control === "color" && /^#[0-9a-fA-F]{6,8}$/.test(bare)) {
|
|
1911
|
+
const input = document.createElement("input");
|
|
1912
|
+
input.type = "color";
|
|
1913
|
+
input.id = `field-${property}`;
|
|
1914
|
+
input.value = bare.slice(0, 7);
|
|
1915
|
+
input.disabled = state.readOnly;
|
|
1916
|
+
input.addEventListener("change", () => {
|
|
1917
|
+
commit([{ op: "set", target: id, prop: `style:${property}`, value: input.value }]);
|
|
1918
|
+
});
|
|
1919
|
+
label.htmlFor = input.id;
|
|
1920
|
+
field.appendChild(input);
|
|
1921
|
+
field.appendChild(valueLine(bare));
|
|
1922
|
+
return field;
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
const input = textControl(id, property, bare, {
|
|
1926
|
+
// A number input sanitizes what it cannot parse to the empty string, which
|
|
1927
|
+
// would hide a value the file really carries (`opacity: var(--fade)`).
|
|
1928
|
+
type: control === "number" && isNumericValue(bare) ? "number" : "text",
|
|
1929
|
+
scrub: scrubFor(property, bare),
|
|
1930
|
+
...rangeFor(property),
|
|
1931
|
+
});
|
|
1932
|
+
label.htmlFor = input.id;
|
|
1933
|
+
field.appendChild(input);
|
|
1934
|
+
|
|
1935
|
+
// A token is worth showing twice: once as what the file says, once as what the
|
|
1936
|
+
// browser resolved it to. `var(--color-ink)` is the intent; `#111827` is the
|
|
1937
|
+
// colour, and a designer checking contrast needs both.
|
|
1938
|
+
const resolved = resolveTokens(bare, state.model.tokens);
|
|
1939
|
+
if (resolved !== bare) field.appendChild(valueLine(`→ ${resolved}`));
|
|
1940
|
+
return field;
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
function valueLine(text) {
|
|
1944
|
+
const line = document.createElement("div");
|
|
1945
|
+
line.className = "value-line";
|
|
1946
|
+
line.textContent = text;
|
|
1947
|
+
return line;
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
function textControl(id, property, value, { type = "text", scrub = false, min, max } = {}) {
|
|
1951
|
+
const input = document.createElement("input");
|
|
1952
|
+
input.type = type;
|
|
1953
|
+
input.id = `field-${property}`;
|
|
1954
|
+
input.value = value;
|
|
1955
|
+
input.disabled = state.readOnly;
|
|
1956
|
+
const hint = hintFor(property);
|
|
1957
|
+
if (hint) input.placeholder = hint;
|
|
1958
|
+
// The full value on hover: a field can be narrower than its value, and the
|
|
1959
|
+
// value it holds is the one thing this control must not hide.
|
|
1960
|
+
input.title = value;
|
|
1961
|
+
if (min !== undefined) input.min = String(min);
|
|
1962
|
+
if (max !== undefined) input.max = String(max);
|
|
1963
|
+
|
|
1964
|
+
const send = (next) => commit([{ op: "set", target: id, prop: `style:${property}`, value: next }]);
|
|
1965
|
+
input.addEventListener("change", () => send(input.value));
|
|
1966
|
+
if (scrub) {
|
|
1967
|
+
input.className = "scrub";
|
|
1968
|
+
input.title = "Drag left or right to change, or type a value";
|
|
1969
|
+
attachScrub(input, send);
|
|
1970
|
+
}
|
|
1971
|
+
return input;
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
function segmentedControl(id, property, value, options) {
|
|
1975
|
+
const group = document.createElement("div");
|
|
1976
|
+
group.className = "seg";
|
|
1977
|
+
group.setAttribute("role", "group");
|
|
1978
|
+
group.setAttribute("aria-label", property);
|
|
1979
|
+
for (const option of options) {
|
|
1980
|
+
const button = document.createElement("button");
|
|
1981
|
+
button.type = "button";
|
|
1982
|
+
button.className = "seg-option";
|
|
1983
|
+
if (option === value) button.classList.add("active");
|
|
1984
|
+
button.textContent = option;
|
|
1985
|
+
button.disabled = state.readOnly;
|
|
1986
|
+
button.addEventListener("click", () => {
|
|
1987
|
+
commit([{ op: "set", target: id, prop: `style:${property}`, value: option }]);
|
|
1988
|
+
});
|
|
1989
|
+
group.appendChild(button);
|
|
1990
|
+
}
|
|
1991
|
+
return group;
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
function selectControl(id, property, value, options) {
|
|
1995
|
+
const select = document.createElement("select");
|
|
1996
|
+
select.id = `field-${property}`;
|
|
1997
|
+
select.disabled = state.readOnly;
|
|
1998
|
+
for (const option of options) {
|
|
1999
|
+
const entry = document.createElement("option");
|
|
2000
|
+
entry.value = option;
|
|
2001
|
+
entry.textContent = option;
|
|
2002
|
+
if (option === value) entry.selected = true;
|
|
2003
|
+
select.appendChild(entry);
|
|
2004
|
+
}
|
|
2005
|
+
select.addEventListener("change", () => {
|
|
2006
|
+
commit([{ op: "set", target: id, prop: `style:${property}`, value: select.value }]);
|
|
2007
|
+
});
|
|
2008
|
+
return select;
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
/**
|
|
2012
|
+
* A number that scrubs.
|
|
2013
|
+
*
|
|
2014
|
+
* Only the value is previewed while the pointer moves; the op is sent on
|
|
2015
|
+
* release. Sending an op per pixel would fill the change log with steps nobody
|
|
2016
|
+
* asked for, and every one of them is an undo step.
|
|
2017
|
+
*/
|
|
2018
|
+
function attachScrub(input, onCommit) {
|
|
2019
|
+
let startX = 0;
|
|
2020
|
+
let startValue = 0;
|
|
2021
|
+
let unit = "";
|
|
2022
|
+
let original = "";
|
|
2023
|
+
let moved = false;
|
|
2024
|
+
let active = false;
|
|
2025
|
+
|
|
2026
|
+
input.addEventListener("pointerdown", (event) => {
|
|
2027
|
+
if (event.button !== 0) return;
|
|
2028
|
+
active = true;
|
|
2029
|
+
moved = false;
|
|
2030
|
+
startX = event.clientX;
|
|
2031
|
+
original = input.value;
|
|
2032
|
+
const match = /^(-?\d+(?:\.\d+)?)(.*)$/.exec(input.value.trim());
|
|
2033
|
+
startValue = match ? Number(match[1]) : 0;
|
|
2034
|
+
unit = match ? match[2] : "";
|
|
2035
|
+
capturePointer(input, event.pointerId);
|
|
2036
|
+
event.preventDefault();
|
|
2037
|
+
});
|
|
2038
|
+
|
|
2039
|
+
input.addEventListener("pointermove", (event) => {
|
|
2040
|
+
if (!active) return;
|
|
2041
|
+
if (event.clientX !== startX) moved = true;
|
|
2042
|
+
const delta = Math.round((event.clientX - startX) / 2);
|
|
2043
|
+
input.value = `${startValue + delta}${unit}`;
|
|
2044
|
+
});
|
|
2045
|
+
|
|
2046
|
+
input.addEventListener("pointerup", (event) => {
|
|
2047
|
+
if (!active) return;
|
|
2048
|
+
active = false;
|
|
2049
|
+
try { input.releasePointerCapture(event.pointerId); } catch { /* released */ }
|
|
2050
|
+
if (!moved) {
|
|
2051
|
+
// A press that never moved is a press, not a drag. The value goes back
|
|
2052
|
+
// exactly as the file spells it — `0.50` is not rewritten to `0.5`, and
|
|
2053
|
+
// `var(--space-md)` cannot be re-read as `0` — so touching a field can
|
|
2054
|
+
// never be the thing that changed it.
|
|
2055
|
+
input.value = original;
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
const next = input.value.trim();
|
|
2059
|
+
if (next !== `${startValue}${unit}`) onCommit(next);
|
|
2060
|
+
});
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
function findNode(id) {
|
|
2064
|
+
if (!state.doc) return null;
|
|
2065
|
+
let found = null;
|
|
2066
|
+
const visit = (node) => {
|
|
2067
|
+
if (found) return;
|
|
2068
|
+
if (node.kind === "element" && getAttr(node, "data-id") === id) { found = node; return; }
|
|
2069
|
+
for (const child of node.children ?? []) visit(child);
|
|
2070
|
+
};
|
|
2071
|
+
visit(state.doc);
|
|
2072
|
+
return found;
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
// -- selection ---------------------------------------------------------------
|
|
2076
|
+
|
|
2077
|
+
/**
|
|
2078
|
+
* Select an element.
|
|
2079
|
+
*
|
|
2080
|
+
* `reveal` pans the canvas to it; `expand` opens the layer tree down to it. They
|
|
2081
|
+
* are two different questions and the answer differs by who asked: a row click
|
|
2082
|
+
* in the tree needs the canvas moved but the tree is already there, while a pick
|
|
2083
|
+
* on the canvas — or a row in the Problems drawer — has to open the tree, even
|
|
2084
|
+
* when the element was already the selection. Without that second half, picking
|
|
2085
|
+
* the same element twice, or picking it after "collapse all", leaves the panel
|
|
2086
|
+
* showing nothing selected, which reads as a panel that missed the click.
|
|
2087
|
+
*/
|
|
2088
|
+
function select(id, options = {}) {
|
|
2089
|
+
state.selection = id;
|
|
2090
|
+
state.selectionPage = id ? options.pageId ?? null : null;
|
|
2091
|
+
// Selecting something is also standing on it: the canvas then shows it solid
|
|
2092
|
+
// and its own children dashed, ready for the next click down. Nothing selected
|
|
2093
|
+
// is back to the first rung — the page's own children.
|
|
2094
|
+
state.levelDepth = id ? pathNodesOf(id).length - 1 : 1;
|
|
2095
|
+
if (options.reveal) reveal(id);
|
|
2096
|
+
if (options.expand) state.expandRow = true;
|
|
2097
|
+
renderChrome();
|
|
2098
|
+
// The outline is drawn from `state.selection`, so a selection that does not
|
|
2099
|
+
// redraw it leaves the canvas boxing whatever was selected before. A click on
|
|
2100
|
+
// the canvas forces the redraw by moving the pointer over a different element
|
|
2101
|
+
// first; a row in the Problems drawer never touches the canvas at all, so it
|
|
2102
|
+
// was the case that showed it — the drawer named one element and the canvas
|
|
2103
|
+
// went on highlighting another, for as long as the pointer stayed off the page.
|
|
2104
|
+
drawOverlay();
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
/** Bring an element into view. Camera only: `select` redraws the overlay once. */
|
|
2108
|
+
function reveal(id) {
|
|
2109
|
+
const element = elementForId(id);
|
|
2110
|
+
if (!element) return;
|
|
2111
|
+
const rect = ui.viewport.getBoundingClientRect();
|
|
2112
|
+
const box = element.getBoundingClientRect();
|
|
2113
|
+
const inside = box.left >= rect.left && box.right <= rect.right && box.top >= rect.top && box.bottom <= rect.bottom;
|
|
2114
|
+
if (inside) return;
|
|
2115
|
+
const zoom = state.camera.zoom;
|
|
2116
|
+
state.camera.x += rect.left + rect.width / 2 - (box.left + box.width / 2);
|
|
2117
|
+
state.camera.y += rect.top + rect.height / 2 - (box.top + box.height / 2);
|
|
2118
|
+
applyCamera();
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
// -- writes ------------------------------------------------------------------
|
|
2122
|
+
|
|
2123
|
+
/**
|
|
2124
|
+
* Every write from the canvas goes through here, and every one of them goes
|
|
2125
|
+
* through the service's funnel: revalidate, refuse on a new error, write
|
|
2126
|
+
* atomically. The canvas has no privileged path.
|
|
2127
|
+
*/
|
|
2128
|
+
/**
|
|
2129
|
+
* Write ops, re-render, then put the optimistic preview back.
|
|
2130
|
+
*
|
|
2131
|
+
* `onSettled` is what a drag uses to undo the styles it painted on the live
|
|
2132
|
+
* element while it followed the pointer. It runs at every exit — after the
|
|
2133
|
+
* re-render on success, after the re-render on a refusal, and immediately when
|
|
2134
|
+
* the write never happened (read-only, busy) — because the preview has to stay
|
|
2135
|
+
* on screen until the re-rendered element replaces it. Clearing it earlier is
|
|
2136
|
+
* the flash: the card jumps back to where it was dragged from and jumps to the
|
|
2137
|
+
* drop point again when the round trip lands.
|
|
2138
|
+
*/
|
|
2139
|
+
async function commit(ops, { onSettled } = {}) {
|
|
2140
|
+
const settle = () => {
|
|
2141
|
+
try { onSettled?.(); } catch { /* cleanup must not mask the op result */ }
|
|
2142
|
+
};
|
|
2143
|
+
if (state.readOnly) {
|
|
2144
|
+
toast("The file is read only. Fix the problem it reports first.", "refused");
|
|
2145
|
+
settle();
|
|
2146
|
+
return;
|
|
2147
|
+
}
|
|
2148
|
+
if (state.busy) {
|
|
2149
|
+
settle();
|
|
2150
|
+
return;
|
|
2151
|
+
}
|
|
2152
|
+
state.busy = true;
|
|
2153
|
+
const originalText = state.text;
|
|
2154
|
+
try {
|
|
2155
|
+
const { status, payload } = await api("ops", { body: { baseRevision: state.revision, ops } });
|
|
2156
|
+
if (status === 200 && payload?.ok) {
|
|
2157
|
+
state.revision = payload.revision;
|
|
2158
|
+
state.canUndo = true;
|
|
2159
|
+
state.canRedo = false;
|
|
2160
|
+
await loadDocument({ keepCamera: true });
|
|
2161
|
+
return;
|
|
2162
|
+
}
|
|
2163
|
+
// A refusal is a result. The whole point of refusing is to show what the
|
|
2164
|
+
// edit would have introduced, so that is what the drawer shows.
|
|
2165
|
+
state.text = originalText;
|
|
2166
|
+
if (payload?.diagnostics?.length) {
|
|
2167
|
+
state.diagnostics = payload.diagnostics;
|
|
2168
|
+
renderProblems();
|
|
2169
|
+
ui.problems.hidden = false;
|
|
2170
|
+
toast(`Refused: ${payload.diagnostics[0].message}`, "refused");
|
|
2171
|
+
} else {
|
|
2172
|
+
toast(`Refused: ${payload?.code ?? status}`, "refused");
|
|
2173
|
+
}
|
|
2174
|
+
await loadDocument({ keepCamera: true });
|
|
2175
|
+
} catch (error) {
|
|
2176
|
+
toast(`The service did not accept that: ${error.message}`, "refused");
|
|
2177
|
+
} finally {
|
|
2178
|
+
state.busy = false;
|
|
2179
|
+
settle();
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
// -- gestures ----------------------------------------------------------------
|
|
2184
|
+
|
|
2185
|
+
let drag = null;
|
|
2186
|
+
|
|
2187
|
+
/**
|
|
2188
|
+
* Capture is an optimisation, not a requirement: it keeps the events coming when
|
|
2189
|
+
* the pointer leaves the element. It throws when there is no live pointer for
|
|
2190
|
+
* the id, and an uncaught throw here would abort the rest of the handler and
|
|
2191
|
+
* leave the interaction half-applied. So it is attempted, never trusted.
|
|
2192
|
+
*/
|
|
2193
|
+
/**
|
|
2194
|
+
* Turn text selection off for the whole duration of a geometry drag.
|
|
2195
|
+
*
|
|
2196
|
+
* One class on the viewport, set at press and cleared on release, covering every
|
|
2197
|
+
* drag kind. `user-select: none` here also reaches the pages: the shadow host
|
|
2198
|
+
* resolves `auto` against it, so the design's own text stops being selectable
|
|
2199
|
+
* while an element is being moved — but only while it is being moved.
|
|
2200
|
+
*/
|
|
2201
|
+
function setDragging(on) {
|
|
2202
|
+
ui.viewport.classList.toggle("moving", on);
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
function capturePointer(element, pointerId) {
|
|
2206
|
+
try {
|
|
2207
|
+
element.setPointerCapture(pointerId);
|
|
2208
|
+
} catch {
|
|
2209
|
+
/* the drag still works without capture */
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
/**
|
|
2214
|
+
* A press on the canvas must also give this document keyboard focus.
|
|
2215
|
+
*
|
|
2216
|
+
* The selection path calls `preventDefault()` on `pointerdown`, which suppresses
|
|
2217
|
+
* the browser's normal focus change. That is invisible in a normal browser tab
|
|
2218
|
+
* (the tab already owns the keyboard), but this page is also embedded in a DSH
|
|
2219
|
+
* right-sidebar iframe: without taking focus, ⌘C goes to the host page and the
|
|
2220
|
+
* canvas never hears it, so the pointer is never copied.
|
|
2221
|
+
*/
|
|
2222
|
+
function claimKeyboardFocus() {
|
|
2223
|
+
try {
|
|
2224
|
+
window.focus();
|
|
2225
|
+
} catch {
|
|
2226
|
+
/* an embedding shell may refuse; the parent is the fallback path */
|
|
2227
|
+
}
|
|
2228
|
+
try {
|
|
2229
|
+
document.body?.focus({ preventScroll: true });
|
|
2230
|
+
} catch {
|
|
2231
|
+
/* focus is best effort; a non-focusable body just keeps the old target */
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
ui.viewport.addEventListener("pointerdown", (event) => {
|
|
2236
|
+
claimKeyboardFocus();
|
|
2237
|
+
const target = event.target;
|
|
2238
|
+
// The panel toggles sit on the canvas but they are not the canvas. Without
|
|
2239
|
+
// this the click falls through to "nothing was hit", which clears the
|
|
2240
|
+
// selection — folding a panel away would also throw away what was selected.
|
|
2241
|
+
if (target?.closest?.(".edge-toggle")) return;
|
|
2242
|
+
const handle = target?.classList?.contains("handle") ? target : null;
|
|
2243
|
+
|
|
2244
|
+
if (handle && state.selection) {
|
|
2245
|
+
const element = elementForId(state.selection);
|
|
2246
|
+
if (element) {
|
|
2247
|
+
setDragging(true);
|
|
2248
|
+
// The element's own size, not its painted bounding rect: a rotated box's
|
|
2249
|
+
// bounding rect is the AABB, and resizing from that starts too big.
|
|
2250
|
+
const start = element.getBoundingClientRect();
|
|
2251
|
+
drag = { kind: "resize", corner: handle.dataset.handle, id: state.selection, startX: event.clientX, startY: event.clientY, startWidth: element.offsetWidth, startHeight: element.offsetHeight, startLeft: start.left, startTop: start.top, element };
|
|
2252
|
+
capturePointer(ui.viewport, event.pointerId);
|
|
2253
|
+
event.preventDefault();
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
const label = target?.classList?.contains("page-label") ? target : null;
|
|
2259
|
+
if (label || event.altKey) {
|
|
2260
|
+
const pageBox = target.closest(".page-box");
|
|
2261
|
+
if (pageBox) {
|
|
2262
|
+
// A press on the label is also a statement about which page this is. The label is
|
|
2263
|
+
// the only part of a page a full-bleed child never covers — every page of
|
|
2264
|
+
// `examples/alarm.html` is filled by a `width:100%;height:100%` root frame — so
|
|
2265
|
+
// without this a page cannot be selected on the canvas at all, and `Export`, which
|
|
2266
|
+
// writes the page that is selected, would be reachable only from the layer tree.
|
|
2267
|
+
// The drag is unchanged: a press that moves still moves the page, and one that does
|
|
2268
|
+
// not move still writes nothing.
|
|
2269
|
+
if (label) select(pageBox.dataset.pageId, { pageId: pageBox.dataset.pageId });
|
|
2270
|
+
drag = pageDrag(pageBox, pageBox.dataset.pageId, event);
|
|
2271
|
+
setDragging(true);
|
|
2272
|
+
capturePointer(ui.viewport, event.pointerId);
|
|
2273
|
+
event.preventDefault();
|
|
2274
|
+
return;
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
if (target === ui.viewport || target === ui.canvas) {
|
|
2279
|
+
// The background is the one place with nothing under it, so a press here is
|
|
2280
|
+
// a candidate for "clear the selection" — but only if the pointer never
|
|
2281
|
+
// moves. Panning the canvas is not a way of saying "select nothing".
|
|
2282
|
+
drag = {
|
|
2283
|
+
kind: "pan", startX: event.clientX, startY: event.clientY,
|
|
2284
|
+
originX: state.camera.x, originY: state.camera.y, moved: false,
|
|
2285
|
+
};
|
|
2286
|
+
ui.viewport.classList.add("panning");
|
|
2287
|
+
capturePointer(ui.viewport, event.pointerId);
|
|
2288
|
+
// Cancel the press. Without this the pointer drag is *also* the browser's
|
|
2289
|
+
// text selection gesture, and panning the canvas drags a blue smear across
|
|
2290
|
+
// every string in the design. (Cancelling `pointerdown` suppresses the
|
|
2291
|
+
// compatibility mouse events the selection would start from; `user-select`
|
|
2292
|
+
// on `.panning` is the belt to this pair of braces.)
|
|
2293
|
+
event.preventDefault();
|
|
2294
|
+
return;
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
const hit = targetFromEvent(event);
|
|
2298
|
+
if (hit) {
|
|
2299
|
+
// A press takes the level the pointer is offering — the box the solid hover
|
|
2300
|
+
// outline is already drawing — not the deepest element under the cursor and
|
|
2301
|
+
// not one of its children. That is what makes a click a safe gesture: it
|
|
2302
|
+
// selects the card, and a drag that follows moves the card, not the word
|
|
2303
|
+
// under the cursor. Stepping inside is a double-click (`drill`).
|
|
2304
|
+
const target = pressTarget(hit.id, hit.pageId, { drill: isRepeatPress(event, hit.id) })
|
|
2305
|
+
?? { pageId: hit.pageId, node: treeNodeFor(hit.id) ?? { id: hit.id } };
|
|
2306
|
+
select(target.node.id, { expand: true, pageId: target.pageId });
|
|
2307
|
+
// A page is placed by `data-canvas`, and that is the only spelling for it.
|
|
2308
|
+
// This used to fall through to the frame path below, which writes
|
|
2309
|
+
// `position:absolute` plus `left`/`top`: the canvas ignores those on a page
|
|
2310
|
+
// because it positions one from `data-canvas`, so the file looked right
|
|
2311
|
+
// here while `export` shipped the page pushed off its own top-left corner.
|
|
2312
|
+
const moving = elementInPage(target.pageId, target.node.id) ?? hit.element;
|
|
2313
|
+
drag = hit.kind === "page"
|
|
2314
|
+
? pageDrag(hit.element, hit.id, event)
|
|
2315
|
+
: { kind: "maybe-move", id: target.node.id, startX: event.clientX, startY: event.clientY, moved: false, element: moving };
|
|
2316
|
+
setDragging(true);
|
|
2317
|
+
capturePointer(ui.viewport, event.pointerId);
|
|
2318
|
+
// A press on the design is not a press on text. Without this the browser
|
|
2319
|
+
// starts its own selection gesture on the text run under the pointer and
|
|
2320
|
+
// keeps extending it while the element follows the pointer — a blue smear
|
|
2321
|
+
// across every string the drag passes, which is exactly what `panning`
|
|
2322
|
+
// already guarded for the background. `selectstart` cannot do this job here:
|
|
2323
|
+
// the text lives inside a page's shadow root and that event does not reach
|
|
2324
|
+
// the viewport from there.
|
|
2325
|
+
event.preventDefault();
|
|
2326
|
+
} else {
|
|
2327
|
+
select(null);
|
|
2328
|
+
}
|
|
2329
|
+
});
|
|
2330
|
+
|
|
2331
|
+
/** A drag that moves a whole page. `data-canvas` is what it commits. */
|
|
2332
|
+
function pageDrag(pageBox, id, event) {
|
|
2333
|
+
const box = state.boxes.get(id);
|
|
2334
|
+
return {
|
|
2335
|
+
kind: "page", id, element: pageBox,
|
|
2336
|
+
startX: event.clientX, startY: event.clientY,
|
|
2337
|
+
originX: box?.position?.x ?? 0, originY: box?.position?.y ?? 0,
|
|
2338
|
+
zoom: state.camera.zoom,
|
|
2339
|
+
};
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
/**
|
|
2343
|
+
* Every gesture that moves geometry redraws the boxes, or they do not move.
|
|
2344
|
+
*
|
|
2345
|
+
* The overlay is a *sibling* of the canvas, not a child of it: it is positioned
|
|
2346
|
+
* in viewport pixels and inherits neither the camera transform nor the page's
|
|
2347
|
+
* position. So a camera move, a page drag, an element drag and a resize all have
|
|
2348
|
+
* to redraw it — and each of them forgot at some point, which is a blue
|
|
2349
|
+
* rectangle that stays exactly where it was while the design slides out from
|
|
2350
|
+
* under it. The cost is one measured box per pointer move, which is what the
|
|
2351
|
+
* pan branch has been paying since it was fixed.
|
|
2352
|
+
*/
|
|
2353
|
+
ui.viewport.addEventListener("pointermove", (event) => {
|
|
2354
|
+
if (!drag) {
|
|
2355
|
+
const hit = targetFromEvent(event);
|
|
2356
|
+
setHovering(hit?.id ?? null, hit?.pageId ?? null);
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
if (drag.kind === "pan") {
|
|
2361
|
+
if (Math.abs(event.clientX - drag.startX) > 3 || Math.abs(event.clientY - drag.startY) > 3) drag.moved = true;
|
|
2362
|
+
state.camera.x = drag.originX + (event.clientX - drag.startX);
|
|
2363
|
+
state.camera.y = drag.originY + (event.clientY - drag.startY);
|
|
2364
|
+
applyCamera();
|
|
2365
|
+
drawOverlay();
|
|
2366
|
+
return;
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
if (drag.kind === "page") {
|
|
2370
|
+
const zoom = drag.zoom || 1;
|
|
2371
|
+
const x = Math.round(drag.originX + (event.clientX - drag.startX) / zoom);
|
|
2372
|
+
const y = Math.round(drag.originY + (event.clientY - drag.startY) / zoom);
|
|
2373
|
+
drag.element.style.left = `${x}px`;
|
|
2374
|
+
drag.element.style.top = `${y}px`;
|
|
2375
|
+
drag.moved = { x, y };
|
|
2376
|
+
drawOverlay();
|
|
2377
|
+
return;
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
if (drag.kind === "maybe-move") {
|
|
2381
|
+
const dx = event.clientX - drag.startX;
|
|
2382
|
+
const dy = event.clientY - drag.startY;
|
|
2383
|
+
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) drag.moved = true;
|
|
2384
|
+
if (drag.moved && drag.element) {
|
|
2385
|
+
const [lx, ly] = parentDelta(drag.element, dx / state.camera.zoom, dy / state.camera.zoom);
|
|
2386
|
+
drag.element.style.translate = `${lx}px ${ly}px`;
|
|
2387
|
+
drawOverlay();
|
|
2388
|
+
}
|
|
2389
|
+
return;
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
if (drag.kind === "resize") {
|
|
2393
|
+
const zoom = state.camera.zoom || 1;
|
|
2394
|
+
// A corner handle turns with its box, so the pointer delta has to be turned
|
|
2395
|
+
// back before it means "wider" or "taller" along this element's own axes.
|
|
2396
|
+
const [dx, dy] = unturn(
|
|
2397
|
+
(event.clientX - drag.startX) / zoom,
|
|
2398
|
+
(event.clientY - drag.startY) / zoom,
|
|
2399
|
+
rotationOfElement(drag.element),
|
|
2400
|
+
);
|
|
2401
|
+
const [hx, hy] = drag.corner.split("").map(Number);
|
|
2402
|
+
const width = Math.max(1, Math.round(drag.startWidth + (hx ? dx : 0)));
|
|
2403
|
+
const height = Math.max(1, Math.round(drag.startHeight + (hy ? dy : 0)));
|
|
2404
|
+
drag.element.style.width = `${width}px`;
|
|
2405
|
+
drag.element.style.height = `${height}px`;
|
|
2406
|
+
drag.pending = { width, height };
|
|
2407
|
+
// The four handles are children of the selection box. Without this they stay
|
|
2408
|
+
// where the element *was*, so the thing you are dragging a corner of is no
|
|
2409
|
+
// longer the thing the corners are on.
|
|
2410
|
+
drawOverlay();
|
|
2411
|
+
}
|
|
2412
|
+
});
|
|
2413
|
+
|
|
2414
|
+
/**
|
|
2415
|
+
* A cancelled gesture puts the design back and drops the drag state.
|
|
2416
|
+
*
|
|
2417
|
+
* `pointercancel` arrives when the browser takes the pointer over — a touch
|
|
2418
|
+
* scroll, a system gesture, a window losing the pointer. Without a handler the
|
|
2419
|
+
* drag stayed set: `.moving` kept the design's text unselectable until the next
|
|
2420
|
+
* successful drag ended, and the preview transform stayed on the element. There
|
|
2421
|
+
* is no commit here, because a cancelled gesture is one the user did not finish.
|
|
2422
|
+
*/
|
|
2423
|
+
ui.viewport.addEventListener("pointercancel", () => {
|
|
2424
|
+
if (!drag) return;
|
|
2425
|
+
const current = drag;
|
|
2426
|
+
drag = null;
|
|
2427
|
+
ui.viewport.classList.remove("panning");
|
|
2428
|
+
setDragging(false);
|
|
2429
|
+
if (current.element) {
|
|
2430
|
+
if (current.kind === "maybe-move") current.element.style.translate = "";
|
|
2431
|
+
if (current.kind === "page") {
|
|
2432
|
+
current.element.style.left = `${current.originX}px`;
|
|
2433
|
+
current.element.style.top = `${current.originY}px`;
|
|
2434
|
+
}
|
|
2435
|
+
if (current.kind === "resize") {
|
|
2436
|
+
current.element.style.width = `${current.startWidth}px`;
|
|
2437
|
+
current.element.style.height = `${current.startHeight}px`;
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
drawOverlay();
|
|
2441
|
+
});
|
|
2442
|
+
|
|
2443
|
+
ui.viewport.addEventListener("pointerup", (event) => {
|
|
2444
|
+
if (!drag) return;
|
|
2445
|
+
const current = drag;
|
|
2446
|
+
drag = null;
|
|
2447
|
+
ui.viewport.classList.remove("panning");
|
|
2448
|
+
setDragging(false);
|
|
2449
|
+
|
|
2450
|
+
// A press on the empty canvas that did not pan is a click on nothing, and
|
|
2451
|
+
// clicking nothing clears the selection — the same thing `Escape` does. The
|
|
2452
|
+
// three-pixel threshold is the same one an element drag uses: a click always
|
|
2453
|
+
// arrives with a pixel or two of jitter, and a jittery click is still a click.
|
|
2454
|
+
if (current.kind === "pan") {
|
|
2455
|
+
if (!current.moved) select(null);
|
|
2456
|
+
return;
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
if (current.kind === "page" && current.moved) {
|
|
2460
|
+
const { x, y } = current.moved;
|
|
2461
|
+
const node = state.model.pages.find((p) => p.id === current.id);
|
|
2462
|
+
if (node) {
|
|
2463
|
+
const preview = current.element;
|
|
2464
|
+
commit([{ op: "set", target: current.id, prop: "data-canvas", value: `${x},${y}` }], {
|
|
2465
|
+
onSettled: () => {
|
|
2466
|
+
if (!preview) return;
|
|
2467
|
+
preview.style.left = `${current.originX}px`;
|
|
2468
|
+
preview.style.top = `${current.originY}px`;
|
|
2469
|
+
},
|
|
2470
|
+
});
|
|
2471
|
+
}
|
|
2472
|
+
return;
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
if (current.kind === "maybe-move" && current.moved) {
|
|
2476
|
+
const rawX = (event.clientX - current.startX) / (state.camera.zoom || 1);
|
|
2477
|
+
const rawY = (event.clientY - current.startY) / (state.camera.zoom || 1);
|
|
2478
|
+
const [movedX, movedY] = current.element ? parentDelta(current.element, rawX, rawY) : [rawX, rawY];
|
|
2479
|
+
const dx = Math.round(movedX);
|
|
2480
|
+
const dy = Math.round(movedY);
|
|
2481
|
+
// The preview transform is left on the element on purpose: it is what the
|
|
2482
|
+
// re-render replaces. `onSettled` clears it whatever the write did.
|
|
2483
|
+
const preview = current.element;
|
|
2484
|
+
const node = findNode(current.id);
|
|
2485
|
+
if (!node) return;
|
|
2486
|
+
const map = styleMapOf(node);
|
|
2487
|
+
// A frame moves by becoming positioned. That is one spelling, not two: the
|
|
2488
|
+
// canonical form has absolute placement as `position:absolute` plus
|
|
2489
|
+
// `left`/`top`, so the canvas writes exactly that.
|
|
2490
|
+
const left = (parseFloat(map.get("left")) || 0) + dx;
|
|
2491
|
+
const top = (parseFloat(map.get("top")) || 0) + dy;
|
|
2492
|
+
commit([
|
|
2493
|
+
{ op: "set", target: current.id, prop: "style:position", value: "absolute" },
|
|
2494
|
+
{ op: "set", target: current.id, prop: "style:left", value: `${Math.round(left)}px` },
|
|
2495
|
+
{ op: "set", target: current.id, prop: "style:top", value: `${Math.round(top)}px` },
|
|
2496
|
+
], {
|
|
2497
|
+
onSettled: () => { if (preview) preview.style.translate = ""; },
|
|
2498
|
+
});
|
|
2499
|
+
return;
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
if (current.kind === "resize" && current.pending) {
|
|
2503
|
+
const preview = current.element;
|
|
2504
|
+
commit([
|
|
2505
|
+
{ op: "set", target: current.id, prop: "style:width", value: `${current.pending.width}px` },
|
|
2506
|
+
{ op: "set", target: current.id, prop: "style:height", value: `${current.pending.height}px` },
|
|
2507
|
+
], {
|
|
2508
|
+
onSettled: () => {
|
|
2509
|
+
if (!preview) return;
|
|
2510
|
+
preview.style.width = `${current.startWidth}px`;
|
|
2511
|
+
preview.style.height = `${current.startHeight}px`;
|
|
2512
|
+
},
|
|
2513
|
+
});
|
|
2514
|
+
}
|
|
2515
|
+
});
|
|
2516
|
+
|
|
2517
|
+
ui.viewport.addEventListener("wheel", (event) => {
|
|
2518
|
+
event.preventDefault();
|
|
2519
|
+
if (event.ctrlKey || event.metaKey) {
|
|
2520
|
+
zoomAt(event.clientX, event.clientY, event.deltaY < 0 ? 1.08 : 1 / 1.08);
|
|
2521
|
+
return;
|
|
2522
|
+
}
|
|
2523
|
+
if (event.shiftKey) state.camera.x -= event.deltaY;
|
|
2524
|
+
else { state.camera.x -= event.deltaX; state.camera.y -= event.deltaY; }
|
|
2525
|
+
applyCamera();
|
|
2526
|
+
drawOverlay();
|
|
2527
|
+
}, { passive: false });
|
|
2528
|
+
|
|
2529
|
+
window.addEventListener("keydown", (event) => {
|
|
2530
|
+
const inField = ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement?.tagName);
|
|
2531
|
+
if (inField) return;
|
|
2532
|
+
if ((event.metaKey || event.ctrlKey) && event.code === "Backslash") {
|
|
2533
|
+
// The layout, from the keyboard: ⌘\ is the layers panel, ⇧⌘\ the inspector.
|
|
2534
|
+
// The *physical* key is what is checked, not `event.key`: with shift held the
|
|
2535
|
+
// same key reports `|`, and a shortcut that stops working when you press the
|
|
2536
|
+
// modifier it is documented with is a shortcut nobody can use.
|
|
2537
|
+
event.preventDefault();
|
|
2538
|
+
const side = event.shiftKey ? "right" : "left";
|
|
2539
|
+
const before = document.activeElement;
|
|
2540
|
+
togglePanel(side);
|
|
2541
|
+
// The panel that just closed took the focused control with it. Focus goes to
|
|
2542
|
+
// the control that replaced it — the edge tab — so the next Tab continues
|
|
2543
|
+
// from the panel, not from the top of the document.
|
|
2544
|
+
if (before && before !== document.body && !before.getClientRects().length) focusPanelControl(side);
|
|
2545
|
+
return;
|
|
2546
|
+
}
|
|
2547
|
+
if (event.key === " ") {
|
|
2548
|
+
// A focused button activates on space, and that keypress is the button's.
|
|
2549
|
+
if (document.activeElement?.tagName === "BUTTON") return;
|
|
2550
|
+
ui.viewport.classList.add("move-mode");
|
|
2551
|
+
event.preventDefault();
|
|
2552
|
+
return;
|
|
2553
|
+
}
|
|
2554
|
+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z") {
|
|
2555
|
+
event.preventDefault();
|
|
2556
|
+
if (event.shiftKey) redo(); else undo();
|
|
2557
|
+
return;
|
|
2558
|
+
}
|
|
2559
|
+
if (event.key === "Delete" || event.key === "Backspace") {
|
|
2560
|
+
if (!state.selection) return;
|
|
2561
|
+
event.preventDefault();
|
|
2562
|
+
commit([{ op: "remove", target: state.selection }]);
|
|
2563
|
+
return;
|
|
2564
|
+
}
|
|
2565
|
+
if (event.key === "Escape") {
|
|
2566
|
+
// Up one rung, and the selection goes with it: from a card to the section it
|
|
2567
|
+
// sits in, and from the first rung to nothing selected at all.
|
|
2568
|
+
const path = state.selection ? pathNodesOf(state.selection, state.selectionPage) : [];
|
|
2569
|
+
if (path.length - 1 > 1) select(path[path.length - 2].id, { pageId: path[0].id });
|
|
2570
|
+
else select(null);
|
|
2571
|
+
return;
|
|
2572
|
+
}
|
|
2573
|
+
const arrows = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
|
2574
|
+
if (arrows[event.key] && state.selection) {
|
|
2575
|
+
// The nudge is the one branch that writes, so it gets the second guard: the
|
|
2576
|
+
// tree and the panel edges handle the arrow keys themselves and stop their
|
|
2577
|
+
// own events, and this is what keeps a leaked keypress from moving a design
|
|
2578
|
+
// by one pixel. Only the nudge — Delete, ⌘Z, Escape and ⌘\ have to keep
|
|
2579
|
+
// working while a row in the tree has focus, and none of them nudges.
|
|
2580
|
+
if (event.target?.closest?.("#layer-tree, .divider")) return;
|
|
2581
|
+
event.preventDefault();
|
|
2582
|
+
const step = event.shiftKey ? 10 : 1;
|
|
2583
|
+
const [dx, dy] = arrows[event.key];
|
|
2584
|
+
const node = findNode(state.selection);
|
|
2585
|
+
if (!node) return;
|
|
2586
|
+
const map = styleMapOf(node);
|
|
2587
|
+
const left = (parseFloat(map.get("left")) || 0) + dx * step;
|
|
2588
|
+
const top = (parseFloat(map.get("top")) || 0) + dy * step;
|
|
2589
|
+
commit([
|
|
2590
|
+
{ op: "set", target: state.selection, prop: "style:position", value: "absolute" },
|
|
2591
|
+
{ op: "set", target: state.selection, prop: "style:left", value: `${left}px` },
|
|
2592
|
+
{ op: "set", target: state.selection, prop: "style:top", value: `${top}px` },
|
|
2593
|
+
]);
|
|
2594
|
+
}
|
|
2595
|
+
});
|
|
2596
|
+
|
|
2597
|
+
window.addEventListener("keyup", (event) => {
|
|
2598
|
+
if (event.key === " ") ui.viewport.classList.remove("move-mode");
|
|
2599
|
+
});
|
|
2600
|
+
|
|
2601
|
+
// -- copy for an agent -------------------------------------------------------
|
|
2602
|
+
|
|
2603
|
+
/** `C:\a\b.html` → `file:///c%3A/a/b.html`, `/a/b.html` → `file:///a/b.html`. */
|
|
2604
|
+
function fileUriOf(absolute) {
|
|
2605
|
+
const slashed = absolute.replace(/\\/g, "/");
|
|
2606
|
+
const drive = /^([A-Za-z]):\//.exec(slashed);
|
|
2607
|
+
const rest = drive ? `/${drive[1].toLowerCase()}%3A${slashed.slice(2)}` : slashed;
|
|
2608
|
+
return `file://${rest.split("/").map((part, i) => (drive && i === 1 ? part : encodeURIComponent(part))).join("/")}`;
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
/**
|
|
2612
|
+
* ⌘C on a selection copies a pointer to it, not the design: the reader is an
|
|
2613
|
+
* agent chat box, and the agent reads the element from the file by its id.
|
|
2614
|
+
*
|
|
2615
|
+
* `text/plain` is what every chat box receives. `application/x-lexical-editor`
|
|
2616
|
+
* is only read by Lexical editors — Cursor's chat input is one — which paste it
|
|
2617
|
+
* as a file chip instead of the raw sentence. A custom type can only be written
|
|
2618
|
+
* from the `copy` event; `navigator.clipboard.write` refuses it.
|
|
2619
|
+
*/
|
|
2620
|
+
document.addEventListener("copy", (event) => {
|
|
2621
|
+
if (!state.selection || !state.path) return;
|
|
2622
|
+
if (["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement?.tagName)) return;
|
|
2623
|
+
if (!(window.getSelection()?.isCollapsed ?? true)) return;
|
|
2624
|
+
|
|
2625
|
+
const id = state.selection;
|
|
2626
|
+
const name = getAttr(findNode(id), "data-name") || id;
|
|
2627
|
+
const pageId = state.selectionPage ?? pathNodesOf(id)[0]?.id;
|
|
2628
|
+
const where = pageId && pageId !== id ? `. Page id: ${pageId}` : "";
|
|
2629
|
+
const prompt = `Use justSkill tools. Current file ${fileUriOf(state.path)}${where}. Selected node ids: ${id}`;
|
|
2630
|
+
const ext = state.file.split(".").pop()?.toLowerCase() ?? "";
|
|
2631
|
+
|
|
2632
|
+
event.clipboardData.setData("text/plain", prompt);
|
|
2633
|
+
event.clipboardData.setData("application/x-lexical-editor", JSON.stringify({
|
|
2634
|
+
nodes: [
|
|
2635
|
+
{
|
|
2636
|
+
detail: 0, format: 0, mode: "segmented", style: "",
|
|
2637
|
+
text: prompt,
|
|
2638
|
+
mentionName: `${state.file} (${name})`,
|
|
2639
|
+
type: "mention", version: 1, typeaheadType: "file",
|
|
2640
|
+
metadata: { iconClasses: ["file-icon", "name-file-icon", `${ext}-ext-file-icon`, "ext-file-icon", `${ext}-lang-file-icon`] },
|
|
2641
|
+
},
|
|
2642
|
+
{ detail: 0, format: 0, mode: "normal", style: "", text: " ", type: "text", version: 1 },
|
|
2643
|
+
],
|
|
2644
|
+
}));
|
|
2645
|
+
event.preventDefault();
|
|
2646
|
+
toast(`Copied ${name} for the agent`);
|
|
2647
|
+
});
|
|
2648
|
+
|
|
2649
|
+
// -- top bar actions ---------------------------------------------------------
|
|
2650
|
+
|
|
2651
|
+
document.getElementById("zoom-in").addEventListener("click", () => {
|
|
2652
|
+
const rect = ui.viewport.getBoundingClientRect();
|
|
2653
|
+
zoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, 1.15);
|
|
2654
|
+
});
|
|
2655
|
+
document.getElementById("zoom-out").addEventListener("click", () => {
|
|
2656
|
+
const rect = ui.viewport.getBoundingClientRect();
|
|
2657
|
+
zoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, 1 / 1.15);
|
|
2658
|
+
});
|
|
2659
|
+
document.getElementById("fit").addEventListener("click", fitAll);
|
|
2660
|
+
document.getElementById("undo").addEventListener("click", undo);
|
|
2661
|
+
document.getElementById("redo").addEventListener("click", redo);
|
|
2662
|
+
document.getElementById("toggle-problems").addEventListener("click", () => {
|
|
2663
|
+
ui.problems.hidden = !ui.problems.hidden;
|
|
2664
|
+
});
|
|
2665
|
+
document.getElementById("export-page").addEventListener("click", () => exportPages(false));
|
|
2666
|
+
document.getElementById("export-all").addEventListener("click", () => exportPages(true));
|
|
2667
|
+
document.getElementById("close-problems").addEventListener("click", () => {
|
|
2668
|
+
ui.problems.hidden = true;
|
|
2669
|
+
});
|
|
2670
|
+
document.getElementById("fix-text").addEventListener("click", openFixText);
|
|
2671
|
+
document.getElementById("fix-save").addEventListener("click", saveFixText);
|
|
2672
|
+
|
|
2673
|
+
/**
|
|
2674
|
+
* Read the file back into a textarea so a person can repair what does not parse.
|
|
2675
|
+
*
|
|
2676
|
+
* `/api/save` has existed since the service did and nothing called it: a file the parser
|
|
2677
|
+
* rejects was read-only, with no way out from the canvas but an editor outside it. A
|
|
2678
|
+
* broken file is exactly when someone wants the canvas open — it is where the diagnostics
|
|
2679
|
+
* are.
|
|
2680
|
+
*
|
|
2681
|
+
* `baseRevision` is sent with the text, so a save that would land on top of a change made
|
|
2682
|
+
* while the textarea was open is answered with `rebased: true` and says so, rather than
|
|
2683
|
+
* replacing the other writer's work in silence.
|
|
2684
|
+
*/
|
|
2685
|
+
async function openFixText() {
|
|
2686
|
+
const { payload } = await api("file");
|
|
2687
|
+
if (typeof payload !== "string") {
|
|
2688
|
+
toast("Could not read the file.", "refused");
|
|
2689
|
+
return;
|
|
2690
|
+
}
|
|
2691
|
+
ui.fixText.value = payload;
|
|
2692
|
+
ui.fixHint.textContent = "This file does not parse, so it cannot be edited one change at a time. Fix the text, then save — the whole file is replaced.";
|
|
2693
|
+
ui.fixPane.hidden = false;
|
|
2694
|
+
document.getElementById("fix-save").hidden = false;
|
|
2695
|
+
ui.problems.hidden = false;
|
|
2696
|
+
ui.fixText.focus();
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
async function saveFixText() {
|
|
2700
|
+
const text = ui.fixText.value;
|
|
2701
|
+
const { payload } = await api("save", { body: { baseRevision: state.revision, text } });
|
|
2702
|
+
if (!payload?.ok) {
|
|
2703
|
+
const diagnostics = payload?.diagnostics ?? [];
|
|
2704
|
+
if (diagnostics.length > 0) {
|
|
2705
|
+
state.diagnostics = diagnostics;
|
|
2706
|
+
renderProblems();
|
|
2707
|
+
}
|
|
2708
|
+
ui.fixHint.textContent = payload?.message ?? "The text was refused.";
|
|
2709
|
+
toast(payload?.message ?? "The text was refused.", "refused");
|
|
2710
|
+
return;
|
|
2711
|
+
}
|
|
2712
|
+
ui.fixPane.hidden = true;
|
|
2713
|
+
document.getElementById("fix-save").hidden = true;
|
|
2714
|
+
ui.fixText.value = "";
|
|
2715
|
+
if (payload.rebased) {
|
|
2716
|
+
toast("Saved, but the file had changed while you were typing.", "refused");
|
|
2717
|
+
} else {
|
|
2718
|
+
toast("Saved.", "ok");
|
|
2719
|
+
}
|
|
2720
|
+
await loadDocument({ keepCamera: true });
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
/**
|
|
2724
|
+
* Write the deliverable the user is looking at.
|
|
2725
|
+
*
|
|
2726
|
+
* The canvas cannot write files, so it asks the service to — and the service calls
|
|
2727
|
+
* `lib/export-html.mjs`, the same functions `design.mjs export` calls. A deliverable taken
|
|
2728
|
+
* from here and one taken from the command line are the same bytes, which is the property
|
|
2729
|
+
* that makes "export" mean one thing.
|
|
2730
|
+
*
|
|
2731
|
+
* Which page is not a default. The service answers a missing `page` with the first page,
|
|
2732
|
+
* and a button that silently wrote page one while the canvas showed page four handed over
|
|
2733
|
+
* a deliverable nobody asked for, under a name that said it was the right one. So the
|
|
2734
|
+
* request names the page, `renderChrome` disables the button until a page is selected, and
|
|
2735
|
+
* the check here covers the gap between the click and the render.
|
|
2736
|
+
*
|
|
2737
|
+
* A refusal with diagnostics opens the drawer rather than flashing a toast: the file has a
|
|
2738
|
+
* problem the user has to see, and 7 seconds is not enough time to read a diagnostic.
|
|
2739
|
+
*/
|
|
2740
|
+
async function exportPages(all) {
|
|
2741
|
+
const page = all ? null : selectedPage(state.model?.pages, state.selection, state.selectionPage);
|
|
2742
|
+
if (!all && !page) {
|
|
2743
|
+
toast("Select a page first — Export writes the selected page.", "refused");
|
|
2744
|
+
return;
|
|
2745
|
+
}
|
|
2746
|
+
const { payload } = await api("export", { body: all ? { all: true } : { page: page.id } });
|
|
2747
|
+
if (payload?.ok) {
|
|
2748
|
+
const names = payload.files.map((entry) => entry.path.split("/").pop());
|
|
2749
|
+
toast(`Wrote ${names.join(", ")}`, "ok");
|
|
2750
|
+
return;
|
|
2751
|
+
}
|
|
2752
|
+
const diagnostics = payload?.diagnostics ?? [];
|
|
2753
|
+
if (diagnostics.length > 0) {
|
|
2754
|
+
state.diagnostics = diagnostics;
|
|
2755
|
+
renderProblems();
|
|
2756
|
+
ui.problems.hidden = false;
|
|
2757
|
+
}
|
|
2758
|
+
toast(payload?.message ?? "Export refused.", "refused");
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
async function undo() {
|
|
2762
|
+
const { payload } = await api("undo", { body: {} });
|
|
2763
|
+
if (payload?.ok) {
|
|
2764
|
+
state.canUndo = Boolean(payload.canUndo);
|
|
2765
|
+
state.canRedo = Boolean(payload.canRedo);
|
|
2766
|
+
await loadDocument({ keepCamera: true });
|
|
2767
|
+
} else {
|
|
2768
|
+
toast("Nothing to undo.", "");
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
async function redo() {
|
|
2773
|
+
const { payload } = await api("redo", { body: {} });
|
|
2774
|
+
if (payload?.ok) {
|
|
2775
|
+
state.canUndo = Boolean(payload.canUndo);
|
|
2776
|
+
state.canRedo = Boolean(payload.canRedo);
|
|
2777
|
+
await loadDocument({ keepCamera: true });
|
|
2778
|
+
} else {
|
|
2779
|
+
toast("Nothing to redo.", "");
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
// -- live updates ------------------------------------------------------------
|
|
2784
|
+
|
|
2785
|
+
/**
|
|
2786
|
+
* The file has two writers, so the canvas has to hear about the other one.
|
|
2787
|
+
* A revision it already has is ignored: the canvas caused that write itself.
|
|
2788
|
+
*/
|
|
2789
|
+
function subscribe() {
|
|
2790
|
+
const events = new EventSource(`/api/events?t=${encodeURIComponent(TOKEN)}`);
|
|
2791
|
+
events.addEventListener("changed", async (event) => {
|
|
2792
|
+
let revision = null;
|
|
2793
|
+
try { revision = JSON.parse(event.data)?.revision; } catch { /* plain text */ }
|
|
2794
|
+
if (revision && revision === state.revision) return;
|
|
2795
|
+
await loadDocument({ keepCamera: true });
|
|
2796
|
+
});
|
|
2797
|
+
events.addEventListener("hello", () => { /* the stream is live */ });
|
|
2798
|
+
events.onerror = () => { /* EventSource reconnects on its own */ };
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
window.addEventListener("resize", () => {
|
|
2802
|
+
// A window narrow enough to squeeze a panel re-clamps it, so a remembered
|
|
2803
|
+
// 700px inspector cannot cover the design on a laptop screen — and the
|
|
2804
|
+
// re-clamp goes through the same geometry path as a drag, so giving a column
|
|
2805
|
+
// width back does not slide the drawing sideways with the viewport edge.
|
|
2806
|
+
reclampPanels();
|
|
2807
|
+
});
|
|
2808
|
+
window.addEventListener("beforeunload", () => {
|
|
2809
|
+
navigator.sendBeacon(`/api/bye?t=${encodeURIComponent(TOKEN)}`);
|
|
2810
|
+
});
|
|
2811
|
+
|
|
2812
|
+
window.addEventListener("error", (event) => {
|
|
2813
|
+
toast(`Canvas error: ${event.message}`, "refused");
|
|
2814
|
+
});
|
|
2815
|
+
|
|
2816
|
+
// -- start -------------------------------------------------------------------
|
|
2817
|
+
|
|
2818
|
+
/**
|
|
2819
|
+
* Start. A failure here must be visible: a canvas that renders nothing and says
|
|
2820
|
+
* nothing is the worst possible failure mode, because it looks like a design
|
|
2821
|
+
* with no pages.
|
|
2822
|
+
*/
|
|
2823
|
+
wirePanels();
|
|
2824
|
+
|
|
2825
|
+
/**
|
|
2826
|
+
* Enter embedded presentation after the first document load: fold both panels,
|
|
2827
|
+
* then fit the pages into the remaining width. The fold goes through the same
|
|
2828
|
+
* geometry path a click uses, so the camera stays on the design instead of
|
|
2829
|
+
* sliding with the closed panel; it deliberately skips `savePanels()` so the
|
|
2830
|
+
* reader's normal browser preferences are not overwritten by an embed.
|
|
2831
|
+
*/
|
|
2832
|
+
function enterEmbed() {
|
|
2833
|
+
document.body.dataset.embed = "1";
|
|
2834
|
+
withPanelGeometry(() => {
|
|
2835
|
+
panels.left.open = false;
|
|
2836
|
+
panels.right.open = false;
|
|
2837
|
+
});
|
|
2838
|
+
const refit = () => {
|
|
2839
|
+
applyBarFit();
|
|
2840
|
+
fitAll();
|
|
2841
|
+
};
|
|
2842
|
+
refit();
|
|
2843
|
+
// The right-sidebar tab can still be widening when the iframe's `load`
|
|
2844
|
+
// settles (the panel expands as the tab opens). A fit measured against that
|
|
2845
|
+
// intermediate width leaves the pages at a needlessly tiny zoom, so fit
|
|
2846
|
+
// again after the first frame and once more after the expansion settles.
|
|
2847
|
+
requestAnimationFrame(() => requestAnimationFrame(refit));
|
|
2848
|
+
setTimeout(refit, 500);
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
loadDocument()
|
|
2852
|
+
.then(() => {
|
|
2853
|
+
if (EMBED) {
|
|
2854
|
+
try {
|
|
2855
|
+
enterEmbed();
|
|
2856
|
+
} catch (error) {
|
|
2857
|
+
console.error("embed mode failed:", error);
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
})
|
|
2861
|
+
.then(subscribe)
|
|
2862
|
+
.catch(async (error) => {
|
|
2863
|
+
ui.revision.textContent = "load failed";
|
|
2864
|
+
toast(`Cannot load the design: ${error.message}`, "refused");
|
|
2865
|
+
const panel = document.createElement("p");
|
|
2866
|
+
panel.className = "empty";
|
|
2867
|
+
panel.textContent = `${error.message}`;
|
|
2868
|
+
ui.inspector.prepend(panel);
|
|
2869
|
+
console.error(error);
|
|
2870
|
+
});
|