studiograph 1.3.48-next.239 → 1.3.48-next.240
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/agent/orchestrator.js +7 -2
- package/dist/agent/orchestrator.js.map +1 -1
- package/dist/cli/commands/index.js +2 -3
- package/dist/cli/commands/index.js.map +1 -1
- package/dist/core/artifact-export.d.ts +56 -0
- package/dist/core/artifact-export.js +1148 -165
- package/dist/core/artifact-export.js.map +1 -1
- package/dist/core/secrets/registry.js +8 -0
- package/dist/core/secrets/registry.js.map +1 -1
- package/dist/core/user-config.d.ts +11 -0
- package/dist/core/user-config.js +15 -0
- package/dist/core/user-config.js.map +1 -1
- package/dist/core/workspace-manager.js +2 -3
- package/dist/core/workspace-manager.js.map +1 -1
- package/dist/server/index.js +4 -1
- package/dist/server/index.js.map +1 -1
- package/dist/services/event-processor.js +8 -2
- package/dist/services/event-processor.js.map +1 -1
- package/dist/services/utility-model-resolver.d.ts +41 -0
- package/dist/services/utility-model-resolver.js +73 -0
- package/dist/services/utility-model-resolver.js.map +1 -0
- package/dist/services/vector-service.js +2 -3
- package/dist/services/vector-service.js.map +1 -1
- package/dist/web/_app/immutable/chunks/{C87IaHYh2.js → 72adfQ502.js} +2 -2
- package/dist/web/_app/immutable/chunks/{D5B_tKDU2.js → B0ODumca2.js} +1 -1
- package/dist/web/_app/immutable/chunks/{CfWggRjP.js → B9UB9kFA.js} +1 -1
- package/dist/web/_app/immutable/chunks/{opOSqe3M.js → BCE5gXjB.js} +1 -1
- package/dist/web/_app/immutable/chunks/{CVKD10C7.js → BE6-yRQH.js} +1 -1
- package/dist/web/_app/immutable/chunks/BEEqa3Qb.js +1 -0
- package/dist/web/_app/immutable/chunks/{Be8atwXD.js → BGsN3AOY.js} +2 -2
- package/dist/web/_app/immutable/chunks/{DdVDf5xu.js → BL8AWF8D.js} +1 -1
- package/dist/web/_app/immutable/chunks/{c_0_3I7o.js → BXQNQb6F.js} +1 -1
- package/dist/web/_app/immutable/chunks/B_bE6pEN.js +1 -0
- package/dist/web/_app/immutable/chunks/{CjBwpMBz2.js → BgY8QdzI2.js} +1 -1
- package/dist/web/_app/immutable/chunks/{DIjVvUkI.js → Bi2QjHM_.js} +1 -1
- package/dist/web/_app/immutable/chunks/{DzPoqIW3.js → BozYJgJr.js} +1 -1
- package/dist/web/_app/immutable/chunks/{CM2kFeMr.js → BvPgIjLl.js} +1 -1
- package/dist/web/_app/immutable/chunks/{C6_MueJs.js → C2VWAkAz.js} +1 -1
- package/dist/web/_app/immutable/chunks/{C9-W5gb5.js → C3cNdu6f.js} +2 -2
- package/dist/web/_app/immutable/chunks/{BNyqaReS.js → C4OS3Hl8.js} +4 -4
- package/dist/web/_app/immutable/chunks/{s3KhJbPc.js → C6L2YM4g.js} +1 -1
- package/dist/web/_app/immutable/chunks/{BlB-J7g2.js → C9Soz7Bf.js} +1 -1
- package/dist/web/_app/immutable/chunks/{ltIFKHeT.js → CAV2UQEv.js} +1 -1
- package/dist/web/_app/immutable/chunks/{DcYQcDrH2.js → CBUGBIrj2.js} +1 -1
- package/dist/web/_app/immutable/chunks/{CdmK3A3f2.js → CKaREKi52.js} +1 -1
- package/dist/web/_app/immutable/chunks/{DU4VHSBM.js → CLU3XGf3.js} +1 -1
- package/dist/web/_app/immutable/chunks/{e8-FLj6j2.js → CQkXOr6e2.js} +1 -1
- package/dist/web/_app/immutable/chunks/{DA1j3PV-2.js → CR3Re7B92.js} +1 -1
- package/dist/web/_app/immutable/chunks/CRKkjfRp2.js +1144 -0
- package/dist/web/_app/immutable/chunks/{Bqj5THe4.js → CWkTzOk4.js} +1 -1
- package/dist/web/_app/immutable/chunks/{CfzXQJbU2.js → CYYYvAxA2.js} +2 -2
- package/dist/web/_app/immutable/chunks/{Dme6nqpX.js → CgHh2LQw.js} +3 -3
- package/dist/web/_app/immutable/chunks/{B9tdcPFd2.js → CkSE-YU02.js} +1 -1
- package/dist/web/_app/immutable/chunks/{D3DRGdDK.js → CpaG4OkD.js} +2 -2
- package/dist/web/_app/immutable/chunks/{CqFbmzSF.js → CplgUGzf.js} +1 -1
- package/dist/web/_app/immutable/chunks/{Bu68wlAv.js → Cyo7Bm5P.js} +2 -2
- package/dist/web/_app/immutable/chunks/{DsLjiBTy2.js → D0XHAGG-2.js} +2 -2
- package/dist/web/_app/immutable/chunks/{DKotJLkU.js → D5LY6Nzk.js} +1 -1
- package/dist/web/_app/immutable/chunks/{D4w_-z_1.js → D5QMfoNA.js} +1 -1
- package/dist/web/_app/immutable/chunks/{B-53fhEp.js → D5zleytw.js} +1 -1
- package/dist/web/_app/immutable/chunks/{B_Q2ikpC.js → D8I20exC.js} +1 -1
- package/dist/web/_app/immutable/chunks/{0Q9Poj7Q2.js → DBkUXXYo2.js} +1 -1
- package/dist/web/_app/immutable/chunks/{QFgAlnjg.js → DHnE6Xn5.js} +1 -1
- package/dist/web/_app/immutable/chunks/{BwNx-Krs.js → DMQTt_0r.js} +2 -2
- package/dist/web/_app/immutable/chunks/{DGxjBBgc.js → DT1BKlcT.js} +2 -2
- package/dist/web/_app/immutable/chunks/{Cj_OZrfj.js → DY9ig0aE.js} +1 -1
- package/dist/web/_app/immutable/chunks/{DJJ3G70D.js → DfZdwM54.js} +1 -1
- package/dist/web/_app/immutable/chunks/{DbrXa1V62.js → DhRfUYg22.js} +1 -1
- package/dist/web/_app/immutable/chunks/{Dtg0mY8S.js → Ds_bNYpm.js} +1 -1
- package/dist/web/_app/immutable/chunks/{Dnh8R-L4.js → DtDR_kzZ.js} +1 -1
- package/dist/web/_app/immutable/chunks/{Cr5BwQl3.js → DuVSySGL.js} +2 -2
- package/dist/web/_app/immutable/chunks/{bXA0LEtk.js → Dw8yqW7o.js} +1 -1
- package/dist/web/_app/immutable/chunks/{BzqwCWHS.js → DxLjzY98.js} +1 -1
- package/dist/web/_app/immutable/chunks/{DDGvXH7D.js → DzjvBO7l.js} +1 -1
- package/dist/web/_app/immutable/chunks/{BNFLmNbm.js → KIBwJHiT.js} +1 -1
- package/dist/web/_app/immutable/chunks/{iERz5c4V2.js → OBnW1_mC2.js} +1 -1
- package/dist/web/_app/immutable/chunks/{Cy5jFIwC2.js → U6R5QbcE2.js} +1 -1
- package/dist/web/_app/immutable/chunks/{Dd3b5zAE.js → U_IKElWA.js} +2 -2
- package/dist/web/_app/immutable/chunks/{Crp6TpJG.js → euw1Fjzs.js} +1 -1
- package/dist/web/_app/immutable/chunks/{1WNYnx2s.js → jEhxxrEw.js} +1 -1
- package/dist/web/_app/immutable/chunks/{DftarwxC.js → mxxpM3kA.js} +1 -1
- package/dist/web/_app/immutable/chunks/{BZRSMLpj.js → rdIFJ9Ip.js} +1 -1
- package/dist/web/_app/immutable/chunks/{BX58EMfc.js → xngu3UPw.js} +1 -1
- package/dist/web/_app/immutable/chunks/{CzGq8Ipm.js → y_jKvS2W.js} +1 -1
- package/dist/web/_app/immutable/entry/{app.D5h04EVK.js → app.CTyJv8HR.js} +2 -2
- package/dist/web/_app/immutable/entry/start.MwUDufvI.js +1 -0
- package/dist/web/_app/immutable/nodes/{0.Uo_fpMxv.js → 0.Dh_rdgu5.js} +2 -2
- package/dist/web/_app/immutable/nodes/{1.CNWAaSgM.js → 1.BRItxi2y.js} +1 -1
- package/dist/web/_app/immutable/nodes/{10.CcrAJFX3.js → 10.CyUqzdG2.js} +1 -1
- package/dist/web/_app/immutable/nodes/{11.BpqEWp0v.js → 11.D5lCAA2O.js} +1 -1
- package/dist/web/_app/immutable/nodes/{12.qOg-Rv20.js → 12.D9JS-Gb4.js} +1 -1
- package/dist/web/_app/immutable/nodes/{13.BOu1vBz9.js → 13.B2R04e4k.js} +1 -1
- package/dist/web/_app/immutable/nodes/{15.BS5-L-82.js → 15.63ibwKyR.js} +1 -1
- package/dist/web/_app/immutable/nodes/{17.C4R428bf.js → 17.BWyOTvJ_.js} +1 -1
- package/dist/web/_app/immutable/nodes/{18.BTMNXp-p.js → 18.kS0umg4i.js} +1 -1
- package/dist/web/_app/immutable/nodes/{2.B9o6EQbw.js → 2.D6wvWOO0.js} +4 -4
- package/dist/web/_app/immutable/nodes/{3.BgHyHKnp.js → 3.Xu-dh4-l.js} +3 -3
- package/dist/web/_app/immutable/nodes/{4.PNVLzVyZ.js → 4.CxeTp9tJ.js} +3 -3
- package/dist/web/_app/immutable/nodes/{5.C452eJJj.js → 5.CXyJyKTo.js} +1 -1
- package/dist/web/_app/immutable/nodes/6.Bi03B3mz.js +6 -0
- package/dist/web/_app/immutable/nodes/{7.BtJajjKA.js → 7.C76KmmYy.js} +1 -1
- package/dist/web/_app/immutable/nodes/{8.CT5hirOF.js → 8.Deier5V8.js} +1 -1
- package/dist/web/_app/immutable/nodes/{9.jKfHATxg.js → 9.BISrklc5.js} +1 -1
- package/dist/web/_app/version.json +1 -1
- package/dist/web/index.html +13 -13
- package/package.json +1 -1
- package/dist/web/_app/immutable/chunks/CE4L9Tdp2.js +0 -781
- package/dist/web/_app/immutable/chunks/DgF3ta9a.js +0 -1
- package/dist/web/_app/immutable/chunks/Kh-6DI1Z.js +0 -1
- package/dist/web/_app/immutable/entry/start.qaBR9PnG.js +0 -1
- package/dist/web/_app/immutable/nodes/6.lD-emq-Y.js +0 -6
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { encodeBase64, escapeAttribute, escapeHtml, escapeScript, escapeStyle, } from './artifact-escape.js';
|
|
2
2
|
import { applyAssetResolution, artifactCsp } from './artifact-asset-resolution.js';
|
|
3
|
-
import {
|
|
3
|
+
import { encodeAssetFilename, findArtifactAssetRefs } from './artifact-asset-urls.js';
|
|
4
|
+
import { FRAME_SELECTION_SELECTOR, FRAME_STEPPER_FALLBACK_SELECTOR, frameStepperSelector, frameStepperVisibilityCss, } from './artifact-frame-selectors.js';
|
|
4
5
|
/** The neutral gray the hosted app presents artifacts on. The web stage rule
|
|
5
6
|
* (.artifact-stage[data-view-mode='fullscreen'] in ArtifactEntityView.svelte)
|
|
6
7
|
* must use the same value — a source-sync test pins it. */
|
|
7
8
|
export const ARTIFACT_PRESENT_BACKDROP = '#4a4a4a';
|
|
9
|
+
/** Shared dead-band for content-size churn, in px. Single-sourced HERE (core)
|
|
10
|
+
* so the iframe runtime — which interpolates it into its injected reporter
|
|
11
|
+
* script below — and the web host's per-slot recorder (which re-exports this
|
|
12
|
+
* through artifact-view-state.ts) can never drift apart: a tuning change one
|
|
13
|
+
* side silently filtered out would ship as a no-op. */
|
|
14
|
+
export const CONTENT_SIZE_DEAD_BAND_PX = 8;
|
|
8
15
|
function stripNetworkAssetTags(html) {
|
|
9
16
|
return html
|
|
10
17
|
.replace(/<link\b(?=[^>]*\brel\s*=\s*(?:"[^"]*\bstylesheet\b[^"]*"|'[^']*\bstylesheet\b[^']*'|[^\s>]*stylesheet[^\s>]*))[^>]*>/gi, '')
|
|
@@ -145,99 +152,727 @@ function scriptTags(bundle, inlineScripts = []) {
|
|
|
145
152
|
.map(({ path, js, kind }) => `<script${kind === 'module' ? ' type="module"' : ''} data-artifact-file="${escapeAttribute(path)}">\n${escapeScript(js)}\n</script>`)
|
|
146
153
|
.join('\n');
|
|
147
154
|
}
|
|
148
|
-
function
|
|
149
|
-
|
|
150
|
-
|
|
155
|
+
function getStepperControl(bundle) {
|
|
156
|
+
return bundle.display?.controls?.stepper ?? null;
|
|
157
|
+
}
|
|
158
|
+
const FONT_REF_RE = /\.(?:woff2?|ttf|otf)$/i;
|
|
159
|
+
const FONT_PRELOAD_MAX = 12;
|
|
160
|
+
/** `<link rel="preload" as="font">` tags for every workspace font the bundle
|
|
161
|
+
* references (iframe host only — the in-app/share document). Without these the
|
|
162
|
+
* browser only starts fetching a font after stylesheets apply and text lays
|
|
163
|
+
* out — on a cold load that fetch loses the race against the FOUT guard's
|
|
164
|
+
* settle ceiling and the swap still flashes. Emitted with the canonical
|
|
165
|
+
* `/api/assets/...` form; the finalize pass rewrites the hrefs through the
|
|
166
|
+
* SAME AssetResolution as every other asset URL (token `?at=` / share path),
|
|
167
|
+
* so preloads can never disagree with the CSP. `crossorigin` is required for
|
|
168
|
+
* font preloads to match the CSS font fetch mode (the sandbox's opaque origin
|
|
169
|
+
* makes all loads CORS). */
|
|
170
|
+
function fontPreloadTags(bundle, resolution) {
|
|
171
|
+
if (!resolution || resolution.mode === 'none')
|
|
151
172
|
return '';
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
173
|
+
// Token mode with the mint still in flight: every preload would go out
|
|
174
|
+
// un-tokenized and 401 (surfacing as opaque-origin CORS noise in the
|
|
175
|
+
// console). The host defers building until the mint settles, but if a
|
|
176
|
+
// tokenless build happens anyway (mint failed → proceed tokenless), the
|
|
177
|
+
// CSS @font-face fetches degrade gracefully — guaranteed-dead preloads
|
|
178
|
+
// must simply never be emitted.
|
|
179
|
+
if (resolution.mode === 'token' && !resolution.token)
|
|
180
|
+
return '';
|
|
181
|
+
// Relative (export) mode resolves fonts to bundled ./assets paths — no
|
|
182
|
+
// preload tag needed (and a preload of an unbundled placeholder would 404).
|
|
183
|
+
if (resolution.mode === 'relative')
|
|
184
|
+
return '';
|
|
185
|
+
const text = Object.values(bundle.files).filter((v) => typeof v === 'string').join('\n');
|
|
186
|
+
return findArtifactAssetRefs(text)
|
|
187
|
+
.filter((ref) => FONT_REF_RE.test(ref.filename))
|
|
188
|
+
.slice(0, FONT_PRELOAD_MAX)
|
|
189
|
+
.map((ref) => `<link rel="preload" as="font" crossorigin href="/api/assets/${ref.assetId}/${encodeAssetFilename(ref.filename)}">`)
|
|
190
|
+
.join('');
|
|
191
|
+
}
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// Unified artifact runtime (eng review D2). ONE script, both host modes.
|
|
194
|
+
//
|
|
195
|
+
// The frame stepper — frame detection, active-frame attribution, keyboard nav,
|
|
196
|
+
// click-to-step — is the part that historically drifted between the in-app and
|
|
197
|
+
// the standalone-export runtimes (the `.slide` selector bug). It now lives in
|
|
198
|
+
// these three SHARED fragments, emitted verbatim into BOTH host bodies, so the
|
|
199
|
+
// two can never disagree about which elements are frames. Each host body
|
|
200
|
+
// supplies the small hooks the shared fragments call:
|
|
201
|
+
// - `send(payload)` postMessage to the host (iframe) / no-op (none)
|
|
202
|
+
// - `sendFrameState()` report the active frame to the host / no-op
|
|
203
|
+
// - `sendActivity()` report user activity to the host / no-op
|
|
204
|
+
// - `applyFrameFit()` self-fit the active frame to the viewport (none) /
|
|
205
|
+
// no-op (iframe — the host owns scaling)
|
|
206
|
+
// - `stepperEnabled` gate (runtime expr in iframe, literal true in none)
|
|
207
|
+
// - `interactiveSelector` elements a click must NOT hijack for stepping
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
/** Stepper detection + active-frame state. Depends on the host-supplied
|
|
210
|
+
* `stepperEnabled`, `send`, `sendFrameState`, `applyFrameFit`. */
|
|
211
|
+
const STEPPER_SETUP_JS = `
|
|
212
|
+
const stepperSelector = typeof stepperControl?.selector === 'string' && stepperControl.selector.trim() ? stepperControl.selector.trim() : ${JSON.stringify(FRAME_STEPPER_FALLBACK_SELECTOR)};
|
|
213
|
+
const stepperLabel = typeof stepperControl?.label === 'string' && stepperControl.label.trim() ? stepperControl.label.trim().slice(0, 40) : 'Frame';
|
|
214
|
+
let activeFrameIndex = 0;
|
|
215
|
+
let lastActivityAt = 0;
|
|
159
216
|
const frames = () => {
|
|
160
|
-
|
|
217
|
+
if (!stepperEnabled) return [];
|
|
218
|
+
try { return Array.from(document.querySelectorAll(stepperSelector)).filter((node) => node instanceof HTMLElement); }
|
|
161
219
|
catch { return []; }
|
|
162
220
|
};
|
|
221
|
+
const sendFrameState = () => {
|
|
222
|
+
const allFrames = frames();
|
|
223
|
+
if (!stepperEnabled || allFrames.length === 0) return;
|
|
224
|
+
send({ type: 'frame-state', index: activeFrameIndex, count: allFrames.length, label: stepperLabel });
|
|
225
|
+
};
|
|
163
226
|
const setActiveFrame = (index) => {
|
|
164
227
|
const allFrames = frames();
|
|
165
|
-
if (allFrames.length === 0) return;
|
|
166
|
-
|
|
228
|
+
if (!stepperEnabled || allFrames.length === 0) return;
|
|
229
|
+
activeFrameIndex = Math.max(0, Math.min(allFrames.length - 1, Number.isFinite(index) ? Math.round(index) : 0));
|
|
167
230
|
allFrames.forEach((frame, frameIndex) => {
|
|
168
|
-
const active = frameIndex ===
|
|
231
|
+
const active = frameIndex === activeFrameIndex;
|
|
169
232
|
frame.classList.toggle('active', active);
|
|
170
233
|
frame.toggleAttribute('data-studiograph-active-frame', active);
|
|
171
234
|
frame.setAttribute('aria-hidden', active ? 'false' : 'true');
|
|
172
235
|
});
|
|
173
|
-
try { history.replaceState(null, '', '#' + String(
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
// Standalone fullscreen/share view: there's no parent host to scale the frame,
|
|
177
|
-
// so a fixed-size slide (e.g. 1920x1080) renders at native size and gets
|
|
178
|
-
// clipped at the bottom/right by the overflow:hidden container on any smaller
|
|
179
|
-
// window. Scale the active frame to fit the viewport (contain, centered). Only
|
|
180
|
-
// in the fullscreen context — the export/PDF render keeps frames native-size.
|
|
181
|
-
const fitActiveFrame = () => {
|
|
182
|
-
if (document.documentElement.dataset.artifactContext !== 'fullscreen') return;
|
|
183
|
-
const active = frames()[activeIndex];
|
|
184
|
-
if (!active) return;
|
|
185
|
-
active.style.setProperty('transform', 'none', 'important');
|
|
186
|
-
const fw = active.offsetWidth || 1;
|
|
187
|
-
const fh = active.offsetHeight || 1;
|
|
188
|
-
const ww = window.innerWidth || fw;
|
|
189
|
-
const wh = window.innerHeight || fh;
|
|
190
|
-
const scale = Math.min(ww / fw, wh / fh);
|
|
191
|
-
const left = Math.max(0, (ww - fw * scale) / 2);
|
|
192
|
-
const top = Math.max(0, (wh - fh * scale) / 2);
|
|
193
|
-
active.style.setProperty('transform-origin', 'top left', 'important');
|
|
194
|
-
active.style.setProperty('transform', 'translate(' + left + 'px, ' + top + 'px) scale(' + scale + ')', 'important');
|
|
236
|
+
try { history.replaceState(null, '', '#' + String(activeFrameIndex + 1)); } catch {}
|
|
237
|
+
applyFrameFit();
|
|
238
|
+
sendFrameState();
|
|
195
239
|
};
|
|
196
|
-
const
|
|
240
|
+
const initializeStepper = () => {
|
|
241
|
+
if (!stepperEnabled) return;
|
|
197
242
|
const allFrames = frames();
|
|
198
243
|
if (allFrames.length === 0) return;
|
|
199
|
-
|
|
244
|
+
const activeIndex = allFrames.findIndex((frame) => frame.classList.contains('active') || frame.hasAttribute('data-studiograph-active-frame'));
|
|
245
|
+
const hashIndex = /^#\\d+$/.test(location.hash) ? Number(location.hash.slice(1)) - 1 : -1;
|
|
246
|
+
setActiveFrame(hashIndex >= 0 ? hashIndex : activeIndex >= 0 ? activeIndex : 0);
|
|
200
247
|
};
|
|
201
|
-
const
|
|
248
|
+
const stepFrame = (direction) => {
|
|
202
249
|
const allFrames = frames();
|
|
203
250
|
if (allFrames.length === 0) return;
|
|
204
|
-
|
|
205
|
-
const hashIndex = /^#\\d+$/.test(location.hash) ? Number(location.hash.slice(1)) - 1 : -1;
|
|
206
|
-
setActiveFrame(hashIndex >= 0 ? hashIndex : activeFrameIndex >= 0 ? activeFrameIndex : 0);
|
|
251
|
+
setActiveFrame((activeFrameIndex + direction + allFrames.length) % allFrames.length);
|
|
207
252
|
};
|
|
253
|
+
const sendActivity = () => {
|
|
254
|
+
if (!stepperEnabled) return;
|
|
255
|
+
const now = Date.now();
|
|
256
|
+
if (now - lastActivityAt < 150) return;
|
|
257
|
+
lastActivityAt = now;
|
|
258
|
+
send({ type: 'activity' });
|
|
259
|
+
};`;
|
|
260
|
+
/** Keyboard frame navigation (arrows / page / space / home / end / 1–9). */
|
|
261
|
+
const STEPPER_KEYBOARD_JS = `
|
|
208
262
|
window.addEventListener('keydown', (event) => {
|
|
263
|
+
if (!stepperEnabled) return;
|
|
264
|
+
sendActivity();
|
|
209
265
|
if (event.altKey || event.ctrlKey || event.metaKey) return;
|
|
210
|
-
if (event.key === 'ArrowRight' || event.key === 'PageDown' || event.key === ' ') {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
266
|
+
if (event.key === 'ArrowRight' || event.key === 'PageDown' || event.key === ' ') { stepFrame(1); event.preventDefault(); return; }
|
|
267
|
+
if (event.key === 'ArrowLeft' || event.key === 'PageUp' || event.key === 'Backspace') { stepFrame(-1); event.preventDefault(); return; }
|
|
268
|
+
if (event.key === 'Home') { setActiveFrame(0); event.preventDefault(); return; }
|
|
269
|
+
if (event.key === 'End') { setActiveFrame(frames().length - 1); event.preventDefault(); return; }
|
|
270
|
+
if (/^[1-9]$/.test(event.key)) { setActiveFrame(Number(event.key) - 1); event.preventDefault(); }
|
|
271
|
+
});`;
|
|
272
|
+
/** Click-to-step: left fifth steps back, the rest forward — unless the click
|
|
273
|
+
* landed on something interactive. Returns true when it consumed the click. */
|
|
274
|
+
const STEPPER_CLICK_JS = `
|
|
275
|
+
const handleStepperClick = (event) => {
|
|
276
|
+
if (!stepperEnabled) return false;
|
|
277
|
+
if (event.target instanceof Element && event.target.closest(interactiveSelector + ',[data-artifact-no-stepper]')) return false;
|
|
278
|
+
const allFrames = frames();
|
|
279
|
+
if (allFrames.length <= 1) return false;
|
|
280
|
+
stepFrame(event.clientX < window.innerWidth * 0.2 ? -1 : 1);
|
|
281
|
+
event.preventDefault();
|
|
282
|
+
return true;
|
|
283
|
+
};`;
|
|
284
|
+
const INTERACTIVE_SELECTOR_JS = `const interactiveSelector = 'a,button,input,textarea,select,summary,[contenteditable="true"],[role="button"],[data-artifact-no-pan]';`;
|
|
285
|
+
/** The full interactive runtime for a hosted iframe render: postMessage state,
|
|
286
|
+
* pan/zoom, element selection, content-size reporting, the FOUT guard, plus
|
|
287
|
+
* the shared stepper. Behaviour is gated at runtime on the baked
|
|
288
|
+
* `artifactContext` so a single emitted script serves editor + fullscreen. */
|
|
289
|
+
function iframeRuntimeScript(artifactId, context, tool, stepperControl, viewportMode) {
|
|
290
|
+
return `
|
|
291
|
+
(() => {
|
|
292
|
+
const artifactId = ${JSON.stringify(artifactId)};
|
|
293
|
+
const artifactContext = ${JSON.stringify(context)};
|
|
294
|
+
let artifactTool = ${JSON.stringify(tool)};
|
|
295
|
+
const stepperControl = ${JSON.stringify(stepperControl)};
|
|
296
|
+
const artifactViewportMode = ${JSON.stringify(viewportMode)};
|
|
297
|
+
window.__STUDIOGRAPH_ARTIFACT_CONTEXT__ = artifactContext;
|
|
298
|
+
window.__STUDIOGRAPH_ARTIFACT_TOOL__ = artifactTool;
|
|
299
|
+
document.documentElement.dataset.artifactContext = artifactContext;
|
|
300
|
+
document.documentElement.dataset.artifactTool = artifactTool;
|
|
301
|
+
if (stepperControl) document.documentElement.dataset.artifactStepper = 'true';
|
|
302
|
+
// FOUT guard (fullscreen/present only): @font-face with font-display:swap
|
|
303
|
+
// paints fallback text first, then swaps — a visible flash on the share
|
|
304
|
+
// page. The EDITOR canvas never shows it because the host holds the reveal
|
|
305
|
+
// for the settled report (which waits for document.fonts.ready); fullscreen
|
|
306
|
+
// has no host hold, so hold INSIDE the sandbox: keep the document invisible
|
|
307
|
+
// until fonts settle after load, with hard ceilings so a slow or broken
|
|
308
|
+
// font can only ever delay the reveal briefly (blank-on-backdrop beats
|
|
309
|
+
// wrong-typeface-then-jump). visibility (not display) keeps layout running
|
|
310
|
+
// so font loads actually start while hidden.
|
|
311
|
+
if (artifactContext === 'fullscreen' && document.fonts && typeof document.fonts.ready?.then === 'function') {
|
|
312
|
+
const holdStyle = document.createElement('style');
|
|
313
|
+
holdStyle.textContent = 'html { visibility: hidden !important; }';
|
|
314
|
+
document.head.appendChild(holdStyle);
|
|
315
|
+
let revealed = false;
|
|
316
|
+
const reveal = () => {
|
|
317
|
+
if (revealed) return;
|
|
318
|
+
revealed = true;
|
|
319
|
+
holdStyle.remove();
|
|
320
|
+
};
|
|
321
|
+
// Absolute ceiling — reveal no matter what (load never firing, a hung
|
|
322
|
+
// font request, an exotic fonts.ready rejection).
|
|
323
|
+
setTimeout(reveal, 4000);
|
|
324
|
+
const settle = () => {
|
|
325
|
+
// After load the stylesheets are applied and text is laid out, so the
|
|
326
|
+
// @font-face loads are in flight and fonts.ready is meaningful (called
|
|
327
|
+
// earlier it can resolve before any font load has started). The head
|
|
328
|
+
// carries rel=preload for every referenced font, so cold-load fetches
|
|
329
|
+
// started at parse time — this race usually resolves via fonts.ready.
|
|
330
|
+
Promise.race([
|
|
331
|
+
document.fonts.ready,
|
|
332
|
+
new Promise((resolve) => setTimeout(resolve, 2500)),
|
|
333
|
+
]).then(reveal, reveal);
|
|
334
|
+
};
|
|
335
|
+
if (document.readyState === 'complete') settle();
|
|
336
|
+
else window.addEventListener('load', settle, { once: true });
|
|
337
|
+
}
|
|
338
|
+
let panPointerId = null;
|
|
339
|
+
const selectedElements = new Map();
|
|
340
|
+
const send = (payload) => {
|
|
341
|
+
try { parent.postMessage({ channel: 'studiograph-artifact', artifactId, ...payload }, '*'); } catch {}
|
|
342
|
+
};
|
|
343
|
+
let pendingPanMove = null;
|
|
344
|
+
let pendingPanMoveFrame = null;
|
|
345
|
+
let pendingPanDelta = null;
|
|
346
|
+
let pendingPanDeltaFrame = null;
|
|
347
|
+
let pendingZoomDelta = null;
|
|
348
|
+
let pendingZoomDeltaFrame = null;
|
|
349
|
+
const flushPanMove = () => {
|
|
350
|
+
if (pendingPanMoveFrame !== null) {
|
|
351
|
+
cancelAnimationFrame(pendingPanMoveFrame);
|
|
352
|
+
pendingPanMoveFrame = null;
|
|
214
353
|
}
|
|
215
|
-
if (
|
|
216
|
-
|
|
217
|
-
|
|
354
|
+
if (!pendingPanMove) return;
|
|
355
|
+
send(pendingPanMove);
|
|
356
|
+
pendingPanMove = null;
|
|
357
|
+
};
|
|
358
|
+
const queuePanMove = (payload) => {
|
|
359
|
+
pendingPanMove = payload;
|
|
360
|
+
if (pendingPanMoveFrame !== null) return;
|
|
361
|
+
pendingPanMoveFrame = requestAnimationFrame(() => {
|
|
362
|
+
pendingPanMoveFrame = null;
|
|
363
|
+
if (!pendingPanMove) return;
|
|
364
|
+
send(pendingPanMove);
|
|
365
|
+
pendingPanMove = null;
|
|
366
|
+
});
|
|
367
|
+
};
|
|
368
|
+
const queuePanDelta = (dx, dy) => {
|
|
369
|
+
pendingPanDelta = pendingPanDelta
|
|
370
|
+
? { dx: pendingPanDelta.dx + dx, dy: pendingPanDelta.dy + dy }
|
|
371
|
+
: { dx, dy };
|
|
372
|
+
if (pendingPanDeltaFrame !== null) return;
|
|
373
|
+
pendingPanDeltaFrame = requestAnimationFrame(() => {
|
|
374
|
+
pendingPanDeltaFrame = null;
|
|
375
|
+
if (!pendingPanDelta) return;
|
|
376
|
+
const delta = pendingPanDelta;
|
|
377
|
+
pendingPanDelta = null;
|
|
378
|
+
send({ type: 'pan-delta', dx: delta.dx, dy: delta.dy });
|
|
379
|
+
});
|
|
380
|
+
};
|
|
381
|
+
const queueZoomDelta = (delta, x, y) => {
|
|
382
|
+
pendingZoomDelta = pendingZoomDelta
|
|
383
|
+
? { delta: pendingZoomDelta.delta + delta, x, y }
|
|
384
|
+
: { delta, x, y };
|
|
385
|
+
if (pendingZoomDeltaFrame !== null) return;
|
|
386
|
+
pendingZoomDeltaFrame = requestAnimationFrame(() => {
|
|
387
|
+
pendingZoomDeltaFrame = null;
|
|
388
|
+
if (!pendingZoomDelta) return;
|
|
389
|
+
const zoomDelta = pendingZoomDelta;
|
|
390
|
+
pendingZoomDelta = null;
|
|
391
|
+
send({ type: 'zoom-delta', delta: zoomDelta.delta, x: zoomDelta.x, y: zoomDelta.y });
|
|
392
|
+
});
|
|
393
|
+
};
|
|
394
|
+
const setArtifactTool = (nextTool) => {
|
|
395
|
+
if (nextTool !== 'pointer' && nextTool !== 'hand') return;
|
|
396
|
+
artifactTool = nextTool;
|
|
397
|
+
window.__STUDIOGRAPH_ARTIFACT_TOOL__ = artifactTool;
|
|
398
|
+
document.documentElement.dataset.artifactTool = artifactTool;
|
|
399
|
+
if (artifactTool !== 'hand') {
|
|
400
|
+
panPointerId = null;
|
|
401
|
+
flushPanMove();
|
|
402
|
+
document.documentElement.classList.remove('studiograph-artifact-panning');
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
window.addEventListener('error', (event) => {
|
|
406
|
+
send({ status: 'error', message: String(event.message || 'Artifact error') });
|
|
407
|
+
});
|
|
408
|
+
window.addEventListener('unhandledrejection', (event) => {
|
|
409
|
+
send({ status: 'error', message: String(event.reason?.message || event.reason || 'Artifact promise rejection') });
|
|
410
|
+
});
|
|
411
|
+
window.addEventListener('load', () => {
|
|
412
|
+
send({ status: 'ready' });
|
|
413
|
+
initializeStepper();
|
|
414
|
+
startContentSizeReporting();
|
|
415
|
+
scheduleFinalContentSizeReport();
|
|
416
|
+
});
|
|
417
|
+
// Content-size reporting — editor context, fixed-viewport bundles only (the
|
|
418
|
+
// host's measured-geometry path is scoped the same way; responsive bundles
|
|
419
|
+
// just echo whatever size they're given, so reports would be noise). The
|
|
420
|
+
// host sizes the editor iframe and overview zoom from what actually
|
|
421
|
+
// rendered, not the declared viewport (a deck's editor contact sheet
|
|
422
|
+
// renders far larger than it declares). The FIRST report goes out at
|
|
423
|
+
// DOMContentLoaded — the host holds the initial reveal for it, and waiting
|
|
424
|
+
// for window 'load' would hold a blank canvas behind slow images whose
|
|
425
|
+
// layout extent is already known. Layout that settles later (fonts, images
|
|
426
|
+
// without declared dimensions, script-built content) follows through the
|
|
427
|
+
// debounced ResizeObserver. Sub-dead-band wiggle is dropped here (the host
|
|
428
|
+
// applies the same shared dead-band).
|
|
429
|
+
let lastContentSize = null;
|
|
430
|
+
let contentSizeTimer = null;
|
|
431
|
+
let contentSizeReportingStarted = false;
|
|
432
|
+
// Single gate for BOTH the streaming reporter and the settled (final)
|
|
433
|
+
// report — widening one without the other would arm the ResizeObserver
|
|
434
|
+
// loop while never sending the final marker the host's reveal hold waits
|
|
435
|
+
// for (every open would ride the full ceiling).
|
|
436
|
+
const contentSizeReportingEnabled = artifactContext === 'editor' && ${JSON.stringify('fixed')} === artifactViewportMode;
|
|
437
|
+
const measureContentSize = () => ({
|
|
438
|
+
width: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth || 0),
|
|
439
|
+
height: Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight || 0),
|
|
440
|
+
});
|
|
441
|
+
const reportContentSize = () => {
|
|
442
|
+
const size = measureContentSize();
|
|
443
|
+
if (!(size.width > 0) || !(size.height > 0)) return;
|
|
444
|
+
if (lastContentSize && Math.abs(size.width - lastContentSize.width) < ${CONTENT_SIZE_DEAD_BAND_PX} && Math.abs(size.height - lastContentSize.height) < ${CONTENT_SIZE_DEAD_BAND_PX}) return;
|
|
445
|
+
lastContentSize = size;
|
|
446
|
+
send({ type: 'content-size', width: size.width, height: size.height });
|
|
447
|
+
};
|
|
448
|
+
const queueContentSizeReport = () => {
|
|
449
|
+
if (contentSizeTimer) clearTimeout(contentSizeTimer);
|
|
450
|
+
contentSizeTimer = setTimeout(reportContentSize, 150);
|
|
451
|
+
};
|
|
452
|
+
// The once-per-render SETTLED report. Early reports (DOMContentLoaded, RO)
|
|
453
|
+
// can undershoot — images finish and web fonts swap AFTER them, growing the
|
|
454
|
+
// layout. The host holds the initial reveal for this marker so the first
|
|
455
|
+
// visible paint is already at the final size; it goes out after window
|
|
456
|
+
// 'load' + fonts.ready + a quiet beat, unconditionally (the dead-band must
|
|
457
|
+
// not swallow the marker even when the size already matches).
|
|
458
|
+
let finalContentSizeReported = false;
|
|
459
|
+
const reportFinalContentSize = () => {
|
|
460
|
+
if (finalContentSizeReported) return;
|
|
461
|
+
const size = measureContentSize();
|
|
462
|
+
if (!(size.width > 0) || !(size.height > 0)) return;
|
|
463
|
+
finalContentSizeReported = true;
|
|
464
|
+
lastContentSize = size;
|
|
465
|
+
send({ type: 'content-size', width: size.width, height: size.height, final: true });
|
|
466
|
+
};
|
|
467
|
+
const scheduleFinalContentSizeReport = () => {
|
|
468
|
+
if (!contentSizeReportingEnabled) return;
|
|
469
|
+
const settle = () => setTimeout(reportFinalContentSize, 180);
|
|
470
|
+
try {
|
|
471
|
+
const fontsReady = document.fonts?.ready;
|
|
472
|
+
if (fontsReady && typeof fontsReady.then === 'function') fontsReady.then(settle, settle);
|
|
473
|
+
else settle();
|
|
474
|
+
} catch { settle(); }
|
|
475
|
+
};
|
|
476
|
+
const startContentSizeReporting = () => {
|
|
477
|
+
if (contentSizeReportingStarted) return;
|
|
478
|
+
if (!contentSizeReportingEnabled) return;
|
|
479
|
+
contentSizeReportingStarted = true;
|
|
480
|
+
reportContentSize();
|
|
481
|
+
try {
|
|
482
|
+
const observer = new ResizeObserver(queueContentSizeReport);
|
|
483
|
+
observer.observe(document.documentElement);
|
|
484
|
+
if (document.body) observer.observe(document.body);
|
|
485
|
+
} catch {}
|
|
486
|
+
try { document.fonts?.ready?.then(queueContentSizeReport); } catch {}
|
|
487
|
+
};
|
|
488
|
+
if (document.readyState === 'loading') {
|
|
489
|
+
document.addEventListener('DOMContentLoaded', () => startContentSizeReporting());
|
|
490
|
+
} else {
|
|
491
|
+
startContentSizeReporting();
|
|
492
|
+
}
|
|
493
|
+
const stepperEnabled = artifactContext === 'fullscreen' && !!stepperControl;
|
|
494
|
+
// The host owns scaling in an iframe render — the runtime never self-fits.
|
|
495
|
+
const applyFrameFit = () => {};
|
|
496
|
+
${STEPPER_SETUP_JS}
|
|
497
|
+
${INTERACTIVE_SELECTOR_JS}
|
|
498
|
+
${STEPPER_CLICK_JS}
|
|
499
|
+
${STEPPER_KEYBOARD_JS}
|
|
500
|
+
window.addEventListener('message', (event) => {
|
|
501
|
+
const data = event.data;
|
|
502
|
+
if (!data || typeof data !== 'object') return;
|
|
503
|
+
if (data.channel !== 'studiograph-artifact-host' || data.artifactId !== artifactId) return;
|
|
504
|
+
if (data.type === 'set-tool') setArtifactTool(data.tool);
|
|
505
|
+
if (data.type === 'clear-selection') clearSelection();
|
|
506
|
+
if (data.type === 'set-frame') setActiveFrame(Number(data.index));
|
|
507
|
+
if (data.type === 'reset-frames') setActiveFrame(0);
|
|
508
|
+
if (data.type === 'request-frame-state') sendFrameState();
|
|
509
|
+
});
|
|
510
|
+
window.addEventListener('pointermove', sendActivity, { passive: true });
|
|
511
|
+
const canPanFrom = (target) => {
|
|
512
|
+
if (artifactContext !== 'editor' || artifactTool !== 'hand') return false;
|
|
513
|
+
if (!(target instanceof Element)) return false;
|
|
514
|
+
if (target.closest('[data-artifact-no-pan-zone]')) return false;
|
|
515
|
+
return true;
|
|
516
|
+
};
|
|
517
|
+
const canWheelPanFrom = (target) => {
|
|
518
|
+
if (artifactContext !== 'editor') return false;
|
|
519
|
+
if (!(target instanceof Element)) return false;
|
|
520
|
+
if (target.closest('[data-artifact-no-pan-zone]')) return false;
|
|
521
|
+
return true;
|
|
522
|
+
};
|
|
523
|
+
const sendPan = (phase, event) => {
|
|
524
|
+
if (phase === 'start') document.documentElement.classList.add('studiograph-artifact-panning');
|
|
525
|
+
if (phase === 'end' || phase === 'cancel') document.documentElement.classList.remove('studiograph-artifact-panning');
|
|
526
|
+
const payload = {
|
|
527
|
+
type: 'pan',
|
|
528
|
+
phase,
|
|
529
|
+
pointerId: String(event.pointerId),
|
|
530
|
+
x: event.screenX,
|
|
531
|
+
y: event.screenY
|
|
532
|
+
};
|
|
533
|
+
if (phase === 'move') {
|
|
534
|
+
queuePanMove(payload);
|
|
218
535
|
return;
|
|
219
536
|
}
|
|
220
|
-
if (
|
|
221
|
-
|
|
222
|
-
|
|
537
|
+
if (phase === 'end' || phase === 'cancel') flushPanMove();
|
|
538
|
+
send(payload);
|
|
539
|
+
};
|
|
540
|
+
const normalizeWheelDelta = (event) => {
|
|
541
|
+
const unit = event.deltaMode === WheelEvent.DOM_DELTA_LINE ? 16 : event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? window.innerHeight : 1;
|
|
542
|
+
return { dx: event.deltaX * unit, dy: event.deltaY * unit };
|
|
543
|
+
};
|
|
544
|
+
const safeText = (value, limit = 160) => String(value || '').replace(/\\s+/g, ' ').trim().slice(0, limit);
|
|
545
|
+
const artifactFrameSelector = ${JSON.stringify(FRAME_SELECTION_SELECTOR)};
|
|
546
|
+
const artifactScaffoldSelector = '[data-artifact-layout],[data-artifact-pan-surface],[data-artifact-scaffold],[data-artifact-container]';
|
|
547
|
+
const artifactNoSelectSelector = '[data-artifact-no-select]';
|
|
548
|
+
const rootRenderableSelector = artifactFrameSelector + ',svg,canvas,img,video,[data-artifact-selectable]';
|
|
549
|
+
const selectableCandidateSelector = artifactFrameSelector + ',h1,h2,h3,h4,h5,h6,p,li,td,th,blockquote,figure,figcaption,label,section,article,main,svg,path,g,rect,circle,text,a,button,[role="button"],div,span';
|
|
550
|
+
const cssEscape = (value) => {
|
|
551
|
+
if (window.CSS?.escape) return window.CSS.escape(value);
|
|
552
|
+
return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\\\$&');
|
|
553
|
+
};
|
|
554
|
+
const selectorFor = (element) => {
|
|
555
|
+
if (!(element instanceof Element)) return '';
|
|
556
|
+
if (element.id) return '#' + cssEscape(element.id);
|
|
557
|
+
const parts = [];
|
|
558
|
+
let node = element;
|
|
559
|
+
while (node && node.nodeType === 1 && parts.length < 5) {
|
|
560
|
+
let part = node.tagName.toLowerCase();
|
|
561
|
+
const artifactIdAttr = node.getAttribute('data-artifact-id') || node.getAttribute('data-id');
|
|
562
|
+
if (artifactIdAttr) {
|
|
563
|
+
part += '[data-artifact-id="' + artifactIdAttr.replace(/"/g, '\\\\"') + '"]';
|
|
564
|
+
parts.unshift(part);
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
const classes = Array.from(node.classList || []).slice(0, 2);
|
|
568
|
+
if (classes.length) part += '.' + classes.map(cssEscape).join('.');
|
|
569
|
+
const parent = node.parentElement;
|
|
570
|
+
if (parent) {
|
|
571
|
+
const siblings = Array.from(parent.children).filter((child) => child.tagName === node.tagName);
|
|
572
|
+
if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')';
|
|
573
|
+
}
|
|
574
|
+
parts.unshift(part);
|
|
575
|
+
node = parent;
|
|
576
|
+
if (node === document.body || node === document.documentElement) break;
|
|
577
|
+
}
|
|
578
|
+
return parts.join(' > ');
|
|
579
|
+
};
|
|
580
|
+
const selectionKeyFor = (target) => [target.frameId || '', target.selector || '', target.text || ''].join('::');
|
|
581
|
+
const frameFor = (element) => element.closest?.(artifactFrameSelector) || null;
|
|
582
|
+
const frameDescriptorFor = (element) => {
|
|
583
|
+
const frame = frameFor(element);
|
|
584
|
+
if (!(frame instanceof Element)) return {};
|
|
585
|
+
let frameIndex = -1;
|
|
586
|
+
try { frameIndex = Array.from(document.querySelectorAll(artifactFrameSelector)).indexOf(frame); } catch {}
|
|
587
|
+
const frameName = safeText(
|
|
588
|
+
frame.getAttribute('data-sg-name')
|
|
589
|
+
|| frame.getAttribute('data-title')
|
|
590
|
+
|| frame.getAttribute('aria-label')
|
|
591
|
+
|| frame.getAttribute('title')
|
|
592
|
+
|| ''
|
|
593
|
+
, 80);
|
|
594
|
+
const frameStableId = safeText(
|
|
595
|
+
frame.getAttribute('data-sg-id')
|
|
596
|
+
|| frame.getAttribute('data-artifact-id')
|
|
597
|
+
|| frame.id
|
|
598
|
+
|| frame.getAttribute('data-index')
|
|
599
|
+
|| ''
|
|
600
|
+
, 80);
|
|
601
|
+
return {
|
|
602
|
+
frameId: frameStableId || (frameIndex >= 0 ? 'frame-' + String(frameIndex + 1) : undefined),
|
|
603
|
+
frameIndex: frameIndex >= 0 ? frameIndex : undefined,
|
|
604
|
+
frameName: frameName || undefined
|
|
605
|
+
};
|
|
606
|
+
};
|
|
607
|
+
const targetDescriptorFor = (element, event) => {
|
|
608
|
+
const rect = element.getBoundingClientRect();
|
|
609
|
+
const selector = selectorFor(element);
|
|
610
|
+
const name = safeText(
|
|
611
|
+
element.getAttribute('data-sg-name')
|
|
612
|
+
|| element.getAttribute('aria-label')
|
|
613
|
+
|| element.getAttribute('title')
|
|
614
|
+
|| ''
|
|
615
|
+
, 80);
|
|
616
|
+
const role = safeText(
|
|
617
|
+
element.getAttribute('data-sg-role')
|
|
618
|
+
|| element.getAttribute('role')
|
|
619
|
+
|| ''
|
|
620
|
+
, 80);
|
|
621
|
+
const stableId = safeText(
|
|
622
|
+
element.getAttribute('data-sg-id')
|
|
623
|
+
|| element.getAttribute('data-artifact-id')
|
|
624
|
+
|| element.getAttribute('data-id')
|
|
625
|
+
|| element.id
|
|
626
|
+
|| ''
|
|
627
|
+
, 80);
|
|
628
|
+
const target = {
|
|
629
|
+
...frameDescriptorFor(element),
|
|
630
|
+
targetId: '',
|
|
631
|
+
selector,
|
|
632
|
+
tagName: element.tagName.toLowerCase(),
|
|
633
|
+
id: element.id || '',
|
|
634
|
+
className: typeof element.className === 'string' ? element.className : '',
|
|
635
|
+
name: name || undefined,
|
|
636
|
+
role: role || undefined,
|
|
637
|
+
text: safeText(element.textContent),
|
|
638
|
+
x: event.clientX,
|
|
639
|
+
y: event.clientY,
|
|
640
|
+
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
|
|
641
|
+
};
|
|
642
|
+
target.targetId = stableId || selectionKeyFor(target);
|
|
643
|
+
return target;
|
|
644
|
+
};
|
|
645
|
+
const publishSelection = () => {
|
|
646
|
+
const targets = Array.from(selectedElements.values()).map((entry) => entry.target);
|
|
647
|
+
send({
|
|
648
|
+
type: 'selection-change',
|
|
649
|
+
primaryTargetId: targets[targets.length - 1]?.targetId || '',
|
|
650
|
+
targets
|
|
651
|
+
});
|
|
652
|
+
if (targets.length === 0) send({ type: 'clear-selection' });
|
|
653
|
+
};
|
|
654
|
+
const clearSelection = (publish = true) => {
|
|
655
|
+
for (const entry of selectedElements.values()) {
|
|
656
|
+
entry.element?.removeAttribute?.('data-studiograph-selected-target');
|
|
657
|
+
}
|
|
658
|
+
selectedElements.clear();
|
|
659
|
+
if (publish) publishSelection();
|
|
660
|
+
};
|
|
661
|
+
const isArtifactFrameElement = (element) => element.matches(artifactFrameSelector);
|
|
662
|
+
const isAdditiveSelectionEvent = (event) => !!(event.shiftKey || event.metaKey || event.ctrlKey);
|
|
663
|
+
const isArtifactScaffold = (element) => {
|
|
664
|
+
if (!(element instanceof Element)) return false;
|
|
665
|
+
if (element.matches('[data-artifact-selectable]')) return false;
|
|
666
|
+
if (element === document.body || element === document.documentElement) return true;
|
|
667
|
+
if (isArtifactFrameElement(element)) return false;
|
|
668
|
+
if (element.matches(artifactScaffoldSelector)) return true;
|
|
669
|
+
if (element.querySelector(artifactFrameSelector)) return true;
|
|
670
|
+
if (element.parentElement === document.body && element.children.length > 0 && !element.matches(rootRenderableSelector)) return true;
|
|
671
|
+
const rect = element.getBoundingClientRect();
|
|
672
|
+
const bodyRect = document.body?.getBoundingClientRect?.();
|
|
673
|
+
const bodyWidth = Math.max(document.documentElement.clientWidth || 0, document.body?.scrollWidth || 0, bodyRect?.width || 0);
|
|
674
|
+
const bodyHeight = Math.max(document.documentElement.clientHeight || 0, document.body?.scrollHeight || 0, bodyRect?.height || 0);
|
|
675
|
+
return element.children.length > 0
|
|
676
|
+
&& !element.matches(rootRenderableSelector)
|
|
677
|
+
&& rect.width >= bodyWidth - 2
|
|
678
|
+
&& rect.height >= bodyHeight - 2;
|
|
679
|
+
};
|
|
680
|
+
const selectionElementFor = (target, event) => {
|
|
681
|
+
if (!(target instanceof Element)) return null;
|
|
682
|
+
const explicitSelectable = target.closest('[data-artifact-selectable]');
|
|
683
|
+
const explicitNoSelect = target.closest(artifactNoSelectSelector);
|
|
684
|
+
if (explicitNoSelect && !explicitSelectable) return null;
|
|
685
|
+
const element = explicitSelectable || target.closest(selectableCandidateSelector) || target;
|
|
686
|
+
if (!(element instanceof Element)) return null;
|
|
687
|
+
if (!explicitSelectable && isArtifactScaffold(element)) return null;
|
|
688
|
+
return element;
|
|
689
|
+
};
|
|
690
|
+
const shouldSuppressNativeModifierSelection = (event) => {
|
|
691
|
+
if (artifactContext !== 'editor' || artifactTool !== 'pointer') return false;
|
|
692
|
+
if (!isAdditiveSelectionEvent(event)) return false;
|
|
693
|
+
if (event.button !== undefined && event.button !== 0) return false;
|
|
694
|
+
return selectionElementFor(event.target, event) instanceof Element;
|
|
695
|
+
};
|
|
696
|
+
const selectElement = (target, event) => {
|
|
697
|
+
if (artifactContext !== 'editor' || artifactTool !== 'pointer') return;
|
|
698
|
+
if (!(target instanceof Element)) return;
|
|
699
|
+
const element = selectionElementFor(target, event);
|
|
700
|
+
if (!(element instanceof Element)) {
|
|
701
|
+
clearSelection();
|
|
223
702
|
return;
|
|
224
703
|
}
|
|
225
|
-
|
|
226
|
-
|
|
704
|
+
const targetDescriptor = targetDescriptorFor(element, event);
|
|
705
|
+
const key = targetDescriptor.targetId;
|
|
706
|
+
const additive = isAdditiveSelectionEvent(event);
|
|
707
|
+
if (!additive) clearSelection(false);
|
|
708
|
+
if (additive && selectedElements.has(key)) {
|
|
709
|
+
selectedElements.get(key)?.element?.removeAttribute?.('data-studiograph-selected-target');
|
|
710
|
+
selectedElements.delete(key);
|
|
711
|
+
} else {
|
|
712
|
+
element.setAttribute('data-studiograph-selected-target', 'true');
|
|
713
|
+
selectedElements.set(key, { element, target: targetDescriptor });
|
|
714
|
+
}
|
|
715
|
+
publishSelection();
|
|
716
|
+
if (additive) event.preventDefault();
|
|
717
|
+
};
|
|
718
|
+
window.addEventListener('keydown', (event) => {
|
|
719
|
+
if (artifactContext !== 'editor') return;
|
|
720
|
+
if (event.key !== 'Escape' || selectedElements.size === 0) return;
|
|
721
|
+
clearSelection();
|
|
722
|
+
event.preventDefault();
|
|
723
|
+
}, { capture: true });
|
|
724
|
+
// Prototypes frequently contain links (href="#cart", "checkout.html", external URLs)
|
|
725
|
+
// that, inside this sandboxed srcdoc frame, navigate the frame to a nonexistent target
|
|
726
|
+
// and leave a blank canvas. Handle them gracefully: scroll to in-page anchors when they
|
|
727
|
+
// exist, and otherwise swallow the navigation so the prototype stays put. JS-driven
|
|
728
|
+
// links (javascript: / onclick handlers) still run — we only cancel the default nav.
|
|
729
|
+
const handleArtifactLinkClick = (event) => {
|
|
730
|
+
if (event.defaultPrevented) return false;
|
|
731
|
+
if (event.button !== undefined && event.button !== 0) return false;
|
|
732
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false;
|
|
733
|
+
const anchor = event.target instanceof Element ? event.target.closest('a[href]') : null;
|
|
734
|
+
if (!anchor) return false;
|
|
735
|
+
const href = anchor.getAttribute('href') || '';
|
|
736
|
+
if (!href || /^javascript:/i.test(href)) return false;
|
|
737
|
+
if (href.startsWith('#')) {
|
|
738
|
+
// In-page anchor: scroll to it when present, no-op when it dangles. Never navigate.
|
|
227
739
|
event.preventDefault();
|
|
740
|
+
const id = href.slice(1);
|
|
741
|
+
if (id) {
|
|
742
|
+
let dest = null;
|
|
743
|
+
try { dest = document.getElementById(id) || document.querySelector('a[name="' + cssEscape(id) + '"]'); } catch {}
|
|
744
|
+
dest?.scrollIntoView?.({ block: 'start', behavior: 'smooth' });
|
|
745
|
+
}
|
|
746
|
+
return true;
|
|
228
747
|
}
|
|
229
|
-
|
|
748
|
+
// Any other navigation (relative path, external URL, mailto, etc.) would blank the
|
|
749
|
+
// sandboxed frame — swallow it rather than leave the user on an empty canvas.
|
|
750
|
+
event.preventDefault();
|
|
751
|
+
return true;
|
|
752
|
+
};
|
|
230
753
|
window.addEventListener('click', (event) => {
|
|
231
|
-
|
|
232
|
-
if (
|
|
233
|
-
|
|
754
|
+
// In the editor, still allow the click to select the link element; just don't navigate.
|
|
755
|
+
if (handleArtifactLinkClick(event) && artifactContext !== 'editor') return;
|
|
756
|
+
if (stepperEnabled) {
|
|
757
|
+
if (handleStepperClick(event)) return;
|
|
758
|
+
if (event.target instanceof Element && event.target.closest(interactiveSelector + ',[data-artifact-no-stepper]')) return;
|
|
759
|
+
}
|
|
760
|
+
selectElement(event.target, event);
|
|
761
|
+
}, { capture: true });
|
|
762
|
+
window.addEventListener('mousedown', (event) => {
|
|
763
|
+
if (!shouldSuppressNativeModifierSelection(event)) return;
|
|
234
764
|
event.preventDefault();
|
|
235
765
|
}, { capture: true });
|
|
766
|
+
window.addEventListener('wheel', (event) => {
|
|
767
|
+
if (!canWheelPanFrom(event.target)) return;
|
|
768
|
+
const delta = normalizeWheelDelta(event);
|
|
769
|
+
if (Math.abs(delta.dx) < 0.01 && Math.abs(delta.dy) < 0.01) return;
|
|
770
|
+
if (event.ctrlKey || event.metaKey) {
|
|
771
|
+
queueZoomDelta(delta.dy, event.clientX, event.clientY);
|
|
772
|
+
event.preventDefault();
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
// In the editor the canvas is a pan/zoom surface (this handler only runs in
|
|
776
|
+
// editor context — see canWheelPanFrom): a trackpad/wheel gesture pans the
|
|
777
|
+
// whole canvas, it never scrolls a container the artifact happens to sit in.
|
|
778
|
+
// An artifact that genuinely needs an internal scroll region marks it with
|
|
779
|
+
// [data-artifact-no-pan-zone] (canWheelPanFrom returns false there, so the
|
|
780
|
+
// wheel falls through to native scrolling).
|
|
781
|
+
queuePanDelta(delta.dx, delta.dy);
|
|
782
|
+
event.preventDefault();
|
|
783
|
+
}, { capture: true, passive: false });
|
|
784
|
+
window.addEventListener('pointerdown', (event) => {
|
|
785
|
+
if (event.button !== 0 || !canPanFrom(event.target)) return;
|
|
786
|
+
panPointerId = event.pointerId;
|
|
787
|
+
try { event.target.setPointerCapture?.(event.pointerId); } catch {}
|
|
788
|
+
sendPan('start', event);
|
|
789
|
+
event.preventDefault();
|
|
790
|
+
}, { capture: true });
|
|
791
|
+
window.addEventListener('pointermove', (event) => {
|
|
792
|
+
if (panPointerId !== event.pointerId) return;
|
|
793
|
+
sendPan('move', event);
|
|
794
|
+
event.preventDefault();
|
|
795
|
+
}, { capture: true });
|
|
796
|
+
const endPan = (event) => {
|
|
797
|
+
if (panPointerId !== event.pointerId) return;
|
|
798
|
+
sendPan(event.type === 'pointercancel' ? 'cancel' : 'end', event);
|
|
799
|
+
panPointerId = null;
|
|
800
|
+
};
|
|
801
|
+
window.addEventListener('pointerup', endPan, { capture: true });
|
|
802
|
+
window.addEventListener('pointercancel', endPan, { capture: true });
|
|
803
|
+
})();`;
|
|
804
|
+
}
|
|
805
|
+
/** The minimal self-contained runtime for a hostless render (downloaded HTML).
|
|
806
|
+
* No host to scale or chrome the frame, so it owns step-frames + self-fit and
|
|
807
|
+
* nothing else — capability-frozen at today's export parity. Only emitted for
|
|
808
|
+
* a stepped fullscreen export; a paged reader / plain document needs no JS. */
|
|
809
|
+
function standaloneRuntimeScript(stepperControl) {
|
|
810
|
+
return `
|
|
811
|
+
(() => {
|
|
812
|
+
const stepperControl = ${JSON.stringify(stepperControl)};
|
|
813
|
+
const stepperEnabled = true;
|
|
814
|
+
// No host: the runtime scales the active frame to fit the viewport (contain,
|
|
815
|
+
// centered) so a fixed-size slide (e.g. 1920x1080) isn't clipped by the
|
|
816
|
+
// overflow:hidden container on a smaller window. Gated to the fullscreen
|
|
817
|
+
// context so the export/PDF render keeps frames native-size.
|
|
818
|
+
function fitActiveFrame() {
|
|
819
|
+
if (document.documentElement.dataset.artifactContext !== 'fullscreen') return;
|
|
820
|
+
const active = frames()[activeFrameIndex];
|
|
821
|
+
if (!active) return;
|
|
822
|
+
active.style.setProperty('transform', 'none', 'important');
|
|
823
|
+
const fw = active.offsetWidth || 1;
|
|
824
|
+
const fh = active.offsetHeight || 1;
|
|
825
|
+
const ww = window.innerWidth || fw;
|
|
826
|
+
const wh = window.innerHeight || fh;
|
|
827
|
+
const scale = Math.min(ww / fw, wh / fh);
|
|
828
|
+
const left = Math.max(0, (ww - fw * scale) / 2);
|
|
829
|
+
const top = Math.max(0, (wh - fh * scale) / 2);
|
|
830
|
+
active.style.setProperty('transform-origin', 'top left', 'important');
|
|
831
|
+
active.style.setProperty('transform', 'translate(' + left + 'px, ' + top + 'px) scale(' + scale + ')', 'important');
|
|
832
|
+
}
|
|
833
|
+
// send (and the shared fragment's sendFrameState/sendActivity that call it)
|
|
834
|
+
// post to a host; there is none here, so a no-op send makes them inert.
|
|
835
|
+
const send = () => {};
|
|
836
|
+
const applyFrameFit = fitActiveFrame;
|
|
837
|
+
${STEPPER_SETUP_JS}
|
|
838
|
+
${INTERACTIVE_SELECTOR_JS}
|
|
839
|
+
${STEPPER_CLICK_JS}
|
|
840
|
+
${STEPPER_KEYBOARD_JS}
|
|
841
|
+
window.addEventListener('click', (event) => { handleStepperClick(event); }, { capture: true });
|
|
236
842
|
window.addEventListener('resize', fitActiveFrame);
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
843
|
+
// Print-to-PDF (Cmd+P / Save as PDF on the downloaded file): the on-screen
|
|
844
|
+
// self-fit writes an inline transform: translate(...) scale(...) !important
|
|
845
|
+
// on the active frame, sized to the WINDOW. Inline !important beats any print
|
|
846
|
+
// stylesheet, so that screen transform would carry into print and push the
|
|
847
|
+
// frame off the page (a blank PDF). Clear every frame's inline transform
|
|
848
|
+
// before printing — the @media print rules then lay each frame out one per
|
|
849
|
+
// page at native size — and restore the screen fit afterward.
|
|
850
|
+
const clearFitTransforms = () => { for (const frame of frames()) frame.style.removeProperty('transform'); };
|
|
851
|
+
window.addEventListener('beforeprint', clearFitTransforms);
|
|
852
|
+
window.addEventListener('afterprint', fitActiveFrame);
|
|
853
|
+
try {
|
|
854
|
+
const printMedia = window.matchMedia('print');
|
|
855
|
+
const onPrintChange = (event) => { if (event.matches) clearFitTransforms(); else fitActiveFrame(); };
|
|
856
|
+
if (printMedia.addEventListener) printMedia.addEventListener('change', onPrintChange);
|
|
857
|
+
else if (printMedia.addListener) printMedia.addListener(onPrintChange);
|
|
858
|
+
} catch {}
|
|
859
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initializeStepper, { once: true });
|
|
860
|
+
else initializeStepper();
|
|
861
|
+
})();`;
|
|
862
|
+
}
|
|
863
|
+
function addArtifactContextToHtmlTag(html, context, tool, hasStepper) {
|
|
864
|
+
if (!/<html[\s>]/i.test(html))
|
|
865
|
+
return html;
|
|
866
|
+
return html.replace(/<html\b([^>]*)>/i, (match, attrs) => {
|
|
867
|
+
let nextAttrs = attrs;
|
|
868
|
+
if (!/\bdata-artifact-context\s*=/i.test(nextAttrs))
|
|
869
|
+
nextAttrs = ` data-artifact-context="${context}"${nextAttrs}`;
|
|
870
|
+
if (!/\bdata-artifact-tool\s*=/i.test(nextAttrs))
|
|
871
|
+
nextAttrs = ` data-artifact-tool="${tool}"${nextAttrs}`;
|
|
872
|
+
if (hasStepper && !/\bdata-artifact-stepper\s*=/i.test(nextAttrs))
|
|
873
|
+
nextAttrs = ` data-artifact-stepper="true"${nextAttrs}`;
|
|
874
|
+
return `<html${nextAttrs}>`;
|
|
875
|
+
});
|
|
241
876
|
}
|
|
242
877
|
function addAttributesToHtmlTag(html, attrs) {
|
|
243
878
|
if (!/<html[\s>]/i.test(html))
|
|
@@ -254,38 +889,136 @@ function addAttributesToHtmlTag(html, attrs) {
|
|
|
254
889
|
return `<html${nextAttrs}>`;
|
|
255
890
|
});
|
|
256
891
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
return additions.filter(Boolean).join('\n');
|
|
892
|
+
// ---------------------------------------------------------------------------
|
|
893
|
+
// Host styles. The fullscreen/present + paged blocks are shared between the
|
|
894
|
+
// in-app (iframe) and the export (none) host-style packages so the two can't
|
|
895
|
+
// drift on which elements letterbox / step. The surface-specific pieces
|
|
896
|
+
// (editor cursor for iframe; export-frame flattening + print pagination +
|
|
897
|
+
// in-document present backdrop for none) stay where they belong.
|
|
898
|
+
// ---------------------------------------------------------------------------
|
|
899
|
+
function stepperFullscreenCss(bundle) {
|
|
900
|
+
if (!getStepperControl(bundle))
|
|
901
|
+
return '';
|
|
902
|
+
return `
|
|
903
|
+
html[data-artifact-context="fullscreen"][data-artifact-stepper="true"],
|
|
904
|
+
html[data-artifact-context="fullscreen"][data-artifact-stepper="true"] body {
|
|
905
|
+
width: 100% !important;
|
|
906
|
+
height: 100% !important;
|
|
907
|
+
min-height: 100% !important;
|
|
908
|
+
overflow: hidden !important;
|
|
275
909
|
}
|
|
276
|
-
|
|
277
|
-
|
|
910
|
+
html[data-artifact-context="fullscreen"][data-artifact-stepper="true"] .artifact-sequence,
|
|
911
|
+
html[data-artifact-context="fullscreen"][data-artifact-stepper="true"] .deck,
|
|
912
|
+
html[data-artifact-context="fullscreen"][data-artifact-stepper="true"] [data-artifact-layout="sequence"] {
|
|
913
|
+
width: 100% !important;
|
|
914
|
+
height: 100% !important;
|
|
915
|
+
min-height: 100% !important;
|
|
916
|
+
padding: 0 !important;
|
|
917
|
+
margin: 0 !important;
|
|
918
|
+
display: block !important;
|
|
919
|
+
position: relative !important;
|
|
920
|
+
overflow: hidden !important;
|
|
921
|
+
background: transparent !important;
|
|
922
|
+
box-shadow: none !important;
|
|
278
923
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
924
|
+
${frameStepperVisibilityCss(frameStepperSelector(getStepperControl(bundle)?.selector), 'html[data-artifact-context="fullscreen"][data-artifact-stepper="true"]')}
|
|
925
|
+
`;
|
|
926
|
+
}
|
|
927
|
+
// `pinHtmlHeight`: the in-app (iframe) present pins html/body to the host stage
|
|
928
|
+
// height (the stage sizes the iframe, so 100% fills it). The hostless export
|
|
929
|
+
// must NOT — a downloaded paged document has no host stage, and a fixed
|
|
930
|
+
// `height: 100%` body turns the page column into an internal scroll container
|
|
931
|
+
// (and would print only the first viewport when the file is printed to PDF).
|
|
932
|
+
// It keeps `min-height: 100%` so the document grows to natural content height
|
|
933
|
+
// and the WINDOW scrolls — exactly the pre-unification per-surface behavior.
|
|
934
|
+
function pagedFullscreenCss(bundle, pinHtmlHeight) {
|
|
935
|
+
if (bundle.viewport.mode !== 'paged')
|
|
936
|
+
return '';
|
|
937
|
+
return `
|
|
938
|
+
html[data-artifact-context="fullscreen"],
|
|
939
|
+
html[data-artifact-context="fullscreen"] body {
|
|
940
|
+
width: 100% !important;
|
|
941
|
+
${pinHtmlHeight ? ' height: 100% !important;\n' : ''} min-height: 100% !important;
|
|
942
|
+
overflow: auto !important;
|
|
943
|
+
}
|
|
944
|
+
html[data-artifact-context="fullscreen"] .pages,
|
|
945
|
+
html[data-artifact-context="fullscreen"] .document-board,
|
|
946
|
+
html[data-artifact-context="fullscreen"] .artifact-board[data-artifact-layout="paged"],
|
|
947
|
+
html[data-artifact-context="fullscreen"] [data-artifact-layout="paged"] {
|
|
948
|
+
width: 100% !important;
|
|
949
|
+
height: auto !important;
|
|
950
|
+
min-height: 100% !important;
|
|
951
|
+
padding: 48px 0 !important;
|
|
952
|
+
display: flex !important;
|
|
953
|
+
flex-direction: column !important;
|
|
954
|
+
align-items: center !important;
|
|
955
|
+
justify-content: flex-start !important;
|
|
956
|
+
align-content: center !important;
|
|
957
|
+
gap: 32px !important;
|
|
958
|
+
background: transparent !important;
|
|
959
|
+
box-shadow: none !important;
|
|
960
|
+
overflow: visible !important;
|
|
961
|
+
}
|
|
962
|
+
html[data-artifact-context="fullscreen"] .page,
|
|
963
|
+
html[data-artifact-context="fullscreen"] [data-artifact-page] {
|
|
964
|
+
position: relative !important;
|
|
965
|
+
left: auto !important;
|
|
966
|
+
right: auto !important;
|
|
967
|
+
top: auto !important;
|
|
968
|
+
bottom: auto !important;
|
|
969
|
+
transform: none !important;
|
|
970
|
+
}
|
|
971
|
+
`;
|
|
972
|
+
}
|
|
973
|
+
/** Host styles for the in-app (iframe) render: transparent background so the
|
|
974
|
+
* host stage shows through, the editor hand/pointer cursors, plus the shared
|
|
975
|
+
* fullscreen stepper / paged reader CSS. */
|
|
976
|
+
// No `tool` param: the hand/grab cursor CSS keys entirely off the runtime-set
|
|
977
|
+
// `[data-artifact-tool="hand"]` attribute (the runtime flips it live on tool
|
|
978
|
+
// change), never off a build-time tool value — so build-time tool can't affect
|
|
979
|
+
// these styles.
|
|
980
|
+
function inAppHostStyleTags(bundle, context) {
|
|
981
|
+
const stepperFullscreen = context === 'fullscreen' ? stepperFullscreenCss(bundle) : '';
|
|
982
|
+
const editorCursor = context === 'editor'
|
|
983
|
+
? `
|
|
984
|
+
html[data-artifact-context="editor"][data-artifact-tool="hand"],
|
|
985
|
+
html[data-artifact-context="editor"][data-artifact-tool="hand"] body,
|
|
986
|
+
html[data-artifact-context="editor"][data-artifact-tool="hand"] body * {
|
|
987
|
+
cursor: grab !important;
|
|
988
|
+
}
|
|
989
|
+
html[data-artifact-context="editor"][data-artifact-tool="hand"].studiograph-artifact-panning,
|
|
990
|
+
html[data-artifact-context="editor"][data-artifact-tool="hand"].studiograph-artifact-panning body,
|
|
991
|
+
html[data-artifact-context="editor"][data-artifact-tool="hand"].studiograph-artifact-panning body * {
|
|
992
|
+
cursor: grabbing !important;
|
|
993
|
+
}
|
|
994
|
+
html[data-artifact-context="editor"][data-artifact-tool="hand"] a,
|
|
995
|
+
html[data-artifact-context="editor"][data-artifact-tool="hand"] button,
|
|
996
|
+
html[data-artifact-context="editor"][data-artifact-tool="hand"] summary,
|
|
997
|
+
html[data-artifact-context="editor"][data-artifact-tool="hand"] [role="button"] {
|
|
998
|
+
cursor: pointer !important;
|
|
999
|
+
}
|
|
1000
|
+
html[data-artifact-context="editor"][data-artifact-tool="hand"] input,
|
|
1001
|
+
html[data-artifact-context="editor"][data-artifact-tool="hand"] textarea,
|
|
1002
|
+
html[data-artifact-context="editor"][data-artifact-tool="hand"] select,
|
|
1003
|
+
html[data-artifact-context="editor"][data-artifact-tool="hand"] [contenteditable="true"],
|
|
1004
|
+
html[data-artifact-context="editor"][data-artifact-tool="hand"] [data-artifact-no-pan] {
|
|
1005
|
+
cursor: auto !important;
|
|
1006
|
+
}
|
|
1007
|
+
`
|
|
1008
|
+
: '';
|
|
1009
|
+
const pagedFullscreen = context === 'fullscreen' ? pagedFullscreenCss(bundle, true) : '';
|
|
1010
|
+
return `<style data-artifact-host>
|
|
1011
|
+
html, body {
|
|
1012
|
+
background: transparent !important;
|
|
1013
|
+
}
|
|
1014
|
+
.document-board,
|
|
1015
|
+
.artifact-board[data-artifact-layout="paged"] {
|
|
1016
|
+
background: transparent !important;
|
|
1017
|
+
}
|
|
1018
|
+
${editorCursor}
|
|
1019
|
+
${pagedFullscreen}
|
|
1020
|
+
${stepperFullscreen}
|
|
1021
|
+
</style>`;
|
|
289
1022
|
}
|
|
290
1023
|
function artifactRenderHostStyleTags(bundle) {
|
|
291
1024
|
const viewport = bundle.display?.fullscreenViewport ?? bundle.viewport;
|
|
@@ -378,69 +1111,8 @@ ${backdropScope} body {
|
|
|
378
1111
|
}
|
|
379
1112
|
`
|
|
380
1113
|
: '';
|
|
381
|
-
const stepperFullscreen = bundle
|
|
382
|
-
|
|
383
|
-
html[data-artifact-context="fullscreen"][data-artifact-stepper="true"],
|
|
384
|
-
html[data-artifact-context="fullscreen"][data-artifact-stepper="true"] body {
|
|
385
|
-
width: 100% !important;
|
|
386
|
-
height: 100% !important;
|
|
387
|
-
min-height: 100% !important;
|
|
388
|
-
overflow: hidden !important;
|
|
389
|
-
}
|
|
390
|
-
html[data-artifact-context="fullscreen"][data-artifact-stepper="true"] .artifact-sequence,
|
|
391
|
-
html[data-artifact-context="fullscreen"][data-artifact-stepper="true"] .deck,
|
|
392
|
-
html[data-artifact-context="fullscreen"][data-artifact-stepper="true"] [data-artifact-layout="sequence"] {
|
|
393
|
-
width: 100% !important;
|
|
394
|
-
height: 100% !important;
|
|
395
|
-
min-height: 100% !important;
|
|
396
|
-
padding: 0 !important;
|
|
397
|
-
margin: 0 !important;
|
|
398
|
-
display: block !important;
|
|
399
|
-
position: relative !important;
|
|
400
|
-
overflow: hidden !important;
|
|
401
|
-
background: transparent !important;
|
|
402
|
-
box-shadow: none !important;
|
|
403
|
-
}
|
|
404
|
-
${frameStepperVisibilityCss(frameStepperSelector(bundle.display?.controls?.stepper?.selector), 'html[data-artifact-context="fullscreen"][data-artifact-stepper="true"]')}
|
|
405
|
-
`
|
|
406
|
-
: '';
|
|
407
|
-
const pagedFullscreen = bundle.viewport.mode === 'paged'
|
|
408
|
-
? `
|
|
409
|
-
html[data-artifact-context="fullscreen"],
|
|
410
|
-
html[data-artifact-context="fullscreen"] body {
|
|
411
|
-
width: 100% !important;
|
|
412
|
-
min-height: 100% !important;
|
|
413
|
-
overflow: auto !important;
|
|
414
|
-
}
|
|
415
|
-
html[data-artifact-context="fullscreen"] .pages,
|
|
416
|
-
html[data-artifact-context="fullscreen"] .document-board,
|
|
417
|
-
html[data-artifact-context="fullscreen"] .artifact-board[data-artifact-layout="paged"],
|
|
418
|
-
html[data-artifact-context="fullscreen"] [data-artifact-layout="paged"] {
|
|
419
|
-
width: 100% !important;
|
|
420
|
-
height: auto !important;
|
|
421
|
-
min-height: 100% !important;
|
|
422
|
-
padding: 48px 0 !important;
|
|
423
|
-
display: flex !important;
|
|
424
|
-
flex-direction: column !important;
|
|
425
|
-
align-items: center !important;
|
|
426
|
-
justify-content: flex-start !important;
|
|
427
|
-
align-content: center !important;
|
|
428
|
-
gap: 32px !important;
|
|
429
|
-
background: transparent !important;
|
|
430
|
-
box-shadow: none !important;
|
|
431
|
-
overflow: visible !important;
|
|
432
|
-
}
|
|
433
|
-
html[data-artifact-context="fullscreen"] .page,
|
|
434
|
-
html[data-artifact-context="fullscreen"] [data-artifact-page] {
|
|
435
|
-
position: relative !important;
|
|
436
|
-
left: auto !important;
|
|
437
|
-
right: auto !important;
|
|
438
|
-
top: auto !important;
|
|
439
|
-
bottom: auto !important;
|
|
440
|
-
transform: none !important;
|
|
441
|
-
}
|
|
442
|
-
`
|
|
443
|
-
: '';
|
|
1114
|
+
const stepperFullscreen = stepperFullscreenCss(bundle);
|
|
1115
|
+
const pagedFullscreen = pagedFullscreenCss(bundle, false);
|
|
444
1116
|
const printPagination = `
|
|
445
1117
|
@media print {
|
|
446
1118
|
html[data-artifact-context="fullscreen"],
|
|
@@ -519,36 +1191,331 @@ ${exportFrameSizing}
|
|
|
519
1191
|
}
|
|
520
1192
|
}
|
|
521
1193
|
`;
|
|
1194
|
+
// Letterboxed exports (a stepped deck/prototype, a paged document) print each
|
|
1195
|
+
// frame as its own page at native size. `preferCSSPageSize:false` in the
|
|
1196
|
+
// headless Playwright PDF path ignores this, so it only governs a browser
|
|
1197
|
+
// Cmd+P / Save-as-PDF of the downloaded file — exactly the path it fixes.
|
|
1198
|
+
const printPageSize = (getStepperControl(bundle) || bundle.viewport.mode === 'paged')
|
|
1199
|
+
? `\n@media print {\n @page { size: ${frameWidth} ${frameHeight}; margin: 0; }\n html[data-artifact-context="fullscreen"] {\n --studiograph-export-frame-width: ${frameWidth};\n --studiograph-export-frame-height: ${frameHeight};\n }\n}\n`
|
|
1200
|
+
: '';
|
|
1201
|
+
// The on-screen present chrome (hide-inactive-frames, letterbox/self-fit
|
|
1202
|
+
// scaffolding, gray backdrop) is `@media screen` ONLY. Without this the
|
|
1203
|
+
// stepper hide rule out-specifies the print "show every frame" rule and only
|
|
1204
|
+
// the active frame would survive a print — and even it lands off-page from
|
|
1205
|
+
// the runtime's inline self-fit transform. Confining the present chrome to
|
|
1206
|
+
// screen lets the print rules below lay out all frames cleanly, one per page.
|
|
522
1207
|
return `<style data-artifact-export-host>
|
|
523
1208
|
html, body {
|
|
524
1209
|
margin: 0;
|
|
525
1210
|
padding: 0;
|
|
526
1211
|
}
|
|
1212
|
+
@media screen {
|
|
527
1213
|
${presentBackdrop}
|
|
528
1214
|
${pagedFullscreen}
|
|
529
1215
|
${stepperFullscreen}
|
|
1216
|
+
}
|
|
530
1217
|
${exportFlattening}
|
|
531
1218
|
${printPagination}
|
|
1219
|
+
${printPageSize}
|
|
532
1220
|
</style>`;
|
|
533
1221
|
}
|
|
534
|
-
|
|
1222
|
+
function headAdditions(bundle, entry, options) {
|
|
1223
|
+
// The CSP is derived from the SAME AssetResolution that rewrites the asset
|
|
1224
|
+
// URLs (finalize step), so this document can't carry URLs from one asset
|
|
1225
|
+
// model and a policy from another. `base-uri` opens to exactly the
|
|
1226
|
+
// baseHref's origin when the headless render injects a <base> tag.
|
|
1227
|
+
// Safe unescaped: every dynamic CSP part is validated upstream (origins are
|
|
1228
|
+
// URL-parsed, share tokens charset-checked) — no quote can reach here.
|
|
1229
|
+
const csp = artifactCsp(options.assets, { allowBaseOrigin: options.baseHref });
|
|
1230
|
+
const additions = [
|
|
1231
|
+
'<meta charset="utf-8">',
|
|
1232
|
+
`<meta http-equiv="Content-Security-Policy" content="${csp}">`,
|
|
1233
|
+
options.baseHref ? `<base href="${escapeAttribute(options.baseHref)}">` : '',
|
|
1234
|
+
'<meta name="generator" content="Studiograph Artifact Canvas">',
|
|
1235
|
+
/<title[\s>]/i.test(entry) ? '' : `<title>${escapeHtml(bundle.title)}</title>`,
|
|
1236
|
+
styleTags(bundle),
|
|
1237
|
+
options.includeRenderHostStyles ? artifactRenderHostStyleTags(bundle) : '',
|
|
1238
|
+
];
|
|
1239
|
+
return additions.filter(Boolean).join('\n');
|
|
1240
|
+
}
|
|
1241
|
+
function withDoctype(html) {
|
|
1242
|
+
return /^\s*<!doctype\b/i.test(html) ? html : `<!doctype html>\n${html}`;
|
|
1243
|
+
}
|
|
1244
|
+
export function artifactExportFilename(bundle, extension = 'html') {
|
|
1245
|
+
const source = bundle.title || bundle.artifactId || 'artifact';
|
|
1246
|
+
const slug = source
|
|
1247
|
+
.normalize('NFKD')
|
|
1248
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
1249
|
+
.toLowerCase()
|
|
1250
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
1251
|
+
.replace(/^-+|-+$/g, '')
|
|
1252
|
+
.slice(0, 80);
|
|
1253
|
+
return `${slug || 'artifact'}.${extension}`;
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* THE single artifact document builder (eng review D2). Every render context —
|
|
1257
|
+
* in-app canvas, public share/present page, downloaded standalone HTML, and the
|
|
1258
|
+
* headless Playwright render — produces its HTML here, from one set of
|
|
1259
|
+
* content-processing helpers and one runtime. `host` selects the document SHAPE
|
|
1260
|
+
* (a live-hosted iframe srcdoc vs a self-contained file) and the runtime's
|
|
1261
|
+
* capabilities; the asset CSP + URL rewrite come from the one `assets` value.
|
|
1262
|
+
*/
|
|
1263
|
+
export function buildArtifactDocument(bundle, ctx) {
|
|
1264
|
+
return ctx.host === 'iframe' ? buildIframeDocument(bundle, ctx) : buildStandaloneDocument(bundle, ctx);
|
|
1265
|
+
}
|
|
1266
|
+
/** A self-contained runtime that replaces the browser's default broken-image
|
|
1267
|
+
* glyph with a footprint-preserving placeholder, mirroring the reading
|
|
1268
|
+
* surfaces' missing-asset chip (Carbon DocumentUnknown glyph + the same
|
|
1269
|
+
* "Original asset is no longer available" copy; createUnavailableImageNote in
|
|
1270
|
+
* web/src/lib/utils/unavailable-image.ts). Injected into EVERY render (canvas,
|
|
1271
|
+
* fullscreen, standalone download, headless PNG/PDF) so a deleted or
|
|
1272
|
+
* unreachable embed degrades identically everywhere — and, unlike the inline
|
|
1273
|
+
* reading chip, holds the image's box so a fixed artifact layout (e.g. a slide)
|
|
1274
|
+
* doesn't collapse. Vanilla (the sandboxed iframe can't import the Svelte
|
|
1275
|
+
* builder), theme-agnostic (an artifact background is light OR dark, and the
|
|
1276
|
+
* app's `--color-text-*` tokens aren't defined inside the sandbox), capture
|
|
1277
|
+
* phase (an `<img>` `error` event doesn't bubble), idempotent, never throws. */
|
|
1278
|
+
function brokenImageScriptTag() {
|
|
1279
|
+
const js = `
|
|
1280
|
+
(() => {
|
|
1281
|
+
var MESSAGE = 'Original asset is no longer available';
|
|
1282
|
+
var GLYPH = [
|
|
1283
|
+
'M16,30H8a2,2,0,0,1-2-2V4A2,2,0,0,1,8,2h10a1,1,0,0,1,.71.29l5,5A1,1,0,0,1,24,8v6H22V9H17a1,1,0,0,1-1-1V4H8V28h8Z',
|
|
1284
|
+
'M24.5,29A2.5,2.5,0,1,1,27,26.5,2.5,2.5,0,0,1,24.5,29Zm0-3.5a1,1,0,1,0,1,1A1,1,0,0,0,24.5,25.5Z',
|
|
1285
|
+
'M24.5,23a1,1,0,0,1-1-1V21a2.5,2.5,0,1,0-2.5-2.5,1,1,0,0,1-2,0A4.5,4.5,0,1,1,25,22.79V22A1,1,0,0,1,24.5,23Z'
|
|
1286
|
+
];
|
|
1287
|
+
var NS = 'http://www.w3.org/2000/svg';
|
|
1288
|
+
function replace(img) {
|
|
1289
|
+
try {
|
|
1290
|
+
if (!img || img.tagName !== 'IMG' || img.hasAttribute('data-sg-broken')) return;
|
|
1291
|
+
if (img.complete && img.naturalWidth > 0) return; // recovered (e.g. a retry swapped src)
|
|
1292
|
+
img.setAttribute('data-sg-broken', '');
|
|
1293
|
+
var cs = window.getComputedStyle ? getComputedStyle(img) : null;
|
|
1294
|
+
var rect = img.getBoundingClientRect();
|
|
1295
|
+
// getComputedStyle returns a USED px length ("695px") only for a laid-out
|
|
1296
|
+
// element. Inside a display:none subtree — an INACTIVE FRAME in present /
|
|
1297
|
+
// fullscreen mode — it returns the COMPUTED value verbatim ("100%"),
|
|
1298
|
+
// which is not a pixel box; parseFloat("100%") === 100 would bake a bogus
|
|
1299
|
+
// 100x100 footprint (the small glyph-only box seen in present mode). So
|
|
1300
|
+
// accept a computed length ONLY when it is in px; it still dodges the
|
|
1301
|
+
// collapsed broken-img rendered box and the "numeric width attr that CSS
|
|
1302
|
+
// overrides" trap. Then a unitless pixel attribute, the rendered box, a
|
|
1303
|
+
// default. A percentage means the image fills its slot — mirrored below.
|
|
1304
|
+
var pxLen = function (cssVal) {
|
|
1305
|
+
if (typeof cssVal === 'string' && /px$/.test(cssVal)) {
|
|
1306
|
+
var c = parseFloat(cssVal);
|
|
1307
|
+
if (isFinite(c) && c > 1) return Math.round(c);
|
|
1308
|
+
}
|
|
1309
|
+
return null;
|
|
1310
|
+
};
|
|
1311
|
+
var dim = function (cssVal, attr, rectVal, dflt) {
|
|
1312
|
+
var c = pxLen(cssVal);
|
|
1313
|
+
if (c != null) return c;
|
|
1314
|
+
if (attr && /^\\s*\\d+(?:\\.\\d+)?\\s*$/.test(attr)) return parseFloat(attr);
|
|
1315
|
+
if (rectVal > 1) return Math.round(rectVal);
|
|
1316
|
+
return dflt;
|
|
1317
|
+
};
|
|
1318
|
+
var w = dim(cs && cs.width, img.getAttribute('width'), rect.width, 240);
|
|
1319
|
+
var h = dim(cs && cs.height, img.getAttribute('height'), rect.height, 160);
|
|
1320
|
+
// Fill case: the computed size is a percentage we could NOT resolve to px
|
|
1321
|
+
// (the img lives in a display:none present-mode frame). Mirror the
|
|
1322
|
+
// percentage onto the placeholder so it fills the same slot once the frame
|
|
1323
|
+
// is revealed + self-fit-scaled, instead of the default box. w/h keep
|
|
1324
|
+
// their defaults purely to select the large (glyph+label) tier, since a
|
|
1325
|
+
// fill image is content-sized.
|
|
1326
|
+
var fillW = (pxLen(cs && cs.width) == null && cs && /%$/.test(cs.width)) ? cs.width : null;
|
|
1327
|
+
var fillH = (pxLen(cs && cs.height) == null && cs && /%$/.test(cs.height)) ? cs.height : null;
|
|
1328
|
+
var radius = (cs && cs.borderRadius && cs.borderRadius !== '0px') ? cs.borderRadius : '4px';
|
|
1329
|
+
// Degrade by available footprint so a tiny broken embed stays tiny instead
|
|
1330
|
+
// of blowing out the layout with text it can't hold: large -> glyph +
|
|
1331
|
+
// label; small -> glyph only; tiny -> just the box. The aria-label always
|
|
1332
|
+
// carries the full message regardless of what is drawn.
|
|
1333
|
+
var minDim = w < h ? w : h;
|
|
1334
|
+
var showGlyph = minDim >= 40;
|
|
1335
|
+
var showText = w >= 130 && h >= 70;
|
|
1336
|
+
var pad = showText ? 12 : (showGlyph ? 6 : 0);
|
|
1337
|
+
// Block-level images (incl. flex/grid items) get a block-level flex box so
|
|
1338
|
+
// full-width layout and margin:auto centering survive; inline images stay
|
|
1339
|
+
// inline-flex.
|
|
1340
|
+
var disp = (cs && /^inline/.test(cs.display)) ? 'inline-flex' : 'flex';
|
|
1341
|
+
var note = document.createElement('div');
|
|
1342
|
+
note.setAttribute('data-sg-broken-image', '');
|
|
1343
|
+
note.setAttribute('role', 'img');
|
|
1344
|
+
var alt = (img.getAttribute('alt') || '').trim();
|
|
1345
|
+
note.setAttribute('aria-label', alt ? alt + ' \\u2014 ' + MESSAGE : MESSAGE);
|
|
1346
|
+
// width holds the footprint; the text tier uses min-height (not a fixed
|
|
1347
|
+
// height) so the label can grow rather than clip, while the glyph/box
|
|
1348
|
+
// tiers keep the exact footprint. overflow:hidden only rounds the corners
|
|
1349
|
+
// (the grown/exact height always fits whatever is drawn).
|
|
1350
|
+
var widthCss = 'width:' + (fillW || (w + 'px')) + ';';
|
|
1351
|
+
var heightCss = fillH ? 'height:' + fillH + ';' : (showText ? 'min-height:' + h + 'px;' : 'height:' + h + 'px;');
|
|
1352
|
+
note.style.cssText = 'box-sizing:border-box;display:' + disp + ';flex-direction:column;align-items:center;justify-content:center;gap:8px;' + widthCss + heightCss + 'padding:' + pad + 'px;background:rgba(128,128,128,0.12);border:1px solid rgba(128,128,128,0.25);border-radius:' + radius + ';color:rgba(120,120,120,0.95);font:500 13px/1.35 system-ui,-apple-system,sans-serif;text-align:center;overflow:hidden';
|
|
1353
|
+
// Preserve the image's placement so a broken absolutely-positioned,
|
|
1354
|
+
// floated, centered, or grid/flex-placed image keeps its slot instead of
|
|
1355
|
+
// dropping into normal flow and shifting the whole artifact.
|
|
1356
|
+
if (cs) {
|
|
1357
|
+
var keep = ['position','top','right','bottom','left','transform','transformOrigin','float','marginTop','marginRight','marginBottom','marginLeft','verticalAlign','alignSelf','justifySelf','gridColumn','gridRow'];
|
|
1358
|
+
for (var k = 0; k < keep.length; k++) {
|
|
1359
|
+
var val = cs[keep[k]];
|
|
1360
|
+
if (val && val !== 'auto' && val !== 'normal' && val !== 'static' && val !== 'none' && val !== '0px') {
|
|
1361
|
+
try { note.style[keep[k]] = val; } catch (e) {}
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
if (showGlyph) {
|
|
1366
|
+
var svg = document.createElementNS(NS, 'svg');
|
|
1367
|
+
svg.setAttribute('viewBox', '0 0 32 32');
|
|
1368
|
+
svg.setAttribute('width', '20');
|
|
1369
|
+
svg.setAttribute('height', '20');
|
|
1370
|
+
svg.setAttribute('fill', 'currentColor');
|
|
1371
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
1372
|
+
svg.style.cssText = 'flex:none;opacity:0.85';
|
|
1373
|
+
for (var i = 0; i < GLYPH.length; i++) {
|
|
1374
|
+
var p = document.createElementNS(NS, 'path');
|
|
1375
|
+
p.setAttribute('d', GLYPH[i]);
|
|
1376
|
+
svg.appendChild(p);
|
|
1377
|
+
}
|
|
1378
|
+
note.appendChild(svg);
|
|
1379
|
+
}
|
|
1380
|
+
if (showText) {
|
|
1381
|
+
var label = document.createElement('span');
|
|
1382
|
+
label.textContent = MESSAGE;
|
|
1383
|
+
label.style.maxWidth = '100%';
|
|
1384
|
+
note.appendChild(label);
|
|
1385
|
+
}
|
|
1386
|
+
var prevDisplay = img.style.display;
|
|
1387
|
+
img.style.display = 'none';
|
|
1388
|
+
if (img.parentNode) img.parentNode.insertBefore(note, img.nextSibling);
|
|
1389
|
+
// Self-heal: if the same <img> later loads (an artifact's own onerror
|
|
1390
|
+
// retry swapped src to a working URL), drop the placeholder and restore
|
|
1391
|
+
// the image to its PRIOR inline display rather than leave a valid image
|
|
1392
|
+
// hidden behind the note.
|
|
1393
|
+
img.addEventListener('load', function onload() {
|
|
1394
|
+
if (img.naturalWidth > 0) {
|
|
1395
|
+
img.removeEventListener('load', onload);
|
|
1396
|
+
if (note.parentNode) note.parentNode.removeChild(note);
|
|
1397
|
+
img.style.display = prevDisplay;
|
|
1398
|
+
img.removeAttribute('data-sg-broken');
|
|
1399
|
+
}
|
|
1400
|
+
});
|
|
1401
|
+
} catch (e) {}
|
|
1402
|
+
}
|
|
1403
|
+
// Schedule on the window receiver (a detached rAF call can throw in strict
|
|
1404
|
+
// contexts) and never let scheduling throw out of the capture listener.
|
|
1405
|
+
function defer(fn) {
|
|
1406
|
+
try { if (window.requestAnimationFrame) { window.requestAnimationFrame(fn); return; } } catch (e) {}
|
|
1407
|
+
try { window.setTimeout(fn, 0); } catch (e) {}
|
|
1408
|
+
}
|
|
1409
|
+
document.addEventListener('error', function (e) {
|
|
1410
|
+
var t = e && e.target;
|
|
1411
|
+
// Defer a frame so an artifact's own onerror retry can swap src first; the
|
|
1412
|
+
// load-time sweep below still inserts the note synchronously before a
|
|
1413
|
+
// headless capture (which waits for load + rAF), so the export never races.
|
|
1414
|
+
if (t && t.tagName === 'IMG') defer(function () { replace(t); });
|
|
1415
|
+
}, true);
|
|
1416
|
+
// Sweep images already broken before the listener registered (cached 404s, or
|
|
1417
|
+
// the standalone tag running at body end after parse).
|
|
1418
|
+
function sweep() {
|
|
1419
|
+
try {
|
|
1420
|
+
var imgs = document.querySelectorAll('img');
|
|
1421
|
+
for (var i = 0; i < imgs.length; i++) {
|
|
1422
|
+
var im = imgs[i];
|
|
1423
|
+
if (im.complete && im.naturalWidth === 0 && im.getAttribute('src')) replace(im);
|
|
1424
|
+
}
|
|
1425
|
+
} catch (e) {}
|
|
1426
|
+
}
|
|
1427
|
+
// Run once now — catches images already parsed + broken when this tag
|
|
1428
|
+
// executes (the standalone tag sits at body end, after the markup), so a
|
|
1429
|
+
// user/library script running next sees the placeholder, not the raw broken
|
|
1430
|
+
// img. Run again on load for cached 404s that completed before the capture
|
|
1431
|
+
// listener registered.
|
|
1432
|
+
sweep();
|
|
1433
|
+
window.addEventListener('load', sweep);
|
|
1434
|
+
})();`;
|
|
1435
|
+
return `<script data-artifact-broken-image>${escapeScript(js)}</script>`;
|
|
1436
|
+
}
|
|
1437
|
+
/** Live-hosted iframe srcdoc (in-app canvas + public share/present page). */
|
|
1438
|
+
function buildIframeDocument(bundle, ctx) {
|
|
1439
|
+
const context = ctx.context ?? 'editor';
|
|
1440
|
+
const tool = ctx.tool ?? 'pointer';
|
|
1441
|
+
// Resolve workspace-asset URLs per the render's AssetResolution at the
|
|
1442
|
+
// single exit below (token: append ?at= so the cookieless sandbox can load
|
|
1443
|
+
// workspace images; share: the token-scoped public path). The CSP in `meta`
|
|
1444
|
+
// derives from the SAME value, so URLs and policy can never disagree.
|
|
1445
|
+
const finalize = (html) => applyAssetResolution(html, ctx.assets);
|
|
1446
|
+
const stepperControl = getStepperControl(bundle);
|
|
1447
|
+
const rawEntry = stripNetworkAssetTags(bundle.files[bundle.entrypoint] ?? '');
|
|
1448
|
+
const deferred = requestedLibraries(bundle).length > 0 ? deferInlineScripts(rawEntry) : { html: rawEntry, scripts: [] };
|
|
1449
|
+
const entry = deferred.html;
|
|
1450
|
+
// Safe unescaped: every dynamic CSP part is validated upstream (origins are
|
|
1451
|
+
// URL-parsed, share tokens charset-checked) — no quote can reach here.
|
|
1452
|
+
const meta = `<meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${artifactCsp(ctx.assets)}">${fontPreloadTags(bundle, ctx.assets)}`;
|
|
1453
|
+
const libraries = libraryTags(bundle, ctx.librarySources);
|
|
1454
|
+
const runtime = `<script>${escapeScript(iframeRuntimeScript(bundle.artifactId, context, tool, stepperControl, bundle.viewport.mode))}</script>\n${brokenImageScriptTag()}`;
|
|
1455
|
+
const styles = [styleTags(bundle), inAppHostStyleTags(bundle, context)].filter(Boolean).join('\n');
|
|
1456
|
+
const scripts = scriptTags(bundle, deferred.scripts);
|
|
1457
|
+
if (/<html[\s>]/i.test(entry)) {
|
|
1458
|
+
let html = addArtifactContextToHtmlTag(entry, context, tool, !!stepperControl);
|
|
1459
|
+
html = html.replace(/<head([^>]*)>/i, `<head$1>${meta}\n${libraries}\n${runtime}\n${styles}`);
|
|
1460
|
+
if (!/<head[\s>]/i.test(html))
|
|
1461
|
+
html = html.replace(/<html([^>]*)>/i, `<html$1><head>${meta}\n${libraries}\n${runtime}\n${styles}</head>`);
|
|
1462
|
+
if (/<body[\s>]/i.test(html)) {
|
|
1463
|
+
html = /<\/body>/i.test(html) ? html.replace(/<\/body>/i, `${scripts}\n</body>`) : `${html}\n${scripts}`;
|
|
1464
|
+
}
|
|
1465
|
+
else {
|
|
1466
|
+
html += `<body>${scripts}</body>`;
|
|
1467
|
+
}
|
|
1468
|
+
return finalize(html);
|
|
1469
|
+
}
|
|
1470
|
+
return finalize(`<!doctype html>
|
|
1471
|
+
<html data-artifact-context="${escapeAttribute(context)}" data-artifact-tool="${escapeAttribute(tool)}"${stepperControl ? ' data-artifact-stepper="true"' : ''}>
|
|
1472
|
+
<head>
|
|
1473
|
+
${meta}
|
|
1474
|
+
${libraries}
|
|
1475
|
+
${runtime}
|
|
1476
|
+
${styles}
|
|
1477
|
+
</head>
|
|
1478
|
+
<body>
|
|
1479
|
+
${entry}
|
|
1480
|
+
${scripts}
|
|
1481
|
+
</body>
|
|
1482
|
+
</html>`);
|
|
1483
|
+
}
|
|
1484
|
+
/** Self-contained file (downloaded standalone HTML + headless Playwright). */
|
|
1485
|
+
function buildStandaloneDocument(bundle, ctx) {
|
|
1486
|
+
const options = {
|
|
1487
|
+
context: ctx.context,
|
|
1488
|
+
baseHref: ctx.baseHref,
|
|
1489
|
+
includeRenderHostStyles: ctx.includeRenderHostStyles,
|
|
1490
|
+
librarySources: ctx.librarySources,
|
|
1491
|
+
assets: ctx.assets,
|
|
1492
|
+
};
|
|
535
1493
|
// Resolve workspace-asset URLs per the render's AssetResolution at the
|
|
536
1494
|
// single exit (token: append ?at= for the cookieless headless render;
|
|
537
1495
|
// relative: ./assets paths + placeholders for the standalone export).
|
|
538
1496
|
// The CSP in headAdditions derives from the same value.
|
|
539
|
-
const finalize = (html) => applyAssetResolution(html,
|
|
1497
|
+
const finalize = (html) => applyAssetResolution(html, ctx.assets);
|
|
1498
|
+
const stepperControl = getStepperControl(bundle);
|
|
540
1499
|
const rawEntry = stripNetworkAssetTags(bundle.files[bundle.entrypoint] ?? '');
|
|
541
1500
|
const deferred = requestedLibraries(bundle).length > 0 ? deferInlineScripts(rawEntry) : { html: rawEntry, scripts: [] };
|
|
542
1501
|
const entry = deferred.html;
|
|
543
1502
|
const styles = headAdditions(bundle, entry, options);
|
|
544
|
-
const libraries = libraryTags(bundle,
|
|
1503
|
+
const libraries = libraryTags(bundle, ctx.librarySources);
|
|
545
1504
|
const scripts = scriptTags(bundle, deferred.scripts);
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
1505
|
+
// Self-fit stepper runtime: ONLY a stepped fullscreen export needs it (a
|
|
1506
|
+
// paged reader / plain document letterboxes via CSS alone, and the headless
|
|
1507
|
+
// export-context render drives frames itself). Same unified runtime, hostless
|
|
1508
|
+
// capability set — the duplicate standalone stepper is gone.
|
|
1509
|
+
const runtime = (ctx.context === 'fullscreen' && stepperControl)
|
|
1510
|
+
? `<script data-artifact-standalone-runtime>${escapeScript(standaloneRuntimeScript(stepperControl))}</script>`
|
|
1511
|
+
: '';
|
|
1512
|
+
// Broken-image runtime first so its capture listener is live before any
|
|
1513
|
+
// user/library script runs; it also sweeps already-broken images on load.
|
|
1514
|
+
const bodyScripts = [brokenImageScriptTag(), runtime, scripts].filter(Boolean).join('\n');
|
|
1515
|
+
const htmlAttrs = ctx.context
|
|
549
1516
|
? {
|
|
550
|
-
'data-artifact-context':
|
|
551
|
-
'data-artifact-stepper':
|
|
1517
|
+
'data-artifact-context': ctx.context,
|
|
1518
|
+
'data-artifact-stepper': stepperControl ? 'true' : undefined,
|
|
552
1519
|
}
|
|
553
1520
|
: {};
|
|
554
1521
|
if (/<html[\s>]/i.test(entry)) {
|
|
@@ -570,7 +1537,7 @@ export function buildArtifactHtmlExport(bundle, options = {}) {
|
|
|
570
1537
|
return finalize(withDoctype(html));
|
|
571
1538
|
}
|
|
572
1539
|
return finalize(`<!doctype html>
|
|
573
|
-
<html${
|
|
1540
|
+
<html${ctx.context ? ` data-artifact-context="${escapeAttribute(ctx.context)}"${stepperControl ? ' data-artifact-stepper="true"' : ''}` : ''}>
|
|
574
1541
|
<head>
|
|
575
1542
|
${styles}
|
|
576
1543
|
${libraries}
|
|
@@ -581,4 +1548,20 @@ ${bodyScripts}
|
|
|
581
1548
|
</body>
|
|
582
1549
|
</html>`);
|
|
583
1550
|
}
|
|
1551
|
+
/**
|
|
1552
|
+
* Self-contained HTML export + headless Playwright render. A thin adapter over
|
|
1553
|
+
* {@link buildArtifactDocument} with `host: 'none'` — kept for its existing
|
|
1554
|
+
* callers (the export route, MCP `export_artifact`, the render service). A
|
|
1555
|
+
* bare call (no `context`) yields a plain self-contained file, unchanged.
|
|
1556
|
+
*/
|
|
1557
|
+
export function buildArtifactHtmlExport(bundle, options = {}) {
|
|
1558
|
+
return buildArtifactDocument(bundle, {
|
|
1559
|
+
host: 'none',
|
|
1560
|
+
context: options.context,
|
|
1561
|
+
baseHref: options.baseHref,
|
|
1562
|
+
includeRenderHostStyles: options.includeRenderHostStyles,
|
|
1563
|
+
librarySources: options.librarySources,
|
|
1564
|
+
assets: options.assets,
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
584
1567
|
//# sourceMappingURL=artifact-export.js.map
|