Skip to content

Commit 3179f3c

Browse files
TuYvTuYv
authored andcommitted
fix(export): render complete WeChat document offscreen
1 parent 4732ad8 commit 3179f3c

4 files changed

Lines changed: 154 additions & 20 deletions

File tree

e2e/ui/export-menu.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,30 @@ const hyperframesHtml = `<!doctype html>
3535
</body>
3636
</html>`;
3737

38+
const runtimeDeckHtml = `<!doctype html>
39+
<html>
40+
<head>
41+
<title>Runtime Deck</title>
42+
<script>
43+
const style = document.createElement("style");
44+
style.textContent = ".runtime { color: rgb(1, 2, 3); }";
45+
document.head.appendChild(style);
46+
</script>
47+
</head>
48+
<body>
49+
<section class="slide runtime" data-slide-id="1"><h1>Runtime slide one</h1></section>
50+
<section class="slide runtime" data-slide-id="2"><h1>Runtime slide two</h1></section>
51+
</body>
52+
</html>`;
53+
54+
const runtimePlainHtml = `<!doctype html>
55+
<html><head><script>
56+
const style = document.createElement("style");
57+
style.textContent = ".runtime { color: rgb(1, 2, 3); }";
58+
document.head.appendChild(style);
59+
</script></head>
60+
<body><p class="runtime">Runtime source-tab content</p></body></html>`;
61+
3862
async function seedStore(page: Page, opts: SeedOptions) {
3963
const now = 1_700_000_000_000;
4064
const task = {
@@ -78,6 +102,30 @@ async function seedStore(page: Page, opts: SeedOptions) {
78102
);
79103
}
80104

105+
async function captureClipboardHtml(page: Page) {
106+
await page.evaluate(() => {
107+
Object.defineProperty(window, "ClipboardItem", {
108+
configurable: true,
109+
value: class ClipboardItem {
110+
readonly items: Record<string, Blob>;
111+
constructor(items: Record<string, Blob>) {
112+
this.items = items;
113+
}
114+
},
115+
});
116+
Object.defineProperty(navigator, "clipboard", {
117+
configurable: true,
118+
value: {
119+
write: async (items: Array<{ items: Record<string, Blob> }>) => {
120+
const blob = items[0]?.items["text/html"];
121+
(window as typeof window & { __wechatClipboardHtml?: string }).__wechatClipboardHtml =
122+
blob ? await blob.text() : "";
123+
},
124+
},
125+
});
126+
});
127+
}
128+
81129
test.describe("Export menu", () => {
82130
test("keeps Remotion hidden for regular HTML exports", async ({ page }) => {
83131
await seedStore(page, { html: plainHtml });
@@ -96,6 +144,48 @@ test.describe("Export menu", () => {
96144
await expect(menu.getByRole("button", { name: /Remotion project/ })).toHaveCount(0);
97145
});
98146

147+
test("keeps computed styles when exporting from Source and Log tabs", async ({ page }) => {
148+
await seedStore(page, { html: runtimePlainHtml });
149+
await page.goto("/");
150+
await captureClipboardHtml(page);
151+
152+
for (const tab of [/Source/, /Log/]) {
153+
await page.getByRole("button", { name: tab }).click();
154+
await page.evaluate(() => {
155+
(window as typeof window & { __wechatClipboardHtml?: string }).__wechatClipboardHtml = "";
156+
});
157+
await page.getByRole("button", { name: /export/i }).click();
158+
await page.getByTestId("export-menu").getByRole("button", { name: /WeChat/ }).click();
159+
160+
await expect.poll(() =>
161+
page.evaluate(() => (window as typeof window & { __wechatClipboardHtml?: string }).__wechatClipboardHtml ?? ""),
162+
).toContain("Runtime source-tab content");
163+
const copied = await page.evaluate(() =>
164+
(window as typeof window & { __wechatClipboardHtml?: string }).__wechatClipboardHtml ?? "",
165+
);
166+
expect(copied).toContain("color: rgb(1, 2, 3)");
167+
}
168+
});
169+
170+
test("exports every computed-styled slide when slide 2 is selected", async ({ page }) => {
171+
await seedStore(page, { html: runtimeDeckHtml });
172+
await page.goto("/");
173+
await captureClipboardHtml(page);
174+
175+
await page.getByRole("button", { name: "2", exact: true }).click();
176+
await page.getByRole("button", { name: /export/i }).click();
177+
await page.getByTestId("export-menu").getByRole("button", { name: /WeChat/ }).click();
178+
179+
await expect.poll(() =>
180+
page.evaluate(() => (window as typeof window & { __wechatClipboardHtml?: string }).__wechatClipboardHtml ?? ""),
181+
).toContain("Runtime slide one");
182+
const copied = await page.evaluate(() =>
183+
(window as typeof window & { __wechatClipboardHtml?: string }).__wechatClipboardHtml ?? "",
184+
);
185+
expect(copied).toContain("Runtime slide two");
186+
expect(copied).toContain("color: rgb(1, 2, 3)");
187+
});
188+
99189
test("exports a Hyperframes Remotion project zip from the UI", async ({ page }) => {
100190
await seedStore(page, { html: hyperframesHtml });
101191

next/src/components/export-menu.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export function ExportMenu({ iframeRef }: ExportMenuProps) {
7676
{
7777
title: t("export.section.platform"),
7878
actions: [
79-
{ id: "wechat", label: t("export.action.wechat"), emoji: "💬", fn: wrap(t("export.toast.wechat"), async () => { await copyToWechat(cleanHtml(), iframeRef.current?.contentDocument); }) },
79+
{ id: "wechat", label: t("export.action.wechat"), emoji: "💬", fn: wrap(t("export.toast.wechat"), async () => { await copyToWechat(cleanHtml()); }) },
8080
{ id: "zhihu", label: t("export.action.zhihu"), emoji: "🦓", fn: wrap(t("export.toast.zhihu"), async () => { await copyToZhihu(cleanHtml()); }) },
8181
{ id: "twitter-img", label: t("export.action.twitterImg"), emoji: "🐦", fn: wrap(t("export.toast.image"), async () => {
8282
if (!iframeRef.current) throw new Error(t("export.error.previewNotReady")); await copyIframeToClipboard(iframeRef.current);

next/src/lib/export/__tests__/wechat.test.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, afterEach } from "vitest";
2-
import { toWechatHtmlForExport, toWechatHtmlFromDocument } from "../wechat";
2+
import { renderToWechatHtml, toWechatHtmlFromDocument } from "../wechat";
33

44
function parseFragment(html: string): HTMLBodyElement {
55
return new DOMParser().parseFromString(`<body>${html}</body>`, "text/html")
@@ -211,18 +211,17 @@ describe("toWechatHtmlFromDocument", () => {
211211
expect(style).not.toContain("96px");
212212
});
213213

214-
it("keeps every deck slide when the selected iframe contains only one slide", () => {
215-
const fullDeck = `<!doctype html><html><body>
216-
<section class="slide" data-slide-id="1"><h1>Slide one</h1></section>
217-
<section class="slide" data-slide-id="2"><h1>Slide two</h1></section>
214+
it("keeps every runtime-styled deck slide in the full rendered export", async () => {
215+
const fullDeck = `<!doctype html><html><head>
216+
<style>.runtime { color: rgb(1, 2, 3); }</style>
217+
</head><body>
218+
<section class="slide runtime" data-slide-id="1"><h1>Slide one</h1></section>
219+
<section class="slide runtime" data-slide-id="2"><h1>Slide two</h1></section>
218220
</body></html>`;
219-
const selectedSlide = new DOMParser().parseFromString(
220-
`<!doctype html><html><body><section class="slide"><h1>Slide two</h1></section></body></html>`,
221-
"text/html",
222-
);
223221

224-
const exported = toWechatHtmlForExport(fullDeck, selectedSlide);
222+
const exported = await renderToWechatHtml(fullDeck);
225223
expect(exported).toContain("Slide one");
226224
expect(exported).toContain("Slide two");
225+
expect(exported).toContain("color: rgb(1, 2, 3)");
227226
});
228227
});

next/src/lib/export/wechat.ts

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"use client";
22

33
import juice from "juice";
4-
import { isDeck } from "../deck";
54
import { copyHtml } from "./clipboard";
65

76
/**
@@ -106,16 +105,62 @@ export function toWechatHtmlFromDocument(renderedDoc: Document): string {
106105
return section.outerHTML;
107106
}
108107

109-
export function toWechatHtmlForExport(
110-
fullHtml: string,
111-
renderedDoc?: Document | null,
112-
): string {
113-
if (isDeck(fullHtml)) return toWechatHtml(fullHtml);
114-
return renderedDoc?.body ? toWechatHtmlFromDocument(renderedDoc) : toWechatHtml(fullHtml);
108+
/**
109+
* Render the complete source document in an offscreen iframe for every
110+
* toolbar WeChat export. This keeps Source/Log tabs and DeckViewer from
111+
* silently switching the export to a partial or unrendered document.
112+
*/
113+
export async function renderToWechatHtml(fullHtml: string): Promise<string> {
114+
if (typeof window === "undefined") return toWechatHtml(fullHtml);
115+
116+
const iframe = document.createElement("iframe");
117+
iframe.setAttribute("sandbox", "allow-scripts allow-same-origin");
118+
iframe.style.cssText =
119+
"position: fixed; left: -100000px; top: 0; width: 1280px; height: 960px; border: 0; visibility: hidden;";
120+
iframe.srcdoc = fullHtml;
121+
document.body.appendChild(iframe);
122+
123+
try {
124+
await waitForIframeLoad(iframe);
125+
const renderedDoc = iframe.contentDocument;
126+
if (!renderedDoc?.body) return toWechatHtml(fullHtml);
127+
await waitForDocumentReady(renderedDoc);
128+
return toWechatHtmlFromDocument(renderedDoc);
129+
} catch {
130+
return toWechatHtml(fullHtml);
131+
} finally {
132+
iframe.remove();
133+
}
115134
}
116135

117-
export async function copyToWechat(fullHtml: string, renderedDoc?: Document | null): Promise<void> {
118-
await copyHtml(toWechatHtmlForExport(fullHtml, renderedDoc));
136+
export async function copyToWechat(fullHtml: string): Promise<void> {
137+
await copyHtml(await renderToWechatHtml(fullHtml));
138+
}
139+
140+
function waitForIframeLoad(iframe: HTMLIFrameElement): Promise<void> {
141+
return new Promise((resolve) => {
142+
let settled = false;
143+
let timeout = 0;
144+
const finish = () => {
145+
if (settled) return;
146+
settled = true;
147+
window.clearTimeout(timeout);
148+
iframe.removeEventListener("load", finish);
149+
resolve();
150+
};
151+
iframe.addEventListener("load", finish, { once: true });
152+
timeout = window.setTimeout(finish, 8000);
153+
});
154+
}
155+
156+
async function waitForDocumentReady(doc: Document): Promise<void> {
157+
try {
158+
const fonts = (doc as Document & { fonts?: FontFaceSet }).fonts;
159+
if (fonts?.ready) await fonts.ready;
160+
} catch {
161+
// Font loading is best effort; computed styles are still useful without it.
162+
}
163+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
119164
}
120165

121166
function inlineComputedTree(source: Element, clone: Element, view: Window): void {

0 commit comments

Comments
 (0)