truecanvas 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -4
- package/dist/chunk-D565QDII.js +814 -0
- package/dist/chunk-LKFOLM3L.js +54 -0
- package/dist/chunk-NAK4DQWO.js +1005 -0
- package/dist/{chunk-M7B3W7EP.js → chunk-OPKROZI2.js} +333 -7
- package/dist/chunk-OZOV4Y6C.js +45 -0
- package/dist/{chunk-A4QIAVTG.js → chunk-QJ5M3G4E.js} +88 -852
- package/dist/{chunk-TSHD2S36.js → chunk-VWKLYIY6.js} +1 -1
- package/dist/cli.js +798 -962
- package/dist/core/index.js +14 -11
- package/dist/editor/assets/{Menu-BJ3OmqzH.css → Menu-CACFFH7S.css} +1 -1
- package/dist/editor/assets/hub-B_TP_af8.js +2 -0
- package/dist/editor/assets/main-Dj60SnCH.js +26 -0
- package/dist/editor/hub.html +3 -3
- package/dist/editor/index.html +3 -3
- package/dist/hub.d.ts +88 -0
- package/dist/hub.js +15 -0
- package/dist/share/index.d.ts +77 -2
- package/dist/share/index.js +13 -1
- package/dist/vite/index.js +3 -2
- package/package.json +5 -1
- package/dist/chunk-PBKX3SBC.js +0 -97
- package/dist/editor/assets/hub-B5hqZCM2.js +0 -2
- package/dist/editor/assets/main-woTyWmya.js +0 -26
- /package/dist/editor/assets/{Menu-C0Ti-6T2.js → Menu-CZr87VA3.js} +0 -0
|
@@ -40,6 +40,10 @@ header { position: fixed; top: 12px; left: 12px; right: 12px; display: flex; ali
|
|
|
40
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
41
|
.brand img { height: 20px; width: auto; display: block; }
|
|
42
42
|
.brand span { font-weight: 600; letter-spacing: -.01em; }
|
|
43
|
+
.brand .who { font-weight: 450; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 180px; }
|
|
44
|
+
.brand form { display: contents; }
|
|
45
|
+
.brand .who-btn, .brand a.who-btn { height: 28px; padding: 0 8px; border-radius: 8px; font-size: 13px; color: var(--text); text-decoration: none; display: inline-flex; align-items: center; }
|
|
46
|
+
.brand .who-btn:hover { background: var(--line); }
|
|
43
47
|
.title { font-weight: 550; }
|
|
44
48
|
.meta { color: var(--muted); }
|
|
45
49
|
.vselect { gap: 4px; color: var(--muted); padding: 0 6px 0 8px; margin-right: -6px; max-width: 46vw; }
|
|
@@ -120,23 +124,56 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
|
|
|
120
124
|
@media (max-width: 640px) {
|
|
121
125
|
.meta, .sep, .made, .vdate { display: none; } header { top: 8px; left: 8px; right: 8px; gap: 8px; }
|
|
122
126
|
.brand:has(img) span { display: none; }
|
|
127
|
+
.brand .who { display: none; }
|
|
123
128
|
header .pill { min-width: 0; padding: 0 10px; }
|
|
124
129
|
header .pill:last-child { flex: 1 1 auto; justify-content: flex-end; }
|
|
125
130
|
.title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
126
131
|
#pop { left: 8px !important; right: 8px; top: auto !important; bottom: 8px; width: auto; max-height: 60dvh; }
|
|
127
132
|
#panel { left: 8px; right: 8px; top: 56px; bottom: 68px; width: auto; }
|
|
128
133
|
}
|
|
129
|
-
|
|
134
|
+
/* live site */
|
|
135
|
+
.live-btn { height: 22px; min-width: 0; margin-left: 8px; padding: 0 8px 0 6px; gap: 4px; border-radius: 6px; vertical-align: middle;
|
|
136
|
+
background: var(--accent-soft); color: var(--accent); font-size: 11.5px; font-weight: 600; }
|
|
137
|
+
.live-btn:hover { background: var(--accent); color: #fff; }
|
|
138
|
+
#live { position: fixed; inset: 0; z-index: 50; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; padding: 16px;
|
|
139
|
+
background: rgba(12,12,11,.6); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); animation: fade-in .16s var(--ease-out); }
|
|
140
|
+
@keyframes fade-in { from { opacity: 0; } }
|
|
141
|
+
.live-bar { display: flex; align-items: center; gap: 10px; max-width: 100%; height: 40px; padding: 0 6px 0 14px; border-radius: 12px;
|
|
142
|
+
background: var(--panel-solid); border: 1px solid var(--line); box-shadow: 0 8px 28px -10px rgba(0,0,0,.35); }
|
|
143
|
+
.live-bar b { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
|
144
|
+
.live-bar a { color: var(--text); white-space: nowrap; text-decoration: none; padding: 0 8px; height: 30px; display: inline-flex; align-items: center; border-radius: 8px; }
|
|
145
|
+
.live-bar a:hover { background: var(--line); }
|
|
146
|
+
.live-device { position: relative; overflow: hidden; border-radius: 10px; background: #fff; box-shadow: 0 30px 80px -20px rgba(0,0,0,.6); animation: pop-in .2s var(--ease-out); }
|
|
147
|
+
.live-device iframe { position: absolute; left: 0; top: 0; border: 0; transform-origin: 0 0; background: #fff; }
|
|
148
|
+
@media (max-width: 640px) { .live-bar .meta { display: none; } }
|
|
149
|
+
/* the room: who's here, their cursors, a live session */
|
|
150
|
+
#room { gap: 8px; padding: 0 10px 0 12px; }
|
|
151
|
+
.live-badge { display: inline-flex; align-items: center; gap: 6px; font-weight: 650; font-size: 12px; color: #dc2626; letter-spacing: .01em; }
|
|
152
|
+
.live-badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: #dc2626; box-shadow: 0 0 0 3px rgba(220,38,38,.18); }
|
|
153
|
+
.faces { display: flex; }
|
|
154
|
+
.face { width: 24px; height: 24px; border-radius: 50%; display: grid; place-items: center; color: #fff; font-size: 10.5px; font-weight: 650;
|
|
155
|
+
border: 2px solid var(--panel-solid); margin-left: -6px; }
|
|
156
|
+
.face:first-child { margin-left: 0; }
|
|
157
|
+
.rcursor { position: absolute; width: 0; height: 0; transform-origin: 0 0; z-index: 3; pointer-events: none; transition: left .09s linear, top .09s linear; }
|
|
158
|
+
.rcursor svg { position: absolute; left: -2px; top: -2px; display: block; }
|
|
159
|
+
.rcursor span { position: absolute; left: 13px; top: 15px; white-space: nowrap; padding: 2px 7px; border-radius: 7px; color: #fff; font-size: 11.5px; font-weight: 600;
|
|
160
|
+
box-shadow: 0 2px 6px rgba(0,0,0,.18); }
|
|
161
|
+
@media (prefers-reduced-motion: reduce) { .frame-box iframe { transition: none; } #live, .live-device { animation: none; } .rcursor { transition: none; } }
|
|
130
162
|
</style>
|
|
131
163
|
</head>
|
|
132
164
|
<body>
|
|
133
165
|
<div id="stage" aria-label="Design canvas"><div id="world"></div></div>
|
|
134
166
|
<header>
|
|
135
167
|
<div class="pill brand" id="brand"><span>Design review</span></div>
|
|
168
|
+
<div class="pill" id="room" hidden aria-live="polite"></div>
|
|
136
169
|
<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
170
|
</header>
|
|
138
171
|
<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
172
|
<div id="pop" hidden role="dialog" aria-label="Comment"></div>
|
|
173
|
+
<div id="live" hidden role="dialog" aria-modal="true" aria-label="Live site">
|
|
174
|
+
<div class="live-bar"><b id="live-name"></b><span class="meta" id="live-size"></span><a id="live-tab" target="_blank" rel="noopener">Open in a new tab</a><button id="live-close" aria-label="Close live site" title="Close (Esc)">\u2715</button></div>
|
|
175
|
+
<div class="live-device" id="live-device"></div>
|
|
176
|
+
</div>
|
|
140
177
|
<footer><div class="pill">
|
|
141
178
|
<button id="out" aria-label="Zoom out" title="Zoom out (-)">\u2212</button>
|
|
142
179
|
<button id="zoom" aria-label="Zoom to fit" title="Zoom to fit (1)">100%</button>
|
|
@@ -248,6 +285,14 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
|
|
|
248
285
|
if (manifest.brand) {
|
|
249
286
|
document.getElementById("brand").innerHTML = (manifest.brand.logo ? '<img alt="" src="' + esc(manifest.brand.logo) + '">' : "") + (manifest.brand.name ? "<span>" + esc(manifest.brand.name) + "</span>" : "");
|
|
250
287
|
}
|
|
288
|
+
// signed in: who's commenting, and sign out. On review sites with sign-in, a way in for the studio and invitees.
|
|
289
|
+
if (manifest.viewer || manifest.signIn) {
|
|
290
|
+
const brandEl = document.getElementById("brand");
|
|
291
|
+
const here = location.pathname.replace(/\\/+$/, "");
|
|
292
|
+
brandEl.insertAdjacentHTML("beforeend", '<span class="sep"></span>' + (manifest.viewer
|
|
293
|
+
? '<span class="who" title="' + esc(manifest.viewer.email) + '">' + esc(manifest.viewer.name) + '</span><form method="post" action="/signout"><input type="hidden" name="next" value="' + esc(here) + '"><button class="who-btn" type="submit">Sign out</button></form>'
|
|
294
|
+
: '<a class="who-btn" href="/signin?next=' + encodeURIComponent(here) + '">Sign in</a>'));
|
|
295
|
+
}
|
|
251
296
|
|
|
252
297
|
// layout: frames at their canvas positions
|
|
253
298
|
const frames = manifest.frames;
|
|
@@ -258,10 +303,12 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
|
|
|
258
303
|
const el = document.createElement("div");
|
|
259
304
|
el.className = "frame";
|
|
260
305
|
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>" +
|
|
306
|
+
el.innerHTML = '<div class="frame-label"><b>' + esc(f.name) + "</b>" + f.width + " \xD7 " + Math.round(f.height) + (manifest.live ? '<button class="live-btn" title="Open the live site at this width"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M7 4.5v15l13-7.5z"/></svg>View live</button>' : "") + "</div>" +
|
|
262
307
|
'<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
308
|
const iframe = el.querySelector("iframe");
|
|
264
309
|
iframe.addEventListener("load", () => iframe.classList.add("ready"));
|
|
310
|
+
const liveBtn = el.querySelector(".live-btn");
|
|
311
|
+
if (liveBtn) liveBtn.onclick = (e) => { e.stopPropagation(); openLive(f); };
|
|
265
312
|
el.dataset.name = f.name;
|
|
266
313
|
world.appendChild(el);
|
|
267
314
|
labels.push(el.querySelector(".frame-label"));
|
|
@@ -276,7 +323,7 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
|
|
|
276
323
|
zoomLabel.textContent = Math.round(z * 100) + "%";
|
|
277
324
|
// labels keep a readable size at any zoom
|
|
278
325
|
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 + ")";
|
|
326
|
+
for (const p of world.querySelectorAll(".pin, .rcursor")) p.style.transform = "scale(" + 1 / z + ")";
|
|
280
327
|
if (typeof placePop === "function") placePop();
|
|
281
328
|
};
|
|
282
329
|
const zoomAt = (nz, cx, cy) => {
|
|
@@ -319,8 +366,8 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
|
|
|
319
366
|
let pinch = null;
|
|
320
367
|
let moved = false, downAt = null;
|
|
321
368
|
stage.addEventListener("pointerdown", (e) => {
|
|
322
|
-
// pins are buttons: they take their own clicks
|
|
323
|
-
if (e.target.closest && e.target.closest(".pin")) return;
|
|
369
|
+
// pins and View live are buttons: they take their own clicks
|
|
370
|
+
if (e.target.closest && e.target.closest(".pin, .live-btn")) return;
|
|
324
371
|
stage.setPointerCapture(e.pointerId);
|
|
325
372
|
moved = false;
|
|
326
373
|
downAt = { x: e.clientX, y: e.clientY };
|
|
@@ -378,6 +425,7 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
|
|
|
378
425
|
document.getElementById("out").onclick = () => zoomAt(z / 1.25, ...center());
|
|
379
426
|
zoomLabel.onclick = () => (tall() ? fitWidth() : fit());
|
|
380
427
|
addEventListener("keydown", (e) => {
|
|
428
|
+
if (liveOpen()) { if (e.key === "Escape") closeLive(); return; }
|
|
381
429
|
if (e.target.closest && e.target.closest("input, textarea, select")) return;
|
|
382
430
|
if (e.key === "Escape") { closePop(); setCommenting(false); return; }
|
|
383
431
|
if ((e.key === "c" || e.key === "C") && canComment && !e.metaKey && !e.ctrlKey) { setCommenting(!commenting); return; }
|
|
@@ -389,6 +437,53 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
|
|
|
389
437
|
});
|
|
390
438
|
addEventListener("resize", apply);
|
|
391
439
|
|
|
440
|
+
// ---------- live site: the real thing, at the frame's width ----------
|
|
441
|
+
const liveBox = document.getElementById("live");
|
|
442
|
+
const liveDevice = document.getElementById("live-device");
|
|
443
|
+
let liveFrame = null, liveReturn = null;
|
|
444
|
+
const liveOpen = () => !liveBox.hidden;
|
|
445
|
+
// a realistic screen for the width: desktop, tablet or phone
|
|
446
|
+
const viewportHeight = (w) => (w >= 1024 ? 900 : w > 500 ? 1024 : 844);
|
|
447
|
+
function sizeLive() {
|
|
448
|
+
if (!liveFrame) return;
|
|
449
|
+
const w = liveFrame.width, h = viewportHeight(w);
|
|
450
|
+
const s = Math.min(1, (innerWidth - 32) / w, (innerHeight - 32 - 50) / h);
|
|
451
|
+
liveDevice.style.width = Math.round(w * s) + "px";
|
|
452
|
+
liveDevice.style.height = Math.round(h * s) + "px";
|
|
453
|
+
const iframe = liveDevice.querySelector("iframe");
|
|
454
|
+
iframe.style.width = w + "px";
|
|
455
|
+
iframe.style.height = h + "px";
|
|
456
|
+
iframe.style.transform = s < 1 ? "scale(" + s + ")" : "";
|
|
457
|
+
document.getElementById("live-size").textContent = w + " \xD7 " + h + (s < 1 ? " \xB7 " + Math.round(s * 100) + "%" : "");
|
|
458
|
+
}
|
|
459
|
+
function openLive(f) {
|
|
460
|
+
liveFrame = f;
|
|
461
|
+
liveReturn = document.activeElement;
|
|
462
|
+
liveDevice.textContent = "";
|
|
463
|
+
// its own origin: scripts run there, never on the review site
|
|
464
|
+
const iframe = document.createElement("iframe");
|
|
465
|
+
iframe.title = "Live site: " + f.name;
|
|
466
|
+
iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox");
|
|
467
|
+
iframe.setAttribute("referrerpolicy", "no-referrer");
|
|
468
|
+
iframe.src = manifest.live.url;
|
|
469
|
+
liveDevice.appendChild(iframe);
|
|
470
|
+
document.getElementById("live-name").textContent = f.name;
|
|
471
|
+
document.getElementById("live-tab").href = manifest.live.url;
|
|
472
|
+
liveBox.hidden = false;
|
|
473
|
+
sizeLive();
|
|
474
|
+
document.getElementById("live-close").focus();
|
|
475
|
+
}
|
|
476
|
+
function closeLive() {
|
|
477
|
+
liveBox.hidden = true;
|
|
478
|
+
liveDevice.textContent = "";
|
|
479
|
+
liveFrame = null;
|
|
480
|
+
if (liveReturn && liveReturn.focus) liveReturn.focus();
|
|
481
|
+
}
|
|
482
|
+
document.getElementById("live-close").onclick = closeLive;
|
|
483
|
+
// a click on the backdrop closes it too
|
|
484
|
+
liveBox.addEventListener("click", (e) => { if (e.target === liveBox) closeLive(); });
|
|
485
|
+
addEventListener("resize", sizeLive);
|
|
486
|
+
|
|
392
487
|
// ---------- comments (on a review site: the manifest says so) ----------
|
|
393
488
|
var canComment = !!manifest.comments;
|
|
394
489
|
var commenting = false;
|
|
@@ -398,7 +493,8 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
|
|
|
398
493
|
var popAnchor = null;
|
|
399
494
|
var pop = document.getElementById("pop");
|
|
400
495
|
const studioName = (manifest.brand && manifest.brand.name) || "Studio";
|
|
401
|
-
|
|
496
|
+
// signed in: comments carry the account's name, nothing to type
|
|
497
|
+
const storedName = () => { if (manifest.viewer) return manifest.viewer.name; try { return localStorage.getItem("tc-review-name") || ""; } catch { return ""; } };
|
|
402
498
|
const saveName = (n) => { try { localStorage.setItem("tc-review-name", n); } catch {} };
|
|
403
499
|
const ago = (t) => {
|
|
404
500
|
const s = Math.round((Date.now() - t) / 1000);
|
|
@@ -647,6 +743,156 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
|
|
|
647
743
|
setInterval(() => document.visibilityState === "visible" && refresh(), 15000);
|
|
648
744
|
addEventListener("visibilitychange", () => document.visibilityState === "visible" && refresh());
|
|
649
745
|
}
|
|
746
|
+
|
|
747
|
+
// ---------- the link's room: who's here, their cursors, comments as they come, live sessions ----------
|
|
748
|
+
if (manifest.room && "WebSocket" in window) {
|
|
749
|
+
const people = new Map();
|
|
750
|
+
const roomPill = document.getElementById("room");
|
|
751
|
+
let me = null, live = false, route = null, sock = null, retry = 1000, ping = 0;
|
|
752
|
+
// a session shows the app as it is now: only on the latest version
|
|
753
|
+
const canLive = !!manifest.session && (!manifest.versions || !manifest.versions.length || manifest.versions[0].id === manifest.id);
|
|
754
|
+
const initials = (n) => n.split(" ").filter(Boolean).slice(0, 2).map((w) => w[0].toUpperCase()).join("") || "?";
|
|
755
|
+
|
|
756
|
+
function renderRoom() {
|
|
757
|
+
const others = [...people.values()];
|
|
758
|
+
roomPill.hidden = !live && !others.length;
|
|
759
|
+
roomPill.textContent = "";
|
|
760
|
+
if (live) roomPill.appendChild(el("span", "live-badge", "Live"));
|
|
761
|
+
const faces = el("span", "faces");
|
|
762
|
+
for (const p of others.slice(0, 5)) {
|
|
763
|
+
const f = el("span", "face", initials(p.name));
|
|
764
|
+
f.style.background = p.color;
|
|
765
|
+
f.title = p.name + (p.kind === "studio" ? " (" + studioName + ")" : "");
|
|
766
|
+
faces.appendChild(f);
|
|
767
|
+
}
|
|
768
|
+
if (others.length > 5) faces.appendChild(el("span", "face", "+" + (others.length - 5))).style.background = "#8b8a85";
|
|
769
|
+
if (others.length) roomPill.appendChild(faces);
|
|
770
|
+
roomPill.setAttribute("aria-label", (live ? "Live session. " : "") + (others.length ? others.map((p) => p.name).join(", ") + " here" : ""));
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function cursorOf(p) {
|
|
774
|
+
if (p.el) return p.el;
|
|
775
|
+
p.el = el("div", "rcursor");
|
|
776
|
+
p.el.innerHTML = '<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M2.2 1.6 15.1 8.3c.7.4.6 1.4-.2 1.6l-5.3 1.3-2.6 4.8c-.4.7-1.4.6-1.6-.2L1.1 2.7c-.2-.8.5-1.4 1.1-1.1Z" stroke="#fff" stroke-width="1.5" stroke-linejoin="round"/></svg>';
|
|
777
|
+
p.el.querySelector("path").setAttribute("fill", p.color);
|
|
778
|
+
const name = el("span", null, p.name);
|
|
779
|
+
name.style.background = p.color;
|
|
780
|
+
p.el.appendChild(name);
|
|
781
|
+
p.el.style.transform = "scale(" + 1 / z + ")";
|
|
782
|
+
world.appendChild(p.el);
|
|
783
|
+
return p.el;
|
|
784
|
+
}
|
|
785
|
+
function moveCursor(p, cx, cy) {
|
|
786
|
+
if (cx == null || cy == null) { if (p.el) p.el.hidden = true; return; }
|
|
787
|
+
const c = cursorOf(p);
|
|
788
|
+
c.hidden = false;
|
|
789
|
+
c.style.left = cx - minX + "px";
|
|
790
|
+
c.style.top = cy - minY + "px";
|
|
791
|
+
}
|
|
792
|
+
const add = (person) => { if (person.id !== me) people.set(person.id, Object.assign(people.get(person.id) || {}, person)); };
|
|
793
|
+
const remove = (id) => { const p = people.get(id); if (p && p.el) p.el.remove(); people.delete(id); };
|
|
794
|
+
|
|
795
|
+
// live frames: the studio's app, served from the link's session host, at each frame's size
|
|
796
|
+
function sessionFrame(f) {
|
|
797
|
+
const home = new URL(manifest.session.url);
|
|
798
|
+
const url = new URL((route || "/truecanvas/") + encodeURIComponent(manifest.canvas), home);
|
|
799
|
+
url.searchParams.set("frame", f.name);
|
|
800
|
+
if (f.theme) url.searchParams.set("theme", f.theme);
|
|
801
|
+
const token = home.searchParams.get("tc_access");
|
|
802
|
+
if (token) url.searchParams.set("tc_access", token);
|
|
803
|
+
return url.href;
|
|
804
|
+
}
|
|
805
|
+
function setLive(on) {
|
|
806
|
+
on = !!on && canLive;
|
|
807
|
+
if (on === live) return renderRoom();
|
|
808
|
+
live = on;
|
|
809
|
+
for (const frameEl of world.querySelectorAll(".frame")) {
|
|
810
|
+
const f = frameOf(frameEl.dataset.name);
|
|
811
|
+
const box = frameEl.querySelector(".frame-box");
|
|
812
|
+
const old = box.querySelector("iframe");
|
|
813
|
+
const img = box.querySelector("img");
|
|
814
|
+
const next = document.createElement("iframe");
|
|
815
|
+
next.title = f.name;
|
|
816
|
+
next.setAttribute("scrolling", "no");
|
|
817
|
+
if (on) {
|
|
818
|
+
// its own origin: the app's scripts run there, never on the review site
|
|
819
|
+
next.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
|
|
820
|
+
next.setAttribute("referrerpolicy", "no-referrer");
|
|
821
|
+
next.src = sessionFrame(f);
|
|
822
|
+
} else {
|
|
823
|
+
next.setAttribute("sandbox", "");
|
|
824
|
+
next.loading = "lazy";
|
|
825
|
+
next.src = f.html;
|
|
826
|
+
if (img) img.style.visibility = "";
|
|
827
|
+
}
|
|
828
|
+
next.addEventListener("load", () => { next.classList.add("ready"); if (on && img) img.style.visibility = "hidden"; });
|
|
829
|
+
old.replaceWith(next);
|
|
830
|
+
}
|
|
831
|
+
renderRoom();
|
|
832
|
+
toast(on ? "Live: you're seeing the design as the studio works on it" : "The live session ended: back to the published version");
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function onEvent(e) {
|
|
836
|
+
if (e.t === "hello") {
|
|
837
|
+
me = e.you.id;
|
|
838
|
+
for (const id of [...people.keys()]) remove(id);
|
|
839
|
+
for (const p of e.people) add(p);
|
|
840
|
+
route = e.route;
|
|
841
|
+
setLive(e.live);
|
|
842
|
+
} else if (e.t === "join") { add(e.person); renderRoom(); }
|
|
843
|
+
else if (e.t === "leave") { remove(e.id); renderRoom(); }
|
|
844
|
+
else if (e.t === "cursor") { const p = people.get(e.id); if (p) moveCursor(p, e.x, e.y); }
|
|
845
|
+
else if (e.t === "state") { route = e.route || route; setLive(e.live); }
|
|
846
|
+
else if (e.t === "comments" && canComment) refresh();
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function connect() {
|
|
850
|
+
const url = new URL("room", document.baseURI);
|
|
851
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
852
|
+
const typed = storedName();
|
|
853
|
+
if (!manifest.viewer && typed) url.searchParams.set("name", typed);
|
|
854
|
+
const s = new WebSocket(url.href);
|
|
855
|
+
sock = s;
|
|
856
|
+
s.onopen = () => {
|
|
857
|
+
retry = 1000;
|
|
858
|
+
clearInterval(ping);
|
|
859
|
+
// idle sockets are dropped after about 100 s: the room answers "ping" without waking up
|
|
860
|
+
ping = setInterval(() => s.readyState === 1 && s.send("ping"), 30000);
|
|
861
|
+
};
|
|
862
|
+
s.onmessage = (m) => {
|
|
863
|
+
if (m.data === "pong") return;
|
|
864
|
+
let e;
|
|
865
|
+
try { e = JSON.parse(m.data); } catch { return; }
|
|
866
|
+
onEvent(e);
|
|
867
|
+
};
|
|
868
|
+
s.onclose = () => {
|
|
869
|
+
clearInterval(ping);
|
|
870
|
+
if (sock !== s) return;
|
|
871
|
+
sock = null;
|
|
872
|
+
for (const id of [...people.keys()]) remove(id);
|
|
873
|
+
renderRoom();
|
|
874
|
+
setTimeout(connect, retry);
|
|
875
|
+
retry = Math.min(retry * 2, 15000);
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// my cursor, in canvas coordinates, at most 20 times a second
|
|
880
|
+
let next = null, timer = 0, last = 0;
|
|
881
|
+
const flush = () => {
|
|
882
|
+
timer = 0;
|
|
883
|
+
if (!next || !sock || sock.readyState !== 1) return;
|
|
884
|
+
last = Date.now();
|
|
885
|
+
sock.send(JSON.stringify({ t: "cursor", x: next.x, y: next.y }));
|
|
886
|
+
next = null;
|
|
887
|
+
};
|
|
888
|
+
const sendCursor = (cx, cy) => {
|
|
889
|
+
next = { x: cx, y: cy };
|
|
890
|
+
if (!timer) timer = setTimeout(flush, Math.max(0, 50 - (Date.now() - last)));
|
|
891
|
+
};
|
|
892
|
+
stage.addEventListener("pointermove", (e) => sendCursor((e.clientX - x) / z + minX, (e.clientY - y) / z + minY));
|
|
893
|
+
stage.addEventListener("pointerleave", () => sendCursor(null, null));
|
|
894
|
+
connect();
|
|
895
|
+
}
|
|
650
896
|
})();
|
|
651
897
|
</script>
|
|
652
898
|
</body>
|
|
@@ -654,6 +900,86 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
|
|
|
654
900
|
`;
|
|
655
901
|
}
|
|
656
902
|
|
|
903
|
+
// src/share/wire.ts
|
|
904
|
+
var CHUNK = 256 * 1024;
|
|
905
|
+
var enc = new TextEncoder();
|
|
906
|
+
var dec = new TextDecoder();
|
|
907
|
+
function encodeFrame(header, payload) {
|
|
908
|
+
const head = enc.encode(JSON.stringify(header));
|
|
909
|
+
const out = new Uint8Array(4 + head.length + (payload?.length ?? 0));
|
|
910
|
+
new DataView(out.buffer).setUint32(0, head.length);
|
|
911
|
+
out.set(head, 4);
|
|
912
|
+
if (payload) out.set(payload, 4 + head.length);
|
|
913
|
+
return out;
|
|
914
|
+
}
|
|
915
|
+
function decodeFrame(data) {
|
|
916
|
+
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
917
|
+
if (bytes.length < 4) throw new Error("Short tunnel frame");
|
|
918
|
+
const size = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0);
|
|
919
|
+
if (size > bytes.length - 4) throw new Error("Bad tunnel frame");
|
|
920
|
+
const header = JSON.parse(dec.decode(bytes.subarray(4, 4 + size)));
|
|
921
|
+
if (!header || typeof header.t !== "string" || typeof header.id !== "string") throw new Error("Bad tunnel frame");
|
|
922
|
+
return { header, payload: bytes.subarray(4 + size) };
|
|
923
|
+
}
|
|
924
|
+
function safePath(path) {
|
|
925
|
+
if (typeof path !== "string" || path.length > 8192) return null;
|
|
926
|
+
if (!path.startsWith("/") || path.startsWith("//") || /[\\\u0000-\u001f\u007f]/.test(path)) return null;
|
|
927
|
+
if (/^\/(%2f|%5c)/i.test(path)) return null;
|
|
928
|
+
return path;
|
|
929
|
+
}
|
|
930
|
+
var DROP_REQUEST = /* @__PURE__ */ new Set([
|
|
931
|
+
"host",
|
|
932
|
+
"connection",
|
|
933
|
+
"keep-alive",
|
|
934
|
+
"upgrade",
|
|
935
|
+
"transfer-encoding",
|
|
936
|
+
"te",
|
|
937
|
+
"trailer",
|
|
938
|
+
"proxy-authorization",
|
|
939
|
+
"proxy-connection",
|
|
940
|
+
"content-length",
|
|
941
|
+
"accept-encoding",
|
|
942
|
+
"origin",
|
|
943
|
+
"referer",
|
|
944
|
+
"authorization",
|
|
945
|
+
"cdn-loop",
|
|
946
|
+
"x-real-ip",
|
|
947
|
+
"true-client-ip",
|
|
948
|
+
"sec-websocket-key",
|
|
949
|
+
"sec-websocket-version",
|
|
950
|
+
"sec-websocket-extensions",
|
|
951
|
+
"sec-websocket-protocol"
|
|
952
|
+
]);
|
|
953
|
+
function requestHeaders(headers) {
|
|
954
|
+
const out = [];
|
|
955
|
+
for (const [k, v] of headers) {
|
|
956
|
+
const key = k.toLowerCase();
|
|
957
|
+
if (DROP_REQUEST.has(key) || key.startsWith("cf-") || key.startsWith("x-forwarded-") || key.startsWith("sec-fetch-") || key.startsWith("x-tc-")) continue;
|
|
958
|
+
if (key === "cookie") {
|
|
959
|
+
const kept = v.split(";").map((c) => c.trim()).filter((c) => c && !c.startsWith("tc_"));
|
|
960
|
+
if (kept.length) out.push(["cookie", kept.join("; ")]);
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
out.push([key, v]);
|
|
964
|
+
}
|
|
965
|
+
return out;
|
|
966
|
+
}
|
|
967
|
+
var DROP_RESPONSE = /* @__PURE__ */ new Set(["connection", "keep-alive", "transfer-encoding", "content-encoding", "content-length", "upgrade", "trailer", "alt-svc"]);
|
|
968
|
+
function responseHeaders(headers) {
|
|
969
|
+
const out = [];
|
|
970
|
+
for (const [k, v] of headers) {
|
|
971
|
+
const key = k.toLowerCase();
|
|
972
|
+
if (!DROP_RESPONSE.has(key)) out.push([key, v]);
|
|
973
|
+
}
|
|
974
|
+
return out;
|
|
975
|
+
}
|
|
976
|
+
|
|
657
977
|
export {
|
|
658
|
-
viewerHtml
|
|
978
|
+
viewerHtml,
|
|
979
|
+
CHUNK,
|
|
980
|
+
encodeFrame,
|
|
981
|
+
decodeFrame,
|
|
982
|
+
safePath,
|
|
983
|
+
requestHeaders,
|
|
984
|
+
responseHeaders
|
|
659
985
|
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// src/core/config.ts
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
function loadConfig(root, overrides = {}) {
|
|
5
|
+
let file = {};
|
|
6
|
+
const configPath = path.join(root, "truecanvas.config.json");
|
|
7
|
+
if (fs.existsSync(configPath)) file = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
8
|
+
let deps = {};
|
|
9
|
+
try {
|
|
10
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
|
11
|
+
deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
12
|
+
} catch {
|
|
13
|
+
deps = {};
|
|
14
|
+
}
|
|
15
|
+
const framework = file.framework ?? detectFramework(deps);
|
|
16
|
+
const vite = framework === "vite";
|
|
17
|
+
const appDir = file.appDir ?? (vite ? fs.existsSync(path.join(root, "src")) ? "src" : "." : fs.existsSync(path.join(root, "src/app")) ? "src/app" : "app");
|
|
18
|
+
const srcPrefix = appDir === "src" || appDir.startsWith("src/") ? "src/" : "";
|
|
19
|
+
const knownLibraries = ["@paper-design/shaders-react"].filter((l) => l in deps);
|
|
20
|
+
return {
|
|
21
|
+
root,
|
|
22
|
+
framework,
|
|
23
|
+
appUrl: vite ? "http://localhost:5173" : "http://localhost:3000",
|
|
24
|
+
port: 4800,
|
|
25
|
+
canvasDir: `${srcPrefix}canvas`,
|
|
26
|
+
appDir,
|
|
27
|
+
routeDir: vite ? ".truecanvas" : `${appDir}/truecanvas`,
|
|
28
|
+
components: [`${srcPrefix}components/**/*.tsx`],
|
|
29
|
+
darkMode: { strategy: "class", value: "dark" },
|
|
30
|
+
...file,
|
|
31
|
+
libraries: [.../* @__PURE__ */ new Set([...knownLibraries, ...file.libraries ?? []])],
|
|
32
|
+
...stripUndefined(overrides)
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function stripUndefined(o) {
|
|
36
|
+
return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== void 0));
|
|
37
|
+
}
|
|
38
|
+
function detectFramework(deps) {
|
|
39
|
+
return !("next" in deps) && "vite" in deps ? "vite" : "next";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export {
|
|
43
|
+
loadConfig,
|
|
44
|
+
detectFramework
|
|
45
|
+
};
|