@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/analytics.d.ts +47 -0
- package/dist/analytics.js +323 -0
- package/dist/analytics.js.map +1 -0
- package/dist/artifacts.d.ts +53 -0
- package/dist/artifacts.js +129 -0
- package/dist/artifacts.js.map +1 -0
- package/dist/canvas.d.ts +28 -0
- package/dist/canvas.js +646 -0
- package/dist/canvas.js.map +1 -0
- package/dist/changes.d.ts +54 -0
- package/dist/changes.js +154 -0
- package/dist/changes.js.map +1 -0
- package/dist/files.d.ts +48 -0
- package/dist/files.js +168 -0
- package/dist/files.js.map +1 -0
- package/dist/i18n.d.ts +15 -0
- package/dist/i18n.js +625 -0
- package/dist/i18n.js.map +1 -0
- package/dist/icons.d.ts +9 -0
- package/dist/icons.js +197 -0
- package/dist/icons.js.map +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +148 -0
- package/dist/main.js.map +1 -0
- package/dist/markdown.d.ts +10 -0
- package/dist/markdown.js +253 -0
- package/dist/markdown.js.map +1 -0
- package/dist/page.d.ts +1 -0
- package/dist/page.js +8591 -0
- package/dist/page.js.map +1 -0
- package/dist/pick.d.ts +62 -0
- package/dist/pick.js +215 -0
- package/dist/pick.js.map +1 -0
- package/dist/prefs.d.ts +59 -0
- package/dist/prefs.js +66 -0
- package/dist/prefs.js.map +1 -0
- package/dist/projects.d.ts +34 -0
- package/dist/projects.js +129 -0
- package/dist/projects.js.map +1 -0
- package/dist/server.d.ts +50 -0
- package/dist/server.js +2789 -0
- package/dist/server.js.map +1 -0
- package/package.json +36 -0
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("&", "&")
|
|
52
|
+
.replaceAll("<", "<")
|
|
53
|
+
.replaceAll(">", ">")
|
|
54
|
+
.replaceAll('"', """);
|
|
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">−</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"}
|