diff --git a/tests/present-frame.test.ts b/tests/present-frame.test.ts new file mode 100644 index 0000000..568415e --- /dev/null +++ b/tests/present-frame.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { + PRESENT_BYTE_LIMIT, + PRESENT_FRAME_SANDBOX, + buildPresentSrcdoc, + isFrontendBundle, + presentSourceBytes, + previewPacket, +} from "../web/present-frame.js"; + +describe("present frame srcdoc", () => { + it("keeps the sandbox flag to scripts only", () => { + expect(PRESENT_FRAME_SANDBOX).toBe("allow-scripts"); + expect(PRESENT_FRAME_SANDBOX).not.toContain("allow-same-origin"); + }); + + it("embeds the skeleton, styles, script, and sample packet", () => { + const srcdoc = buildPresentSrcdoc({ + html: `
`, + css: `#app { color: #e1e7f0; }`, + js: `present.onData(function (data) { document.getElementById("app").textContent = data.blocks.body; });`, + packet: { schema: "present.v1", blocks: { body: "示例正文" } }, + frameId: "pf1", + }); + expect(srcdoc).toContain(`
`); + expect(srcdoc).toContain(`#app { color: #e1e7f0; }`); + expect(srcdoc).toContain("present.onData"); + expect(srcdoc).toContain("示例正文"); + expect(srcdoc).toContain(`default-src 'none'`); + expect(srcdoc.indexOf("window.present")).toBeLessThan(srcdoc.indexOf("data.blocks.body")); + const scripts = [...srcdoc.matchAll(/ + + +${htmlSafe} + + + +`; +} + +function blocksToPlain(packet) { + const blocks = packet?.blocks; + if (!blocks || typeof blocks !== "object") return ""; + return Object.entries(blocks) + .map(([key, value]) => { + const text = typeof value === "string" ? value : JSON.stringify(value, null, 2); + return `${key}\n${text}`; + }) + .join("\n\n"); +} + +function mountOne(host, id, payload) { + const frame = document.createElement("iframe"); + frame.className = "present-preview-frame"; + frame.setAttribute("sandbox", PRESENT_FRAME_SANDBOX); + frame.setAttribute("referrerpolicy", "no-referrer"); + frame.title = "版式预览"; + const srcdoc = buildPresentSrcdoc({ + html: payload.bundle.html, + css: payload.bundle.css, + js: payload.bundle.js, + packet: payload.packet, + frameId: id, + }); + const section = host.closest(".present-preview"); + const status = section?.querySelector(".present-preview-status"); + const fallback = section?.querySelector(".present-preview-fallback"); + let settled = false; + + const fail = (message) => { + frame.hidden = true; + if (status) { + status.hidden = false; + status.textContent = message; + } + const plain = blocksToPlain(payload.packet); + if (fallback && plain) { + fallback.hidden = false; + fallback.textContent = plain; + } + }; + + const timer = window.setTimeout(() => { + if (settled) return; + settled = true; + fail("模板 3 秒内没有就绪,下面是示例内容。"); + }, 3000); + + const onMessage = (event) => { + if (!frame.isConnected) { + window.removeEventListener("message", onMessage); + window.clearTimeout(timer); + return; + } + if (event.source !== frame.contentWindow) return; + const data = event.data; + if (!data || data.source !== "present-frame" || data.id !== id) return; + if (data.type === "height" && !frame.hidden) { + const height = Number(data.height); + if (height > 0) { + frame.style.height = `${Math.min(960, Math.max(180, Math.ceil(height)))}px`; + } + } + if (data.type === "ready") { + settled = true; + window.clearTimeout(timer); + } + if (data.type === "error") { + window.clearTimeout(timer); + if (!settled) { + settled = true; + fail(data.message ? `模板没有跑起来:${data.message}` : "模板没有跑起来。"); + } else if (status) { + status.hidden = false; + status.textContent = data.message || "模板运行出错"; + } + } + }; + window.addEventListener("message", onMessage); + host.replaceChildren(frame); + frame.srcdoc = srcdoc; +} + +export function mountPresentFrames(root) { + if (!root || typeof root.querySelectorAll !== "function") return; + root.querySelectorAll(".present-frame-host[data-present-id]").forEach((host) => { + if (host.dataset.mounted === "1") return; + const id = host.dataset.presentId; + const payload = frames.get(id); + if (!payload) return; + host.dataset.mounted = "1"; + frames.delete(id); + mountOne(host, id, payload); + }); +} + +export function watchPresentFrames() { + if (typeof document === "undefined" || watchPresentFrames.started) return; + watchPresentFrames.started = true; + const run = () => mountPresentFrames(document); + const start = () => { + const obs = new MutationObserver(run); + obs.observe(document.body, { childList: true, subtree: true }); + run(); + }; + if (document.body) start(); + else document.addEventListener("DOMContentLoaded", start, { once: true }); +} diff --git a/web/styles.css b/web/styles.css index f6fa3aa..3d92635 100644 --- a/web/styles.css +++ b/web/styles.css @@ -3894,6 +3894,38 @@ body.design-workspace .af-mosaic { .af-section:last-child { margin-bottom: 0; } +.present-preview-note, +.present-preview-status { + margin: 0 0 8px; + font-size: 12px; + line-height: 1.45; + color: var(--muted); +} +.present-frame-host { + width: 100%; + min-width: 0; + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; + background: var(--surface); +} +.present-preview-frame { + display: block; + width: 100%; + height: 220px; + border: 0; + background: transparent; +} +.present-preview-fallback { + margin-top: 8px; + padding: 8px 10px; + border-radius: 8px; + background: var(--bg); + white-space: pre-wrap; + word-break: break-word; + font-size: 13px; + line-height: 1.55; +} .af-section-title { margin: 0 0 8px; font-family: var(--font-ui);