@oharness/server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/canvas.js ADDED
@@ -0,0 +1,646 @@
1
+ /**
2
+ * The document a canvas artifact is served as.
3
+ *
4
+ * This is the half of a canvas the model does not write. It authors artboards;
5
+ * everything that makes them a canvas — the pan/zoom stage, selection, the
6
+ * properties panel, undo, and the save protocol — is authored here, once.
7
+ *
8
+ * That split is the whole reason a canvas is worth a mime of its own rather
9
+ * than being "a `page` the model happened to put an editor in":
10
+ *
11
+ * - Size. An editor written into every artifact would be tens of kilobytes
12
+ * of model output on every revision, re-derived and subtly different each
13
+ * time, and counted against MAX_ARTIFACT_BYTES.
14
+ * - Round-tripping. Saving means reading the artboards back out of the DOM.
15
+ * That only works if something knows exactly which markup it put in, which
16
+ * a model improvising an editor per artifact cannot promise.
17
+ * - Trust. The save path writes a new revision of a stored artifact. What
18
+ * decides the bytes that get sent should not itself be model output.
19
+ *
20
+ * The document stays inside the same CSP as any other artifact: everything
21
+ * here is inline, nothing is fetched, and the only channel out is postMessage
22
+ * to the parent frame — which no CSP directive covers, and which carries no
23
+ * credential. A canvas opened with no parent that speaks the protocol (a new
24
+ * tab, a published copy) settles into a read-only board rather than showing a
25
+ * Save button that silently does nothing.
26
+ */
27
+ export function canvasDocument(artifact, revision) {
28
+ return [
29
+ "<!doctype html><html><head><meta charset=utf-8>",
30
+ '<meta name="viewport" content="width=device-width,initial-scale=1">',
31
+ `<title>${escapeText(artifact.title)}</title>`,
32
+ `<style>${CANVAS_CSS}</style></head><body>`,
33
+ // Base64, and not a <template>, because model output is not guaranteed to
34
+ // be balanced. A model that hits its token cap mid-artboard leaves open
35
+ // <div>s, and those swallowed the rest of this document — stage, chrome
36
+ // and editor all ended up nested inside the unclosed markup, and the page
37
+ // rendered as nothing at all. Base64 has no character the HTML parser
38
+ // reacts to, so nothing the model writes can reach the shell. What it
39
+ // costs is a third more bytes on the wire and an explicit parse below.
40
+ `<script id="oh-source" type="text/plain">${Buffer.from(revision.content, "utf8").toString("base64")}</script>`,
41
+ '<div id="oh-stage"><div id="oh-world"></div></div>',
42
+ '<div id="oh-ui"></div>',
43
+ `<script>${CANVAS_JS}</script>`,
44
+ "</body></html>",
45
+ ].join("");
46
+ }
47
+ /** Only ever applied to the title, which is the one place a stored string is
48
+ * interpolated into this document's markup rather than moved as a node. */
49
+ function escapeText(text) {
50
+ return text
51
+ .replaceAll("&", "&amp;")
52
+ .replaceAll("<", "&lt;")
53
+ .replaceAll(">", "&gt;")
54
+ .replaceAll('"', "&quot;");
55
+ }
56
+ /**
57
+ * Styles for the light DOM only — the stage, the boards, and the frame each
58
+ * artboard sits in. The chrome deliberately is not here: it lives in a shadow
59
+ * root with its own stylesheet, because the artboards bring the model's
60
+ * `<style>` into this document and one `*` selector in it would otherwise
61
+ * repaint the toolbar.
62
+ */
63
+ const CANVAS_CSS = [
64
+ "html,body{margin:0;height:100%;overflow:hidden;background:#f4f4f5;color-scheme:light dark}",
65
+ "@media (prefers-color-scheme:dark){html,body{background:#18181b}}",
66
+ "#oh-stage{position:absolute;inset:0;overflow:hidden;touch-action:none;cursor:grab}",
67
+ "#oh-stage.oh-panning{cursor:grabbing}",
68
+ "#oh-world{position:absolute;top:0;left:0;transform-origin:0 0}",
69
+ ".oh-board{position:absolute}",
70
+ ".oh-label{font:12px/1.4 ui-sans-serif,system-ui,sans-serif;color:#71717a;",
71
+ "margin:0 0 6px 2px;white-space:nowrap;user-select:none}",
72
+ ".oh-frame{background:#fff;border-radius:6px;overflow:hidden;position:relative;",
73
+ "box-shadow:0 1px 2px rgba(0,0,0,.08),0 8px 24px rgba(0,0,0,.08)}",
74
+ "@media (prefers-color-scheme:dark){.oh-frame{background:#0b0b0e;",
75
+ "box-shadow:0 1px 2px rgba(0,0,0,.5),0 8px 24px rgba(0,0,0,.45)}}",
76
+ "artboard{display:block;width:100%;height:100%;overflow:hidden}",
77
+ "[contenteditable=true]{outline:none}",
78
+ ].join("");
79
+ /**
80
+ * The editor.
81
+ *
82
+ * Written as a plain string rather than a bundled module because this server
83
+ * has no build step for browser code, and because the one thing it must never
84
+ * do is depend on something it would have to fetch — see the CSP in
85
+ * artifacts.ts. ES5-shaped and free of backticks so it survives being embedded
86
+ * here at all.
87
+ *
88
+ * String.raw, not an ordinary template. The editor has "\\n" inside string
89
+ * literals, and a cooked template would turn each one into a real newline —
90
+ * ending the string mid-line and handing the browser a syntax error in a
91
+ * document that nothing type-checks and no test evaluates.
92
+ */
93
+ const CANVAS_JS = String.raw `
94
+ (function () {
95
+ "use strict";
96
+
97
+ // Layout constants. Boards are packed left to right and wrapped, so the
98
+ // model never has to think about coordinates — it declares sizes, and where
99
+ // they sit is a presentation decision that belongs here.
100
+ var GAP = 72, ROW_MAX = 4600, LABEL_H = 22;
101
+ var DEFAULT_W = 1200, DEFAULT_H = 800;
102
+
103
+ var stage = document.getElementById("oh-stage");
104
+ var world = document.getElementById("oh-world");
105
+ var source = document.getElementById("oh-source");
106
+
107
+ var view = { x: 0, y: 0, scale: 1 };
108
+ var selected = null;
109
+ var dirty = false;
110
+ var saving = false;
111
+ var readOnly = window.parent === window;
112
+ var version = 0;
113
+ var undoStack = [], redoStack = [];
114
+ var authored = "";
115
+ var answered = false;
116
+
117
+ // --- adopting the model's markup ----------------------------------------
118
+
119
+ function adopt() {
120
+ // Decoded and parsed here rather than by the HTML parser that read this
121
+ // document, so unbalanced markup can damage only itself. The parsed
122
+ // document is inert exactly as a template was: nothing in it runs, loads
123
+ // or paints until these nodes are moved into the page.
124
+ var decoded = new TextDecoder().decode(
125
+ Uint8Array.from(atob(source.textContent), function (ch) { return ch.charCodeAt(0); })
126
+ );
127
+ var frag = new DOMParser().parseFromString(decoded, "text/html");
128
+ var styles = frag.querySelectorAll("style");
129
+ for (var i = 0; i < styles.length; i++) authored += styles[i].textContent + "\n";
130
+ if (authored.trim()) {
131
+ var live = document.createElement("style");
132
+ live.id = "oh-authored";
133
+ live.textContent = authored;
134
+ document.head.appendChild(live);
135
+ }
136
+
137
+ var boards = frag.querySelectorAll("artboard");
138
+ if (!boards.length) {
139
+ // Loose markup with no artboard is not an error worth showing a blank
140
+ // page for: the intent is obvious, so it becomes one board at the
141
+ // default size and the next save writes it back in the proper shape.
142
+ var only = document.createElement("artboard");
143
+ only.setAttribute("title", "Artboard");
144
+ while (frag.body.firstChild) {
145
+ var node = frag.body.firstChild;
146
+ frag.body.removeChild(node);
147
+ if (node.nodeName !== "STYLE") only.appendChild(node);
148
+ }
149
+ world.appendChild(wrap(only));
150
+ } else {
151
+ for (var j = 0; j < boards.length; j++) world.appendChild(wrap(boards[j]));
152
+ }
153
+ }
154
+
155
+ function wrap(board) {
156
+ var w = document.createElement("div");
157
+ w.className = "oh-board";
158
+ var label = document.createElement("div");
159
+ label.className = "oh-label";
160
+ var frame = document.createElement("div");
161
+ frame.className = "oh-frame";
162
+ frame.appendChild(board);
163
+ w.appendChild(label);
164
+ w.appendChild(frame);
165
+ size(w);
166
+ return w;
167
+ }
168
+
169
+ function boardOf(wrapper) { return wrapper.querySelector("artboard"); }
170
+
171
+ function size(wrapper) {
172
+ var board = boardOf(wrapper);
173
+ var w = parseInt(board.getAttribute("width"), 10) || DEFAULT_W;
174
+ var h = parseInt(board.getAttribute("height"), 10) || DEFAULT_H;
175
+ var frame = wrapper.querySelector(".oh-frame");
176
+ frame.style.width = w + "px";
177
+ frame.style.height = h + "px";
178
+ wrapper.querySelector(".oh-label").textContent =
179
+ (board.getAttribute("title") || "Artboard") + " " + w + " x " + h;
180
+ return { w: w, h: h };
181
+ }
182
+
183
+ function layout() {
184
+ var x = 0, y = 0, rowH = 0;
185
+ var kids = world.children;
186
+ for (var i = 0; i < kids.length; i++) {
187
+ var d = size(kids[i]);
188
+ if (x > 0 && x + d.w > ROW_MAX) { x = 0; y += rowH + GAP; rowH = 0; }
189
+ kids[i].style.left = x + "px";
190
+ kids[i].style.top = y + "px";
191
+ x += d.w + GAP;
192
+ rowH = Math.max(rowH, d.h + LABEL_H);
193
+ }
194
+ }
195
+
196
+ // --- the stage ----------------------------------------------------------
197
+
198
+ function paintView() {
199
+ world.style.transform =
200
+ "translate(" + view.x + "px," + view.y + "px) scale(" + view.scale + ")";
201
+ placeOutline();
202
+ zoomLabel.textContent = Math.round(view.scale * 100) + "%";
203
+ }
204
+
205
+ function zoomAt(clientX, clientY, factor) {
206
+ var next = Math.min(4, Math.max(0.05, view.scale * factor));
207
+ // Keep the point under the cursor fixed, which is the only zoom that feels
208
+ // like a canvas rather than a slider attached to a picture.
209
+ view.x = clientX - (clientX - view.x) * (next / view.scale);
210
+ view.y = clientY - (clientY - view.y) * (next / view.scale);
211
+ view.scale = next;
212
+ paintView();
213
+ }
214
+
215
+ function fit() {
216
+ var kids = world.children;
217
+ if (!kids.length) return false;
218
+ // A stage with no size yet yields a negative scale that clamps to the
219
+ // floor, which is how the first paint used to land at 5% and look empty.
220
+ // Reporting failure lets the caller wait for a size instead of guessing.
221
+ if (!stage.clientWidth || !stage.clientHeight) return false;
222
+ var maxX = 0, maxY = 0;
223
+ for (var i = 0; i < kids.length; i++) {
224
+ var d = size(kids[i]);
225
+ maxX = Math.max(maxX, parseFloat(kids[i].style.left) + d.w);
226
+ maxY = Math.max(maxY, parseFloat(kids[i].style.top) + d.h + LABEL_H);
227
+ }
228
+ var pad = 48;
229
+ var sx = (stage.clientWidth - pad * 2) / maxX;
230
+ var sy = (stage.clientHeight - pad * 2) / maxY;
231
+ view.scale = Math.min(1, Math.max(0.05, Math.min(sx, sy)));
232
+ view.x = (stage.clientWidth - maxX * view.scale) / 2;
233
+ view.y = (stage.clientHeight - maxY * view.scale) / 2;
234
+ paintView();
235
+ return true;
236
+ }
237
+
238
+ stage.addEventListener("wheel", function (e) {
239
+ e.preventDefault();
240
+ if (e.ctrlKey || e.metaKey) zoomAt(e.clientX, e.clientY, e.deltaY < 0 ? 1.12 : 1 / 1.12);
241
+ else { view.x -= e.deltaX; view.y -= e.deltaY; paintView(); }
242
+ }, { passive: false });
243
+
244
+ var pan = null;
245
+ stage.addEventListener("pointerdown", function (e) {
246
+ var onBoard = e.target.closest && e.target.closest(".oh-frame");
247
+ // Dragging the empty stage pans; dragging inside a board would otherwise
248
+ // make selecting a small element a coin flip between select and pan.
249
+ if (onBoard && e.button === 0 && !e.altKey) return;
250
+ pan = { x: e.clientX, y: e.clientY, ox: view.x, oy: view.y };
251
+ stage.classList.add("oh-panning");
252
+ stage.setPointerCapture(e.pointerId);
253
+ });
254
+ stage.addEventListener("pointermove", function (e) {
255
+ if (!pan) return;
256
+ view.x = pan.ox + (e.clientX - pan.x);
257
+ view.y = pan.oy + (e.clientY - pan.y);
258
+ paintView();
259
+ });
260
+ function endPan(e) {
261
+ if (!pan) return;
262
+ pan = null;
263
+ stage.classList.remove("oh-panning");
264
+ try { stage.releasePointerCapture(e.pointerId); } catch (err) {}
265
+ }
266
+ stage.addEventListener("pointerup", endPan);
267
+ stage.addEventListener("pointercancel", endPan);
268
+ window.addEventListener("resize", placeOutline);
269
+
270
+ // --- selection ----------------------------------------------------------
271
+
272
+ stage.addEventListener("click", function (e) {
273
+ if (readOnly) return;
274
+ var frame = e.target.closest && e.target.closest(".oh-frame");
275
+ if (!frame) { select(null); return; }
276
+ var target = e.target;
277
+ if (target === frame) target = frame.querySelector("artboard");
278
+ select(target);
279
+ });
280
+
281
+ stage.addEventListener("dblclick", function (e) {
282
+ if (readOnly || !selected) return;
283
+ if (selected.children.length) return;
284
+ if (selected.nodeName === "ARTBOARD") return;
285
+ snapshot();
286
+ selected.setAttribute("contenteditable", "true");
287
+ selected.focus();
288
+ var range = document.createRange();
289
+ range.selectNodeContents(selected);
290
+ var sel = window.getSelection();
291
+ sel.removeAllRanges();
292
+ sel.addRange(range);
293
+ });
294
+
295
+ document.addEventListener("focusout", function (e) {
296
+ if (e.target && e.target.getAttribute && e.target.getAttribute("contenteditable")) {
297
+ e.target.removeAttribute("contenteditable");
298
+ touch();
299
+ paintProps();
300
+ }
301
+ });
302
+
303
+ function select(el) {
304
+ selected = el;
305
+ placeOutline();
306
+ paintProps();
307
+ }
308
+
309
+ function placeOutline() {
310
+ if (!selected || !selected.isConnected) { outline.style.display = "none"; return; }
311
+ var r = selected.getBoundingClientRect();
312
+ outline.style.display = "block";
313
+ outline.style.left = r.left + "px";
314
+ outline.style.top = r.top + "px";
315
+ outline.style.width = r.width + "px";
316
+ outline.style.height = r.height + "px";
317
+ }
318
+
319
+ // --- history ------------------------------------------------------------
320
+
321
+ function snapshot() {
322
+ undoStack.push(world.innerHTML);
323
+ if (undoStack.length > 50) undoStack.shift();
324
+ redoStack.length = 0;
325
+ }
326
+
327
+ function restore(html) {
328
+ world.innerHTML = html;
329
+ selected = null;
330
+ layout();
331
+ placeOutline();
332
+ paintProps();
333
+ touch();
334
+ }
335
+
336
+ function undo() {
337
+ if (!undoStack.length) return;
338
+ redoStack.push(world.innerHTML);
339
+ restore(undoStack.pop());
340
+ }
341
+
342
+ function redo() {
343
+ if (!redoStack.length) return;
344
+ undoStack.push(world.innerHTML);
345
+ restore(redoStack.pop());
346
+ }
347
+
348
+ function touch() { dirty = true; paintChrome(); }
349
+
350
+ // --- saving -------------------------------------------------------------
351
+
352
+ function clean(node) {
353
+ node.removeAttribute("contenteditable");
354
+ if (node.getAttribute("style") === "") node.removeAttribute("style");
355
+ var kids = node.children;
356
+ for (var i = 0; i < kids.length; i++) clean(kids[i]);
357
+ }
358
+
359
+ /**
360
+ * Back to the shape the model wrote: an optional style block and one
361
+ * artboard element per board. The wrappers, the labels and the frames are
362
+ * this editor's furniture and must not end up in the artifact, or the next
363
+ * load would wrap the wrappers.
364
+ */
365
+ function serialize() {
366
+ var parts = [];
367
+ if (authored.trim()) parts.push("<style>\n" + authored.trim() + "\n</style>");
368
+ var kids = world.children;
369
+ for (var i = 0; i < kids.length; i++) {
370
+ var board = boardOf(kids[i]).cloneNode(true);
371
+ clean(board);
372
+ parts.push(board.outerHTML);
373
+ }
374
+ return parts.join("\n\n");
375
+ }
376
+
377
+ function save() {
378
+ if (readOnly || saving || !dirty) return;
379
+ saving = true;
380
+ paintChrome();
381
+ // The parent frame is the only recipient, and the artifact is not a
382
+ // secret from it — it is the thing it is showing. "*" rather than a real
383
+ // origin because a sandboxed document has an opaque origin and cannot
384
+ // learn its parent's.
385
+ window.parent.postMessage(
386
+ { source: "oharness-canvas", type: "save", content: serialize() }, "*"
387
+ );
388
+ }
389
+
390
+ window.addEventListener("message", function (e) {
391
+ var d = e.data;
392
+ if (!d || d.source !== "oharness-canvas-host") return;
393
+ if (e.source !== window.parent) return;
394
+ answered = true;
395
+ if (d.type === "hello") { readOnly = !d.canSave; version = d.version || 0; paintChrome(); }
396
+ if (d.type === "saved") { saving = false; dirty = false; version = d.version || version; note("Saved as v" + version); paintChrome(); }
397
+ if (d.type === "error") { saving = false; note(d.message || "Save failed"); paintChrome(); }
398
+ });
399
+
400
+ // --- chrome, in a shadow root -------------------------------------------
401
+ //
402
+ // Shadow rather than ordinary elements because the artboards carry the
403
+ // model's own <style> into this document. A single "* { box-sizing }" or a
404
+ // "button { }" rule in it would otherwise reach the toolbar, and the user
405
+ // would be looking at a broken editor with no idea why.
406
+
407
+ var ui = document.getElementById("oh-ui").attachShadow({ mode: "open" });
408
+ ui.innerHTML =
409
+ "<style>" +
410
+ ":host{position:fixed;inset:0;pointer-events:none;font:13px/1.5 ui-sans-serif,system-ui,sans-serif}" +
411
+ "*{box-sizing:border-box}" +
412
+ "#bar{position:absolute;left:12px;top:12px;display:flex;gap:6px;align-items:center;" +
413
+ "pointer-events:auto;background:var(--bg);border:1px solid var(--line);border-radius:10px;" +
414
+ "padding:6px;box-shadow:0 4px 16px rgba(0,0,0,.12)}" +
415
+ "button{font:inherit;color:var(--fg);background:transparent;border:0;border-radius:6px;" +
416
+ "padding:5px 9px;cursor:pointer}" +
417
+ "button:hover:not(:disabled){background:var(--hover)}" +
418
+ "button:disabled{opacity:.4;cursor:default}" +
419
+ "#save{background:var(--accent);color:var(--bg);font-weight:600}" +
420
+ "#save:hover:not(:disabled){background:var(--accent)}" +
421
+ "#zoom{min-width:48px;text-align:center;color:var(--dim);user-select:none}" +
422
+ ".sep{width:1px;height:18px;background:var(--line)}" +
423
+ "#note{position:absolute;left:12px;bottom:12px;pointer-events:none;color:var(--dim);" +
424
+ "background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:5px 9px;opacity:0;" +
425
+ "transition:opacity .2s}" +
426
+ "#note.on{opacity:1}" +
427
+ "#outline{position:fixed;display:none;pointer-events:none;outline:2px solid var(--select);" +
428
+ "outline-offset:1px;border-radius:2px}" +
429
+ "#props{position:absolute;right:12px;top:12px;width:232px;pointer-events:auto;display:none;" +
430
+ "background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:10px;" +
431
+ "box-shadow:0 4px 16px rgba(0,0,0,.12);max-height:calc(100% - 24px);overflow:auto}" +
432
+ "#props.on{display:block}" +
433
+ "h4{margin:0 0 8px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;color:var(--dim)}" +
434
+ "label{display:block;margin:0 0 8px;color:var(--dim);font-size:12px}" +
435
+ "input,textarea{width:100%;font:inherit;margin-top:3px;padding:5px 7px;color:var(--fg);" +
436
+ "background:var(--field);border:1px solid var(--line);border-radius:6px}" +
437
+ "textarea{resize:vertical;min-height:56px}" +
438
+ ".pair{display:flex;gap:8px}" +
439
+ "#del{width:100%;background:var(--field);color:#dc2626;margin-top:2px}" +
440
+ // Two accents, because they say different things. The first is the app's
441
+ // own primary — inverted ink, the same as every other committing action in
442
+ // page.ts — so Save looks like it belongs to the harness. The select one stays
443
+ // a signal colour: a selection drawn in the text colour reads as a border
444
+ // the design itself has, which on a canvas of borders is exactly wrong.
445
+ ":host{--bg:#fff;--fg:#18181b;--dim:#71717a;--line:#e4e4e7;--hover:#f4f4f5;" +
446
+ "--field:#fafafa;--accent:#111827;--select:#2563eb}" +
447
+ "@media (prefers-color-scheme:dark){:host{--bg:#1c1c1f;--fg:#fafafa;--dim:#a1a1aa;" +
448
+ "--line:#3f3f46;--hover:#27272a;--field:#242428;--accent:#e5e7eb;--select:#3b82f6}}" +
449
+ "</style>" +
450
+ '<div id="bar">' +
451
+ '<button id="zout" title="Zoom out">&minus;</button>' +
452
+ '<span id="zoom">100%</span>' +
453
+ '<button id="zin" title="Zoom in">+</button>' +
454
+ '<button id="fit">Fit</button>' +
455
+ '<span class="sep"></span>' +
456
+ '<button id="undo" title="Undo">Undo</button>' +
457
+ '<button id="redo" title="Redo">Redo</button>' +
458
+ '<span class="sep"></span>' +
459
+ '<button id="save">Save</button>' +
460
+ "</div>" +
461
+ '<div id="props"></div>' +
462
+ '<div id="outline"></div>' +
463
+ '<div id="note"></div>';
464
+
465
+ var outline = ui.getElementById("outline");
466
+ var zoomLabel = ui.getElementById("zoom");
467
+ var saveBtn = ui.getElementById("save");
468
+ var props = ui.getElementById("props");
469
+ var noteBox = ui.getElementById("note");
470
+
471
+ ui.getElementById("zin").onclick = function () { zoomAt(stage.clientWidth / 2, stage.clientHeight / 2, 1.2); };
472
+ ui.getElementById("zout").onclick = function () { zoomAt(stage.clientWidth / 2, stage.clientHeight / 2, 1 / 1.2); };
473
+ ui.getElementById("fit").onclick = fit;
474
+ ui.getElementById("undo").onclick = undo;
475
+ ui.getElementById("redo").onclick = redo;
476
+ saveBtn.onclick = save;
477
+
478
+ var noteTimer = null;
479
+ function note(text) {
480
+ noteBox.textContent = text;
481
+ noteBox.classList.add("on");
482
+ clearTimeout(noteTimer);
483
+ noteTimer = setTimeout(function () { noteBox.classList.remove("on"); }, 2200);
484
+ }
485
+
486
+ function paintChrome() {
487
+ // Read-only is stated by removing the controls, not by leaving a Save
488
+ // button that does nothing. A canvas opened in its own tab or served from
489
+ // the website has no host to save through, and pretending otherwise is
490
+ // how someone loses an afternoon of edits.
491
+ ui.getElementById("save").style.display = readOnly ? "none" : "";
492
+ ui.getElementById("undo").style.display = readOnly ? "none" : "";
493
+ ui.getElementById("redo").style.display = readOnly ? "none" : "";
494
+ saveBtn.disabled = saving || !dirty;
495
+ saveBtn.textContent = saving ? "Saving..." : dirty ? "Save" : "Saved";
496
+ ui.getElementById("undo").disabled = !undoStack.length;
497
+ ui.getElementById("redo").disabled = !redoStack.length;
498
+ }
499
+
500
+ // --- properties ---------------------------------------------------------
501
+
502
+ function field(labelText, value, onChange, type) {
503
+ var wrapEl = document.createElement("label");
504
+ wrapEl.textContent = labelText;
505
+ var input = document.createElement(type === "area" ? "textarea" : "input");
506
+ if (type && type !== "area") input.type = type;
507
+ input.value = value == null ? "" : value;
508
+ input.addEventListener("change", function () { onChange(input.value); });
509
+ wrapEl.appendChild(input);
510
+ return wrapEl;
511
+ }
512
+
513
+ function paintProps() {
514
+ props.replaceChildren();
515
+ if (readOnly || !selected || !selected.isConnected) {
516
+ props.classList.remove("on");
517
+ return;
518
+ }
519
+ props.classList.add("on");
520
+ var isBoard = selected.nodeName === "ARTBOARD";
521
+ var head = document.createElement("h4");
522
+ head.textContent = isBoard ? "Artboard" : selected.nodeName.toLowerCase();
523
+ props.appendChild(head);
524
+
525
+ if (isBoard) {
526
+ var wrapper = selected.closest(".oh-board");
527
+ props.appendChild(field("Title", selected.getAttribute("title") || "", function (v) {
528
+ snapshot(); selected.setAttribute("title", v); layout(); touch(); placeOutline();
529
+ }));
530
+ var pair = document.createElement("div");
531
+ pair.className = "pair";
532
+ pair.appendChild(field("Width", selected.getAttribute("width") || DEFAULT_W, function (v) {
533
+ snapshot(); selected.setAttribute("width", v); layout(); touch(); placeOutline();
534
+ }, "number"));
535
+ pair.appendChild(field("Height", selected.getAttribute("height") || DEFAULT_H, function (v) {
536
+ snapshot(); selected.setAttribute("height", v); layout(); touch(); placeOutline();
537
+ }, "number"));
538
+ props.appendChild(pair);
539
+ if (wrapper) props.appendChild(removeButton("Delete artboard", wrapper));
540
+ return;
541
+ }
542
+
543
+ if (!selected.children.length) {
544
+ props.appendChild(field("Text", selected.textContent, function (v) {
545
+ snapshot(); selected.textContent = v; touch(); placeOutline();
546
+ }, "area"));
547
+ }
548
+ var css = getComputedStyle(selected);
549
+ props.appendChild(field("Colour", toHex(css.color), function (v) {
550
+ snapshot(); selected.style.color = v; touch();
551
+ }, "color"));
552
+ props.appendChild(field("Background", toHex(css.backgroundColor), function (v) {
553
+ snapshot(); selected.style.background = v; touch();
554
+ }, "color"));
555
+ props.appendChild(field("Font size", parseInt(css.fontSize, 10), function (v) {
556
+ snapshot(); selected.style.fontSize = v + "px"; touch(); placeOutline();
557
+ }, "number"));
558
+ props.appendChild(removeButton("Delete element", selected));
559
+ }
560
+
561
+ function removeButton(labelText, target) {
562
+ var b = document.createElement("button");
563
+ b.id = "del";
564
+ b.textContent = labelText;
565
+ b.onclick = function () {
566
+ snapshot();
567
+ target.remove();
568
+ select(null);
569
+ layout();
570
+ touch();
571
+ };
572
+ return b;
573
+ }
574
+
575
+ /** Computed colours arrive as rgb(); the colour input only speaks hex. */
576
+ function toHex(value) {
577
+ var m = /rgba?\(([^)]+)\)/.exec(value || "");
578
+ if (!m) return "#000000";
579
+ var p = m[1].split(",");
580
+ if (p.length > 3 && parseFloat(p[3]) === 0) return "#ffffff";
581
+ var hex = "#";
582
+ for (var i = 0; i < 3; i++) {
583
+ var n = Math.max(0, Math.min(255, parseInt(p[i], 10) || 0));
584
+ hex += (n < 16 ? "0" : "") + n.toString(16);
585
+ }
586
+ return hex;
587
+ }
588
+
589
+ // --- keys ---------------------------------------------------------------
590
+
591
+ document.addEventListener("keydown", function (e) {
592
+ var editing = document.activeElement && (
593
+ document.activeElement.id === "oh-ui" ||
594
+ (document.activeElement.getAttribute && document.activeElement.getAttribute("contenteditable"))
595
+ );
596
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") {
597
+ e.preventDefault(); save(); return;
598
+ }
599
+ if (editing) {
600
+ if (e.key === "Escape" && document.activeElement.blur) document.activeElement.blur();
601
+ return;
602
+ }
603
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z") {
604
+ e.preventDefault();
605
+ if (e.shiftKey) redo(); else undo();
606
+ return;
607
+ }
608
+ if (e.key === "Escape") { select(null); return; }
609
+ if ((e.key === "Delete" || e.key === "Backspace") && selected && !readOnly) {
610
+ e.preventDefault();
611
+ snapshot();
612
+ var target = selected.nodeName === "ARTBOARD" ? selected.closest(".oh-board") : selected;
613
+ target.remove();
614
+ select(null);
615
+ layout();
616
+ touch();
617
+ }
618
+ if (e.key === "0" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); fit(); }
619
+ });
620
+
621
+ // --- go -----------------------------------------------------------------
622
+
623
+ adopt();
624
+ layout();
625
+ paintChrome();
626
+ // The frame is often still zero-sized when this script runs — it is inside
627
+ // an iframe that has not been laid out yet — so the first fit waits for the
628
+ // stage to have a size rather than fitting to nothing.
629
+ var fitted = fit();
630
+ if (typeof ResizeObserver === "function") {
631
+ new ResizeObserver(function () {
632
+ if (!fitted) fitted = fit();
633
+ else placeOutline();
634
+ }).observe(stage);
635
+ }
636
+ if (window.parent !== window) {
637
+ window.parent.postMessage({ source: "oharness-canvas", type: "ready" }, "*");
638
+ // A parent that never answers is a parent that does not speak this
639
+ // protocol. Falling back to read-only is the honest end state.
640
+ setTimeout(function () {
641
+ if (!answered) { readOnly = true; paintChrome(); paintProps(); }
642
+ }, 2000);
643
+ }
644
+ })();
645
+ `;
646
+ //# sourceMappingURL=canvas.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"canvas.js","sourceRoot":"","sources":["../src/canvas.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,UAAU,cAAc,CAAC,QAAkB,EAAE,QAAyB;IAC1E,OAAO;QACL,iDAAiD;QACjD,qEAAqE;QACrE,UAAU,UAAU,CAAC,QAAQ,CAAC,KAAK,CAAC,UAAU;QAC9C,UAAU,UAAU,uBAAuB;QAC3C,0EAA0E;QAC1E,wEAAwE;QACxE,wEAAwE;QACxE,0EAA0E;QAC1E,sEAAsE;QACtE,sEAAsE;QACtE,uEAAuE;QACvE,4CAA4C,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,WAAW;QAC/G,oDAAoD;QACpD,wBAAwB;QACxB,WAAW,SAAS,WAAW;QAC/B,gBAAgB;KACjB,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;AACb,CAAC;AAED;2EAC2E;AAC3E,SAAS,UAAU,CAAC,IAAY;IAC9B,OAAO,IAAI;SACR,UAAU,CAAC,GAAG,EAAE,OAAO,CAAC;SACxB,UAAU,CAAC,GAAG,EAAE,MAAM,CAAC;SACvB,UAAU,CAAC,GAAG,EAAE,MAAM,CAAC;SACvB,UAAU,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC;AAC/B,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,GAAG;IACjB,4FAA4F;IAC5F,mEAAmE;IACnE,oFAAoF;IACpF,uCAAuC;IACvC,gEAAgE;IAChE,8BAA8B;IAC9B,2EAA2E;IAC3E,yDAAyD;IACzD,gFAAgF;IAChF,kEAAkE;IAClE,kEAAkE;IAClE,kEAAkE;IAClE,gEAAgE;IAChE,sCAAsC;CACvC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;AAEX;;;;;;;;;;;;;GAaG;AACH,MAAM,SAAS,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwiB3B,CAAC"}