truecanvas 0.3.0 → 0.4.1

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.
@@ -0,0 +1,659 @@
1
+ // src/share/viewer.ts
2
+ function viewerHtml() {
3
+ return `<!doctype html>
4
+ <html lang="en">
5
+ <head>
6
+ <meta charset="utf-8">
7
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
8
+ <meta name="robots" content="noindex">
9
+ <title>Design review</title>
10
+ <style>
11
+ :root {
12
+ --bg: #f3f2ef; --dot: rgba(0,0,0,.07); --panel: rgba(255,255,255,.86); --line: rgba(0,0,0,.08);
13
+ --text: #1c1b19; --muted: #77756f; --frame-shadow: 0 1px 2px rgba(0,0,0,.06), 0 12px 40px -12px rgba(0,0,0,.18);
14
+ --ease-out: cubic-bezier(.23,1,.32,1); --panel-solid: #fff; --field: #f3f2ef; --accent: #ea6a3c; --accent-soft: rgba(234,106,60,.12);
15
+ color-scheme: light;
16
+ }
17
+ @media (prefers-color-scheme: dark) {
18
+ :root { --bg: #151514; --dot: rgba(255,255,255,.06); --panel: rgba(30,30,28,.86); --line: rgba(255,255,255,.09);
19
+ --text: #ecebe8; --muted: #9a9892; --frame-shadow: 0 1px 2px rgba(0,0,0,.4), 0 16px 48px -12px rgba(0,0,0,.6); color-scheme: dark;
20
+ --panel-solid: #1e1e1c; --field: #2a2a28; --accent-soft: rgba(234,106,60,.2); }
21
+ }
22
+ * { box-sizing: border-box; }
23
+ [hidden] { display: none !important; }
24
+ html, body { margin: 0; height: 100%; overflow: hidden; background: var(--bg); color: var(--text);
25
+ font: 13px/1.4 Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; -webkit-font-smoothing: antialiased; }
26
+ #stage { position: fixed; inset: 0; cursor: grab; touch-action: none;
27
+ background-image: radial-gradient(var(--dot) 1px, transparent 1px); background-size: 22px 22px; }
28
+ #stage.panning { cursor: grabbing; }
29
+ #world { position: absolute; left: 0; top: 0; transform-origin: 0 0; will-change: transform; }
30
+ .frame { position: absolute; }
31
+ .frame-label { position: absolute; left: 0; bottom: 100%; padding-bottom: 6px; white-space: nowrap; color: var(--muted);
32
+ font-size: 12px; transform-origin: 0 100%; }
33
+ .frame-label b { color: var(--text); font-weight: 550; margin-right: 6px; }
34
+ .frame-box { position: relative; overflow: hidden; border-radius: 2px; background: #fff; box-shadow: var(--frame-shadow); }
35
+ .frame-box img, .frame-box iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; }
36
+ .frame-box iframe { opacity: 0; transition: opacity .25s var(--ease-out); pointer-events: none; }
37
+ .frame-box iframe.ready { opacity: 1; }
38
+ header { position: fixed; top: 12px; left: 12px; right: 12px; display: flex; align-items: center; justify-content: space-between; gap: 12px; pointer-events: none; }
39
+ .pill { pointer-events: auto; display: flex; align-items: center; gap: 10px; height: 40px; padding: 0 14px; border-radius: 12px;
40
+ background: var(--panel); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); border: 1px solid var(--line); box-shadow: 0 1px 2px rgba(0,0,0,.05); }
41
+ .brand img { height: 20px; width: auto; display: block; }
42
+ .brand span { font-weight: 600; letter-spacing: -.01em; }
43
+ .title { font-weight: 550; }
44
+ .meta { color: var(--muted); }
45
+ .vselect { gap: 4px; color: var(--muted); padding: 0 6px 0 8px; margin-right: -6px; max-width: 46vw; }
46
+ .vselect .vlabel { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
47
+ .vselect[aria-expanded="true"] { background: var(--line); color: var(--text); }
48
+ .vlist { position: fixed; z-index: 40; max-height: 320px; overflow: auto; padding: 4px; border-radius: 10px; background: var(--panel-solid);
49
+ border: 1px solid var(--line); box-shadow: 0 12px 36px -10px rgba(0,0,0,.28); animation: pop-in .12s var(--ease-out); }
50
+ .vopt { display: flex; align-items: center; gap: 6px; height: 30px; padding: 0 12px 0 4px; border-radius: 6px; cursor: default; white-space: nowrap; }
51
+ .vopt.active { background: var(--accent); color: #fff; }
52
+ .vopt .vcheck { width: 18px; display: inline-grid; place-items: center; flex: none; }
53
+ .vopt .vname { flex: 1; font-weight: 500; }
54
+ .vopt .vhint { margin-left: 18px; color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
55
+ .vopt.active .vhint { color: rgba(255,255,255,.8); }
56
+ .sep { width: 1px; height: 16px; background: var(--line); }
57
+ footer { position: fixed; bottom: 14px; left: 50%; transform: translateX(-50%); }
58
+ footer .pill { padding: 0 6px; gap: 2px; }
59
+ button { all: unset; display: inline-flex; align-items: center; justify-content: center; min-width: 30px; height: 30px; padding: 0 8px;
60
+ border-radius: 8px; cursor: pointer; color: var(--text); font-variant-numeric: tabular-nums; transition: background-color .15s ease, transform .12s var(--ease-out); }
61
+ button:hover { background: var(--line); }
62
+ button:active { transform: scale(.96); }
63
+ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
64
+ #zoom { min-width: 56px; }
65
+ .made { position: fixed; right: 14px; bottom: 18px; color: var(--muted); font-size: 11.5px; text-decoration: none; }
66
+ .made:hover { color: var(--text); }
67
+ #empty { position: fixed; inset: 0; display: grid; place-items: center; color: var(--muted); }
68
+ /* comments */
69
+ #stage.commenting { cursor: crosshair; }
70
+ .pin { position: absolute; width: 0; height: 0; transform-origin: 0 0; z-index: 2; }
71
+ .pin button { position: absolute; left: 0; bottom: 0; min-width: 30px; height: 30px; padding: 0 9px; border-radius: 15px 15px 15px 3px;
72
+ background: var(--accent); color: #fff; font-weight: 600; font-size: 12.5px; box-shadow: 0 0 0 2px #fff, 0 4px 14px -4px rgba(0,0,0,.4);
73
+ transition: transform .15s var(--ease-out); }
74
+ .pin button:hover { background: var(--accent); transform: scale(1.06); }
75
+ .pin.done button { background: #8b8a85; }
76
+ .pin.on button { box-shadow: 0 0 0 2px #fff, 0 0 0 4px var(--accent), 0 6px 18px -4px rgba(0,0,0,.45); }
77
+ .pin.draft button { background: var(--text); }
78
+ #pop { position: fixed; z-index: 20; width: 320px; max-height: min(460px, calc(100dvh - 120px)); display: flex; flex-direction: column;
79
+ background: var(--panel-solid); border: 1px solid var(--line); border-radius: 14px; box-shadow: 0 18px 50px -12px rgba(0,0,0,.35); overflow: hidden;
80
+ animation: pop-in .16s var(--ease-out); }
81
+ @keyframes pop-in { from { opacity: 0; transform: translateY(4px) scale(.98); } }
82
+ #pop[hidden], #panel[hidden] { display: none; }
83
+ .pop-head { display: flex; align-items: center; gap: 8px; padding: 10px 10px 8px 14px; border-bottom: 1px solid var(--line); font-weight: 550; }
84
+ .pop-head span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
85
+ .pop-msgs { overflow-y: auto; padding: 4px 14px; }
86
+ .msg { padding: 10px 0; border-bottom: 1px solid var(--line); }
87
+ .msg:last-child { border-bottom: 0; }
88
+ .msg-who { display: flex; align-items: baseline; gap: 6px; margin-bottom: 3px; }
89
+ .msg-who b { font-weight: 600; }
90
+ .msg-who small { color: var(--muted); }
91
+ .badge { font-size: 10.5px; font-weight: 600; padding: 1px 6px; border-radius: 6px; background: var(--accent-soft); color: var(--accent); }
92
+ .msg p { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
93
+ .resolved-note { color: var(--muted); font-size: 12px; padding: 8px 14px 0; }
94
+ .compose { display: flex; flex-direction: column; gap: 8px; padding: 10px 14px 12px; }
95
+ .compose input, .compose textarea { font: inherit; color: var(--text); background: var(--field); border: 1px solid transparent; border-radius: 9px; padding: 8px 10px; resize: none; width: 100%; }
96
+ .compose input:focus, .compose textarea:focus { outline: none; border-color: var(--accent); }
97
+ .compose-actions { display: flex; justify-content: flex-end; gap: 6px; align-items: center; }
98
+ .compose-actions .hint { margin-right: auto; color: var(--muted); font-size: 11.5px; }
99
+ .btn-primary { background: var(--accent); color: #fff; font-weight: 550; padding: 0 12px; }
100
+ .btn-primary:hover { background: var(--accent); filter: brightness(1.06); }
101
+ .btn-primary[disabled] { opacity: .5; pointer-events: none; }
102
+ #comment.on { background: var(--accent); color: #fff; }
103
+ #comment svg, #list svg { display: block; }
104
+ .count { margin-left: 5px; font-size: 11.5px; color: var(--muted); font-variant-numeric: tabular-nums; }
105
+ #panel { position: fixed; z-index: 15; top: 62px; right: 12px; bottom: 70px; width: 320px; display: flex; flex-direction: column;
106
+ background: var(--panel-solid); border: 1px solid var(--line); border-radius: 14px; box-shadow: 0 18px 50px -16px rgba(0,0,0,.3); overflow: hidden; }
107
+ .panel-head { display: flex; align-items: center; padding: 10px 10px 10px 14px; border-bottom: 1px solid var(--line); font-weight: 600; }
108
+ .panel-head span { flex: 1; }
109
+ .panel-list { overflow-y: auto; flex: 1; }
110
+ .item { all: unset; box-sizing: border-box; display: flex; gap: 10px; width: 100%; padding: 12px 14px; border-bottom: 1px solid var(--line); cursor: pointer; }
111
+ .item:hover { background: var(--line); }
112
+ .item:focus-visible { outline: 2px solid #3b82f6; outline-offset: -2px; }
113
+ .item .num { flex: none; width: 22px; height: 22px; border-radius: 11px 11px 11px 3px; background: var(--accent); color: #fff; font-size: 11px; font-weight: 600; display: grid; place-items: center; }
114
+ .item.done .num { background: #8b8a85; }
115
+ .item div { min-width: 0; }
116
+ .item p { margin: 2px 0 0; color: var(--muted); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
117
+ .item small { color: var(--muted); }
118
+ .empty-list { padding: 24px 16px; color: var(--muted); text-align: center; }
119
+ .toast { position: fixed; left: 50%; bottom: 74px; transform: translateX(-50%); z-index: 30; padding: 8px 14px; border-radius: 10px; background: var(--text); color: var(--bg); font-size: 12.5px; }
120
+ @media (max-width: 640px) {
121
+ .meta, .sep, .made, .vdate { display: none; } header { top: 8px; left: 8px; right: 8px; gap: 8px; }
122
+ .brand:has(img) span { display: none; }
123
+ header .pill { min-width: 0; padding: 0 10px; }
124
+ header .pill:last-child { flex: 1 1 auto; justify-content: flex-end; }
125
+ .title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
126
+ #pop { left: 8px !important; right: 8px; top: auto !important; bottom: 8px; width: auto; max-height: 60dvh; }
127
+ #panel { left: 8px; right: 8px; top: 56px; bottom: 68px; width: auto; }
128
+ }
129
+ @media (prefers-reduced-motion: reduce) { .frame-box iframe { transition: none; } }
130
+ </style>
131
+ </head>
132
+ <body>
133
+ <div id="stage" aria-label="Design canvas"><div id="world"></div></div>
134
+ <header>
135
+ <div class="pill brand" id="brand"><span>Design review</span></div>
136
+ <div class="pill"><span class="title" id="title"></span><span class="sep"></span><span class="meta" id="meta"></span><button id="list" hidden aria-label="Comments" title="Comments"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5h16v11H9l-5 4z"/></svg><span class="count" id="count">0</span></button></div>
137
+ </header>
138
+ <aside id="panel" hidden aria-label="Comments"><div class="panel-head"><span>Comments</span><button id="panel-close" aria-label="Close">\u2715</button></div><div class="panel-list" id="panel-list"></div></aside>
139
+ <div id="pop" hidden role="dialog" aria-label="Comment"></div>
140
+ <footer><div class="pill">
141
+ <button id="out" aria-label="Zoom out" title="Zoom out (-)">\u2212</button>
142
+ <button id="zoom" aria-label="Zoom to fit" title="Zoom to fit (1)">100%</button>
143
+ <button id="in" aria-label="Zoom in" title="Zoom in (+)">+</button>
144
+ <span class="sep" id="comment-sep" hidden></span>
145
+ <button id="comment" hidden aria-pressed="false" title="Comment (C)"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5h16v11H9l-5 4z"/><path d="M12 8v5M9.5 10.5h5"/></svg><span style="margin-left:6px">Comment</span></button>
146
+ </div></footer>
147
+ <a class="made" href="https://truecanvas.dev" target="_blank" rel="noopener">Made with Truecanvas</a>
148
+ <script>
149
+ (async () => {
150
+ const stage = document.getElementById("stage");
151
+ const world = document.getElementById("world");
152
+ const zoomLabel = document.getElementById("zoom");
153
+ let manifest;
154
+ try {
155
+ manifest = await (await fetch("manifest.json" + location.search, { cache: "no-store" })).json();
156
+ } catch {
157
+ document.body.insertAdjacentHTML("beforeend", '<div id="empty">This review link has no designs yet.</div>');
158
+ return;
159
+ }
160
+ const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
161
+ document.title = manifest.canvas + " \xB7 Design review";
162
+ document.getElementById("title").textContent = manifest.title || manifest.canvas;
163
+ const day = (t) => new Date(t).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
164
+ const time = (t) => new Date(t).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
165
+ const meta = document.getElementById("meta");
166
+ if (manifest.versions && manifest.versions.length > 1) versionPicker(meta);
167
+ else meta.textContent = day(manifest.createdAt);
168
+
169
+ /** Versions: the latest by default, earlier ones a pick away. A listbox like the editor's selects. */
170
+ function versionPicker(slot) {
171
+ const vs = manifest.versions;
172
+ const label = (v, i) => (v.latest ? "Latest" : "Version " + (vs.length - i));
173
+ const currentIndex = Math.max(0, vs.findIndex((v) => v.id === manifest.id));
174
+ const trigger = document.createElement("button");
175
+ trigger.className = "vselect";
176
+ trigger.setAttribute("aria-haspopup", "listbox");
177
+ trigger.setAttribute("aria-expanded", "false");
178
+ trigger.setAttribute("aria-label", "Version");
179
+ trigger.innerHTML = '<span class="vlabel"></span><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>';
180
+ trigger.querySelector(".vlabel").textContent = label(vs[currentIndex], currentIndex);
181
+ const date = document.createElement("span");
182
+ date.className = "vdate";
183
+ date.textContent = " \xB7 " + day(vs[currentIndex].createdAt);
184
+ trigger.querySelector(".vlabel").appendChild(date);
185
+ slot.replaceWith(trigger);
186
+ let list = null, active = currentIndex;
187
+ const choose = (i) => {
188
+ close();
189
+ if (i === currentIndex) return;
190
+ location.search = i === 0 ? "" : "?v=" + vs[i].id;
191
+ };
192
+ const paint = () => list && list.querySelectorAll(".vopt").forEach((o, i) => o.classList.toggle("active", i === active));
193
+ const close = (focus) => {
194
+ if (!list) return;
195
+ list.remove();
196
+ list = null;
197
+ trigger.setAttribute("aria-expanded", "false");
198
+ removeEventListener("pointerdown", outside, true);
199
+ if (focus) trigger.focus();
200
+ };
201
+ const outside = (e) => { if (list && !list.contains(e.target) && !trigger.contains(e.target)) close(false); };
202
+ const open = () => {
203
+ list = document.createElement("div");
204
+ list.className = "vlist";
205
+ list.setAttribute("role", "listbox");
206
+ list.setAttribute("aria-label", "Version");
207
+ vs.forEach((v, i) => {
208
+ const o = document.createElement("div");
209
+ o.className = "vopt";
210
+ o.setAttribute("role", "option");
211
+ o.setAttribute("aria-selected", String(i === currentIndex));
212
+ o.innerHTML = '<span class="vcheck">' + (i === currentIndex ? '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>' : "") + '</span><span class="vname"></span><span class="vhint"></span>';
213
+ o.querySelector(".vname").textContent = label(v, i);
214
+ o.querySelector(".vhint").textContent = day(v.createdAt) + ", " + time(v.createdAt);
215
+ o.onpointerenter = () => { active = i; paint(); };
216
+ o.onpointerdown = (e) => e.preventDefault();
217
+ o.onclick = () => choose(i);
218
+ list.appendChild(o);
219
+ });
220
+ document.body.appendChild(list);
221
+ const r = trigger.getBoundingClientRect();
222
+ const h = list.offsetHeight, w = Math.max(list.offsetWidth, r.width);
223
+ const up = r.bottom + h + 8 > innerHeight && r.top > h;
224
+ list.style.minWidth = w + "px";
225
+ list.style.left = Math.max(8, Math.min(r.right - w, innerWidth - w - 8)) + "px";
226
+ list.style.top = (up ? r.top - h - 4 : r.bottom + 4) + "px";
227
+ active = currentIndex;
228
+ paint();
229
+ trigger.setAttribute("aria-expanded", "true");
230
+ addEventListener("pointerdown", outside, true);
231
+ };
232
+ trigger.onclick = () => (list ? close(true) : open());
233
+ trigger.onkeydown = (e) => {
234
+ e.stopPropagation();
235
+ if (!list) {
236
+ if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) { e.preventDefault(); open(); }
237
+ return;
238
+ }
239
+ if (e.key === "ArrowDown") { e.preventDefault(); active = Math.min(vs.length - 1, active + 1); paint(); }
240
+ else if (e.key === "ArrowUp") { e.preventDefault(); active = Math.max(0, active - 1); paint(); }
241
+ else if (e.key === "Home") { e.preventDefault(); active = 0; paint(); }
242
+ else if (e.key === "End") { e.preventDefault(); active = vs.length - 1; paint(); }
243
+ else if (e.key === "Enter" || e.key === " ") { e.preventDefault(); choose(active); }
244
+ else if (e.key === "Escape" || e.key === "Tab") close(e.key === "Escape");
245
+ };
246
+ addEventListener("resize", () => close(false));
247
+ }
248
+ if (manifest.brand) {
249
+ document.getElementById("brand").innerHTML = (manifest.brand.logo ? '<img alt="" src="' + esc(manifest.brand.logo) + '">' : "") + (manifest.brand.name ? "<span>" + esc(manifest.brand.name) + "</span>" : "");
250
+ }
251
+
252
+ // layout: frames at their canvas positions
253
+ const frames = manifest.frames;
254
+ const minX = Math.min(...frames.map((f) => f.x)), minY = Math.min(...frames.map((f) => f.y));
255
+ const maxX = Math.max(...frames.map((f) => f.x + f.width)), maxY = Math.max(...frames.map((f) => f.y + f.height));
256
+ const labels = [];
257
+ for (const f of frames) {
258
+ const el = document.createElement("div");
259
+ el.className = "frame";
260
+ el.style.cssText = "left:" + (f.x - minX) + "px;top:" + (f.y - minY) + "px;width:" + f.width + "px;height:" + f.height + "px";
261
+ el.innerHTML = '<div class="frame-label"><b>' + esc(f.name) + "</b>" + f.width + " \xD7 " + Math.round(f.height) + "</div>" +
262
+ '<div class="frame-box" style="width:' + f.width + "px;height:" + f.height + 'px"><img alt="" src="' + esc(f.image) + '"><iframe sandbox title="' + esc(f.name) + '" scrolling="no" loading="lazy" src="' + esc(f.html) + '"></iframe></div>';
263
+ const iframe = el.querySelector("iframe");
264
+ iframe.addEventListener("load", () => iframe.classList.add("ready"));
265
+ el.dataset.name = f.name;
266
+ world.appendChild(el);
267
+ labels.push(el.querySelector(".frame-label"));
268
+ }
269
+ const bounds = { w: maxX - minX, h: maxY - minY };
270
+
271
+ // camera
272
+ let x = 0, y = 0, z = 1;
273
+ const MIN = 0.02, MAX = 4;
274
+ const apply = () => {
275
+ world.style.transform = "translate(" + x + "px," + y + "px) scale(" + z + ")";
276
+ zoomLabel.textContent = Math.round(z * 100) + "%";
277
+ // labels keep a readable size at any zoom
278
+ for (const l of labels) l.style.transform = "scale(" + 1 / z + ")";
279
+ for (const p of world.querySelectorAll(".pin")) p.style.transform = "scale(" + 1 / z + ")";
280
+ if (typeof placePop === "function") placePop();
281
+ };
282
+ const zoomAt = (nz, cx, cy) => {
283
+ nz = Math.min(MAX, Math.max(MIN, nz));
284
+ x = cx - ((cx - x) * nz) / z;
285
+ y = cy - ((cy - y) * nz) / z;
286
+ z = nz;
287
+ apply();
288
+ };
289
+ const side = () => (innerWidth < 640 ? 12 : 72);
290
+ const top = 88;
291
+ // everything in view
292
+ const fit = (box = { x: 0, y: 0, w: bounds.w, h: bounds.h }) => {
293
+ const vw = innerWidth - side() * 2, vh = innerHeight - top - 72;
294
+ z = Math.min(MAX, Math.max(MIN, Math.min(vw / box.w, vh / box.h, 1)));
295
+ x = (innerWidth - box.w * z) / 2 - box.x * z;
296
+ y = top + Math.max(0, (vh - box.h * z) / 2) - box.y * z;
297
+ apply();
298
+ };
299
+ // readable: as wide as the screen allows, from the top (long pages scroll down)
300
+ const fitWidth = (box = { x: 0, y: 0, w: bounds.w, h: bounds.h }) => {
301
+ z = Math.min(1, Math.max(MIN, (innerWidth - side() * 2) / box.w));
302
+ x = (innerWidth - box.w * z) / 2 - box.x * z;
303
+ y = top - box.y * z;
304
+ apply();
305
+ };
306
+ const tall = () => bounds.h * Math.min(1, (innerWidth - side() * 2) / bounds.w) > (innerHeight - top) * 1.5;
307
+ if (tall()) fitWidth();
308
+ else fit();
309
+
310
+ // wheel: pinch or ctrl/cmd zooms, otherwise pans
311
+ stage.addEventListener("wheel", (e) => {
312
+ e.preventDefault();
313
+ if (e.ctrlKey || e.metaKey) zoomAt(z * Math.exp(-e.deltaY * 0.01), e.clientX, e.clientY);
314
+ else { x -= e.deltaX; y -= e.deltaY; apply(); }
315
+ }, { passive: false });
316
+
317
+ // drag to pan, two fingers to pinch
318
+ const pointers = new Map();
319
+ let pinch = null;
320
+ let moved = false, downAt = null;
321
+ stage.addEventListener("pointerdown", (e) => {
322
+ // pins are buttons: they take their own clicks
323
+ if (e.target.closest && e.target.closest(".pin")) return;
324
+ stage.setPointerCapture(e.pointerId);
325
+ moved = false;
326
+ downAt = { x: e.clientX, y: e.clientY };
327
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
328
+ stage.classList.add("panning");
329
+ if (pointers.size === 2) {
330
+ const [a, b] = [...pointers.values()];
331
+ pinch = { d: Math.hypot(a.x - b.x, a.y - b.y), z };
332
+ }
333
+ });
334
+ stage.addEventListener("pointermove", (e) => {
335
+ const prev = pointers.get(e.pointerId);
336
+ if (!prev) return;
337
+ const now = { x: e.clientX, y: e.clientY };
338
+ pointers.set(e.pointerId, now);
339
+ if (downAt && Math.hypot(now.x - downAt.x, now.y - downAt.y) > 4) moved = true;
340
+ if (pointers.size === 2 && pinch) {
341
+ const [a, b] = [...pointers.values()];
342
+ zoomAt(pinch.z * (Math.hypot(a.x - b.x, a.y - b.y) / pinch.d), (a.x + b.x) / 2, (a.y + b.y) / 2);
343
+ } else if (pointers.size === 1) { x += now.x - prev.x; y += now.y - prev.y; apply(); }
344
+ });
345
+ const end = (e) => {
346
+ pointers.delete(e.pointerId);
347
+ if (pointers.size < 2) pinch = null;
348
+ if (!pointers.size) stage.classList.remove("panning");
349
+ };
350
+ stage.addEventListener("pointerup", end);
351
+ stage.addEventListener("pointercancel", end);
352
+
353
+ // double click (or double tap) a frame: read it at full width; elsewhere: everything in view
354
+ let lastTap = 0;
355
+ const zoomToFrameAt = (cx, cy) => {
356
+ const wx = (cx - x) / z, wy = (cy - y) / z;
357
+ const f = frames.find((f) => wx >= f.x - minX && wx <= f.x - minX + f.width && wy >= f.y - minY && wy <= f.y - minY + f.height);
358
+ if (!f) return fit();
359
+ const box = { x: f.x - minX, y: f.y - minY, w: f.width, h: f.height };
360
+ // keep the part that was double clicked in view
361
+ const keepY = wy;
362
+ fitWidth(box);
363
+ y = cy - keepY * z;
364
+ apply();
365
+ };
366
+ stage.addEventListener("pointerup", (e) => {
367
+ if (moved || !downAt) return;
368
+ downAt = null;
369
+ // comment mode: a tap on a frame starts a comment there
370
+ if (commenting) return startComment(e.clientX, e.clientY);
371
+ const now = Date.now();
372
+ if (now - lastTap < 320) { zoomToFrameAt(e.clientX, e.clientY); lastTap = 0; }
373
+ else lastTap = now;
374
+ });
375
+
376
+ const center = () => [innerWidth / 2, innerHeight / 2];
377
+ document.getElementById("in").onclick = () => zoomAt(z * 1.25, ...center());
378
+ document.getElementById("out").onclick = () => zoomAt(z / 1.25, ...center());
379
+ zoomLabel.onclick = () => (tall() ? fitWidth() : fit());
380
+ addEventListener("keydown", (e) => {
381
+ if (e.target.closest && e.target.closest("input, textarea, select")) return;
382
+ if (e.key === "Escape") { closePop(); setCommenting(false); return; }
383
+ if ((e.key === "c" || e.key === "C") && canComment && !e.metaKey && !e.ctrlKey) { setCommenting(!commenting); return; }
384
+ if (e.key === "+" || e.key === "=") zoomAt(z * 1.25, ...center());
385
+ else if (e.key === "-") zoomAt(z / 1.25, ...center());
386
+ else if (e.key === "0") zoomAt(1, ...center());
387
+ else if (e.key === "1") fit();
388
+ else if (e.key === "2") fitWidth();
389
+ });
390
+ addEventListener("resize", apply);
391
+
392
+ // ---------- comments (on a review site: the manifest says so) ----------
393
+ var canComment = !!manifest.comments;
394
+ var commenting = false;
395
+ var threads = [];
396
+ var openId = null;
397
+ var draft = null;
398
+ var popAnchor = null;
399
+ var pop = document.getElementById("pop");
400
+ const studioName = (manifest.brand && manifest.brand.name) || "Studio";
401
+ const storedName = () => { try { return localStorage.getItem("tc-review-name") || ""; } catch { return ""; } };
402
+ const saveName = (n) => { try { localStorage.setItem("tc-review-name", n); } catch {} };
403
+ const ago = (t) => {
404
+ const s = Math.round((Date.now() - t) / 1000);
405
+ if (s < 60) return "just now";
406
+ if (s < 3600) return Math.round(s / 60) + " min ago";
407
+ if (s < 86400) return Math.round(s / 3600) + " h ago";
408
+ return day(t);
409
+ };
410
+ const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
411
+ const toast = (text) => { const t = el("div", "toast", text); t.setAttribute("role", "status"); document.body.appendChild(t); setTimeout(() => t.remove(), 2600); };
412
+ const frameAt = (cx, cy) => {
413
+ const wx = (cx - x) / z, wy = (cy - y) / z;
414
+ const f = frames.find((f) => wx >= f.x - minX && wx <= f.x - minX + f.width && wy >= f.y - minY && wy <= f.y - minY + f.height);
415
+ return f ? { f, fx: wx - (f.x - minX), fy: wy - (f.y - minY) } : null;
416
+ };
417
+ const frameOf = (name) => frames.find((f) => f.name === name);
418
+ const numberOf = (t) => threads.indexOf(t) + 1;
419
+
420
+ function setCommenting(on) {
421
+ if (!canComment) return;
422
+ commenting = on;
423
+ stage.classList.toggle("commenting", on);
424
+ const b = document.getElementById("comment");
425
+ b.classList.toggle("on", on);
426
+ b.setAttribute("aria-pressed", String(on));
427
+ if (on) toast("Click anywhere on the design to comment");
428
+ }
429
+
430
+ function renderPins() {
431
+ for (const p of world.querySelectorAll(".pin")) p.remove();
432
+ const list = draft ? threads.concat([draft]) : threads;
433
+ for (const t of list) {
434
+ const f = frameOf(t.frame);
435
+ if (!f) continue;
436
+ const pin = el("div", "pin" + (t.resolved ? " done" : "") + (t.id === openId ? " on" : "") + (t === draft ? " draft" : ""));
437
+ pin.style.left = f.x - minX + t.x + "px";
438
+ pin.style.top = f.y - minY + t.y + "px";
439
+ pin.style.transform = "scale(" + 1 / z + ")";
440
+ const b = el("button", null, t === draft ? "+" : String(numberOf(t)));
441
+ b.setAttribute("aria-label", t === draft ? "New comment" : "Comment " + numberOf(t) + " by " + t.messages[0].author.name);
442
+ if (t !== draft) b.onclick = (e) => { e.stopPropagation(); openThread(t.id); };
443
+ pin.appendChild(b);
444
+ world.appendChild(pin);
445
+ }
446
+ const open = threads.filter((t) => !t.resolved).length;
447
+ document.getElementById("count").textContent = String(open);
448
+ renderPanel();
449
+ }
450
+
451
+ function renderPanel() {
452
+ const list = document.getElementById("panel-list");
453
+ list.textContent = "";
454
+ if (!threads.length) { list.appendChild(el("div", "empty-list", "No comments yet. Press Comment, then click on the design.")); return; }
455
+ const sorted = threads.slice().sort((a, b) => Number(a.resolved) - Number(b.resolved) || b.updatedAt - a.updatedAt);
456
+ for (const t of sorted) {
457
+ const item = el("button", "item" + (t.resolved ? " done" : ""));
458
+ item.appendChild(el("span", "num", String(numberOf(t))));
459
+ const body = el("div");
460
+ const first = t.messages[0];
461
+ const who = el("div");
462
+ who.appendChild(el("b", null, first.author.name));
463
+ who.appendChild(el("small", null, " " + ago(t.updatedAt) + (t.messages.length > 1 ? " \xB7 " + (t.messages.length - 1) + (t.messages.length === 2 ? " reply" : " replies") : "") + (t.resolved ? " \xB7 resolved" : "")));
464
+ body.appendChild(who);
465
+ body.appendChild(el("p", null, first.text));
466
+ item.appendChild(body);
467
+ item.onclick = () => { focusThread(t); if (innerWidth < 640) document.getElementById("panel").hidden = true; };
468
+ list.appendChild(item);
469
+ }
470
+ }
471
+
472
+ function focusThread(t) {
473
+ const f = frameOf(t.frame);
474
+ if (!f) return;
475
+ if (z < 0.35) z = Math.min(1, (innerWidth - side() * 2) / f.width);
476
+ const px = f.x - minX + t.x, py = f.y - minY + t.y;
477
+ x = innerWidth / 2 - px * z - (innerWidth >= 640 && !document.getElementById("panel").hidden ? 160 : 0);
478
+ // phones: the thread opens as a bottom sheet, so the pin goes in the top part
479
+ y = (innerWidth < 640 ? innerHeight * 0.22 : innerHeight / 2) - py * z;
480
+ apply();
481
+ openThread(t.id);
482
+ }
483
+
484
+ function closePop() {
485
+ pop.hidden = true;
486
+ pop.textContent = "";
487
+ openId = null;
488
+ popAnchor = null;
489
+ if (draft) draft = null;
490
+ renderPins();
491
+ }
492
+
493
+ function placePop() {
494
+ if (!pop || pop.hidden || !popAnchor) return;
495
+ if (innerWidth < 640) return; // a bottom sheet on phones
496
+ const f = frameOf(popAnchor.frame);
497
+ if (!f) return;
498
+ const sx = x + (f.x - minX + popAnchor.x) * z, sy = y + (f.y - minY + popAnchor.y) * z;
499
+ const w = pop.offsetWidth, h = pop.offsetHeight;
500
+ // beside the pin (which rises up and to the right of its point), never over it
501
+ let left = sx + 56, topPx = sy - 40;
502
+ if (left + w > innerWidth - 12) left = sx - w - 12;
503
+ pop.style.left = Math.max(12, left) + "px";
504
+ pop.style.top = Math.min(Math.max(64, topPx), innerHeight - h - 12) + "px";
505
+ }
506
+
507
+ function composer(placeholder, onPost) {
508
+ const box = el("form", "compose");
509
+ let nameInput = null;
510
+ if (!storedName()) {
511
+ nameInput = el("input");
512
+ nameInput.placeholder = "Your name";
513
+ nameInput.autocomplete = "name";
514
+ nameInput.maxLength = 60;
515
+ nameInput.setAttribute("aria-label", "Your name");
516
+ box.appendChild(nameInput);
517
+ }
518
+ const text = el("textarea");
519
+ text.rows = 3;
520
+ text.placeholder = placeholder;
521
+ text.maxLength = 4000;
522
+ text.setAttribute("aria-label", placeholder);
523
+ box.appendChild(text);
524
+ const actions = el("div", "compose-actions");
525
+ actions.appendChild(el("span", "hint", "\u2318/Ctrl + Enter"));
526
+ const post = el("button", "btn-primary", "Post");
527
+ post.type = "submit";
528
+ actions.appendChild(post);
529
+ box.appendChild(actions);
530
+ const valid = () => text.value.trim() && (!nameInput || nameInput.value.trim());
531
+ const sync = () => { post.disabled = !valid(); };
532
+ sync();
533
+ box.oninput = sync;
534
+ text.onkeydown = (e) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); box.requestSubmit(); } if (e.key === "Escape") closePop(); };
535
+ box.onsubmit = async (e) => {
536
+ e.preventDefault();
537
+ if (!valid()) return;
538
+ if (nameInput) saveName(nameInput.value.trim());
539
+ post.disabled = true;
540
+ post.textContent = "Posting\u2026";
541
+ try {
542
+ await onPost(text.value.trim(), storedName());
543
+ } catch (err) {
544
+ toast(err.message || "Couldn't post. Try again.");
545
+ post.textContent = "Post";
546
+ sync();
547
+ }
548
+ };
549
+ setTimeout(() => (nameInput || text).focus(), 30);
550
+ return box;
551
+ }
552
+
553
+ async function send(path, body) {
554
+ const res = await fetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
555
+ const data = await res.json().catch(() => ({}));
556
+ if (!res.ok) throw new Error(data.error || "Couldn't post. Try again.");
557
+ return data;
558
+ }
559
+
560
+ function startComment(cx, cy) {
561
+ const hit = frameAt(cx, cy);
562
+ if (!hit) return;
563
+ openId = null;
564
+ draft = { id: "draft", frame: hit.f.name, x: Math.round(hit.fx), y: Math.round(hit.fy), resolved: false, messages: [] };
565
+ popAnchor = draft;
566
+ pop.textContent = "";
567
+ const head = el("div", "pop-head");
568
+ head.appendChild(el("span", null, "New comment on " + hit.f.name));
569
+ const close = el("button", null, "\u2715");
570
+ close.setAttribute("aria-label", "Cancel");
571
+ close.onclick = closePop;
572
+ head.appendChild(close);
573
+ pop.appendChild(head);
574
+ pop.appendChild(composer("Leave a comment", async (text, name) => {
575
+ const res = await send("comments", { text, name, frame: draft.frame, version: manifest.id, x: draft.x, y: draft.y });
576
+ draft = null;
577
+ threads.push(res.thread);
578
+ setCommenting(false);
579
+ openThread(res.thread.id);
580
+ }));
581
+ pop.hidden = false;
582
+ renderPins();
583
+ placePop();
584
+ }
585
+
586
+ function openThread(id) {
587
+ const t = threads.find((t) => t.id === id);
588
+ if (!t) return;
589
+ draft = null;
590
+ openId = id;
591
+ popAnchor = t;
592
+ pop.textContent = "";
593
+ const head = el("div", "pop-head");
594
+ head.appendChild(el("span", null, "#" + numberOf(t) + " \xB7 " + t.frame));
595
+ const close = el("button", null, "\u2715");
596
+ close.setAttribute("aria-label", "Close");
597
+ close.onclick = closePop;
598
+ head.appendChild(close);
599
+ pop.appendChild(head);
600
+ const msgs = el("div", "pop-msgs");
601
+ for (const m of t.messages) {
602
+ const row = el("div", "msg");
603
+ const who = el("div", "msg-who");
604
+ who.appendChild(el("b", null, m.author.name));
605
+ if (m.author.kind === "studio") who.appendChild(el("span", "badge", studioName));
606
+ who.appendChild(el("small", null, ago(m.at)));
607
+ row.appendChild(who);
608
+ row.appendChild(el("p", null, m.text));
609
+ msgs.appendChild(row);
610
+ }
611
+ pop.appendChild(msgs);
612
+ if (t.resolved) pop.appendChild(el("div", "resolved-note", "Resolved" + (t.resolvedBy ? " by " + t.resolvedBy.name : "") + ". You can still reply."));
613
+ if (t.version && t.version !== manifest.id) pop.appendChild(el("div", "resolved-note", "Left on an earlier version."));
614
+ pop.appendChild(composer("Reply", async (text, name) => {
615
+ const res = await send("comments/" + t.id, { text, name });
616
+ t.messages.push(res.message);
617
+ t.updatedAt = res.message.at;
618
+ openThread(t.id);
619
+ }));
620
+ pop.hidden = false;
621
+ renderPins();
622
+ placePop();
623
+ msgs.scrollTop = msgs.scrollHeight;
624
+ }
625
+
626
+ async function refresh() {
627
+ try {
628
+ const res = await fetch("comments.json", { cache: "no-store" });
629
+ if (!res.ok) return;
630
+ const next = (await res.json()).threads || [];
631
+ const changed = JSON.stringify(next) !== JSON.stringify(threads);
632
+ threads = next;
633
+ if (!changed) return;
634
+ // keep a reply being typed: only redraw an open thread when its composer is empty
635
+ const typing = pop.querySelector("textarea");
636
+ if (openId && !(typing && typing.value)) openThread(openId);
637
+ else renderPins();
638
+ } catch {}
639
+ }
640
+
641
+ if (canComment) {
642
+ for (const id of ["comment", "comment-sep", "list"]) document.getElementById(id).hidden = false;
643
+ document.getElementById("comment").onclick = () => setCommenting(!commenting);
644
+ document.getElementById("list").onclick = () => { const p = document.getElementById("panel"); p.hidden = !p.hidden; };
645
+ document.getElementById("panel-close").onclick = () => (document.getElementById("panel").hidden = true);
646
+ await refresh();
647
+ setInterval(() => document.visibilityState === "visible" && refresh(), 15000);
648
+ addEventListener("visibilitychange", () => document.visibilityState === "visible" && refresh());
649
+ }
650
+ })();
651
+ </script>
652
+ </body>
653
+ </html>
654
+ `;
655
+ }
656
+
657
+ export {
658
+ viewerHtml
659
+ };