Skip to content

Commit 9f2f982

Browse files
TuYvTuYv
authored andcommitted
fix(export): stabilize rendered WeChat clipboard output
1 parent 3179f3c commit 9f2f982

4 files changed

Lines changed: 128 additions & 10 deletions

File tree

e2e/ui/export-menu.test.ts

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,27 @@ const runtimeDeckHtml = `<!doctype html>
5454
const runtimePlainHtml = `<!doctype html>
5555
<html><head><script>
5656
const style = document.createElement("style");
57-
style.textContent = ".runtime { color: rgb(1, 2, 3); }";
57+
style.textContent =
58+
".runtime { color: rgb(1, 2, 3); }" +
59+
".reveal { opacity: 0; }" +
60+
".reveal.visible { opacity: 1; }" +
61+
".entry { animation: fade-in 700ms linear forwards; }" +
62+
"@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }";
5863
document.head.appendChild(style);
64+
addEventListener("DOMContentLoaded", () => {
65+
const target = document.querySelector(".reveal");
66+
if (target) new IntersectionObserver(() => target.classList.add("visible"), { threshold: 0 }).observe(target);
67+
});
5968
</script></head>
60-
<body><p class="runtime">Runtime source-tab content</p></body></html>`;
69+
<body>
70+
<p class="runtime reveal">Observer reveal</p>
71+
<p class="runtime entry">Animated entry</p>
72+
<p class="runtime">Runtime source-tab content</p>
73+
</body></html>`;
74+
75+
const delayedHtml = `<!doctype html><html><head>
76+
<script src="https://delayed.example/slow.js"></script>
77+
</head><body><p class="runtime">Delayed clipboard content</p></body></html>`;
6178

6279
async function seedStore(page: Page, opts: SeedOptions) {
6380
const now = 1_700_000_000_000;
@@ -107,17 +124,18 @@ async function captureClipboardHtml(page: Page) {
107124
Object.defineProperty(window, "ClipboardItem", {
108125
configurable: true,
109126
value: class ClipboardItem {
110-
readonly items: Record<string, Blob>;
111-
constructor(items: Record<string, Blob>) {
127+
readonly items: Record<string, Blob | Promise<Blob>>;
128+
constructor(items: Record<string, Blob | Promise<Blob>>) {
112129
this.items = items;
113130
}
114131
},
115132
});
116133
Object.defineProperty(navigator, "clipboard", {
117134
configurable: true,
118135
value: {
119-
write: async (items: Array<{ items: Record<string, Blob> }>) => {
120-
const blob = items[0]?.items["text/html"];
136+
write: async (items: Array<{ items: Record<string, Blob | Promise<Blob>> }>) => {
137+
(window as typeof window & { __clipboardWriteStartedAt?: number }).__clipboardWriteStartedAt = performance.now();
138+
const blob = await items[0]?.items["text/html"];
121139
(window as typeof window & { __wechatClipboardHtml?: string }).__wechatClipboardHtml =
122140
blob ? await blob.text() : "";
123141
},
@@ -164,6 +182,9 @@ test.describe("Export menu", () => {
164182
(window as typeof window & { __wechatClipboardHtml?: string }).__wechatClipboardHtml ?? "",
165183
);
166184
expect(copied).toContain("color: rgb(1, 2, 3)");
185+
expect(copied).toContain("Observer reveal");
186+
expect(copied).toContain("Animated entry");
187+
expect(copied).not.toContain("opacity: 0");
167188
}
168189
});
169190

@@ -186,6 +207,29 @@ test.describe("Export menu", () => {
186207
expect(copied).toContain("color: rgb(1, 2, 3)");
187208
});
188209

210+
test("starts the clipboard write before a slow full-document render settles", async ({ page }) => {
211+
await page.route("https://delayed.example/slow.js", async (route) => {
212+
await new Promise((resolve) => setTimeout(resolve, 6000));
213+
await route.fulfill({ contentType: "application/javascript", body: "document.body.dataset.ready = '1';" });
214+
});
215+
await seedStore(page, { html: delayedHtml });
216+
await page.goto("/");
217+
await captureClipboardHtml(page);
218+
219+
const clickStartedAt = await page.evaluate(() => performance.now());
220+
await page.getByRole("button", { name: /export/i }).click();
221+
await page.getByTestId("export-menu").getByRole("button", { name: /WeChat/ }).click();
222+
223+
await expect.poll(() =>
224+
page.evaluate(() => (window as typeof window & { __wechatClipboardHtml?: string }).__wechatClipboardHtml ?? ""),
225+
{ timeout: 12_000 },
226+
).toContain("Delayed clipboard content");
227+
const writeStartedAt = await page.evaluate(() =>
228+
(window as typeof window & { __clipboardWriteStartedAt?: number }).__clipboardWriteStartedAt ?? Infinity,
229+
);
230+
expect(writeStartedAt - clickStartedAt).toBeLessThan(1000);
231+
});
232+
189233
test("exports a Hyperframes Remotion project zip from the UI", async ({ page }) => {
190234
await seedStore(page, { html: hyperframesHtml });
191235

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,18 @@ describe("toWechatHtmlFromDocument", () => {
187187
}
188188
});
189189

190+
it("omits computed-hidden speaker notes", () => {
191+
document.head.innerHTML = `<style>.notes { display: none !important; }</style>`;
192+
document.body.innerHTML = `
193+
<section class="slide"><h1>Visible slide</h1><aside class="notes">SECRET NOTES</aside></section>
194+
`;
195+
196+
const exported = toWechatHtmlFromDocument(document);
197+
expect(exported).toContain("Visible slide");
198+
expect(exported).not.toContain("SECRET NOTES");
199+
expect(exported).not.toContain("class=\"notes\"");
200+
});
201+
190202
it("clamps oversized spacing and drops negative margins", () => {
191203
document.head.innerHTML = `
192204
<style>

next/src/lib/export/clipboard.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,33 @@ export async function copyHtml(html: string, plain?: string): Promise<void> {
2626
await copySafari(html, fallback);
2727
}
2828

29+
/**
30+
* Start the ClipboardItem write during the user gesture while allowing its
31+
* Blob payloads to resolve after a fresh render has settled. Chromium keeps
32+
* the activation associated with this write, unlike awaiting the render first.
33+
*/
34+
export async function copyHtmlWhenReady(htmlPromise: Promise<string>): Promise<void> {
35+
if (typeof window === "undefined") throw new Error("server-side");
36+
const fallbackPromise = htmlPromise.then(stripTags);
37+
38+
if (navigator.clipboard && typeof window.ClipboardItem !== "undefined") {
39+
try {
40+
await navigator.clipboard.write([
41+
new ClipboardItem({
42+
"text/html": htmlPromise.then((html) => new Blob([html], { type: "text/html" })),
43+
"text/plain": fallbackPromise.then((plain) => new Blob([plain], { type: "text/plain" })),
44+
}),
45+
]);
46+
return;
47+
} catch {
48+
// fall through after the render promise settles
49+
}
50+
}
51+
52+
const html = await htmlPromise;
53+
await copySafari(html, await fallbackPromise);
54+
}
55+
2956
export async function copyImage(blob: Blob): Promise<void> {
3057
if (!navigator.clipboard || typeof window.ClipboardItem === "undefined") {
3158
throw new Error("Image clipboard not supported in this browser");

next/src/lib/export/wechat.ts

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

33
import juice from "juice";
4-
import { copyHtml } from "./clipboard";
4+
import { copyHtmlWhenReady } from "./clipboard";
55

66
/**
77
* Clamp `margin` / `padding` to 48px. Poster- and deck-scale templates
@@ -89,6 +89,10 @@ export function toWechatHtmlFromDocument(renderedDoc: Document): string {
8989
const view = renderedDoc.defaultView ?? window;
9090
const wrap = document.createElement("div");
9191
for (const child of Array.from(body.childNodes)) {
92+
if (child.nodeType === Node.ELEMENT_NODE) {
93+
const element = child as Element;
94+
if (isComputedHidden(element, view)) continue;
95+
}
9296
const clone = child.cloneNode(true);
9397
if (child.nodeType === Node.ELEMENT_NODE && clone.nodeType === Node.ELEMENT_NODE) {
9498
inlineComputedTree(child as Element, clone as Element, view);
@@ -116,7 +120,7 @@ export async function renderToWechatHtml(fullHtml: string): Promise<string> {
116120
const iframe = document.createElement("iframe");
117121
iframe.setAttribute("sandbox", "allow-scripts allow-same-origin");
118122
iframe.style.cssText =
119-
"position: fixed; left: -100000px; top: 0; width: 1280px; height: 960px; border: 0; visibility: hidden;";
123+
"position: fixed; left: 0; top: 0; width: 1280px; height: 960px; border: 0; opacity: 0; pointer-events: none; z-index: -2147483648;";
120124
iframe.srcdoc = fullHtml;
121125
document.body.appendChild(iframe);
122126

@@ -134,7 +138,7 @@ export async function renderToWechatHtml(fullHtml: string): Promise<string> {
134138
}
135139

136140
export async function copyToWechat(fullHtml: string): Promise<void> {
137-
await copyHtml(await renderToWechatHtml(fullHtml));
141+
await copyHtmlWhenReady(renderToWechatHtml(fullHtml));
138142
}
139143

140144
function waitForIframeLoad(iframe: HTMLIFrameElement): Promise<void> {
@@ -160,10 +164,33 @@ async function waitForDocumentReady(doc: Document): Promise<void> {
160164
} catch {
161165
// Font loading is best effort; computed styles are still useful without it.
162166
}
163-
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
167+
168+
const view = doc.defaultView ?? window;
169+
await nextFrame(view);
170+
await nextFrame(view);
171+
await new Promise<void>((resolve) => view.setTimeout(resolve, 50));
172+
173+
for (const animation of doc.getAnimations?.() ?? []) {
174+
try {
175+
const timing = animation.effect?.getComputedTiming();
176+
if (Number.isFinite(Number(timing?.endTime))) animation.finish();
177+
animation.pause();
178+
} catch {
179+
// Infinite or script-controlled animations are sampled at their current state.
180+
}
181+
}
182+
await nextFrame(view);
183+
}
184+
185+
function nextFrame(view: Window): Promise<void> {
186+
return new Promise((resolve) => view.requestAnimationFrame(() => resolve()));
164187
}
165188

166189
function inlineComputedTree(source: Element, clone: Element, view: Window): void {
190+
if (isComputedHidden(source, view)) {
191+
clone.remove();
192+
return;
193+
}
167194
const styleText = computedStyleText(source, view);
168195
if (styleText) clone.setAttribute("style", styleText);
169196
materializePseudos(source, clone, view);
@@ -181,6 +208,14 @@ function inlineComputedTree(source: Element, clone: Element, view: Window): void
181208
}
182209
}
183210

211+
function isComputedHidden(element: Element, view: Window): boolean {
212+
try {
213+
return view.getComputedStyle(element).display === "none";
214+
} catch {
215+
return false;
216+
}
217+
}
218+
184219
/**
185220
* WeChat strips pseudo-elements entirely, and our computed-style walk only sees
186221
* real DOM nodes. Read ::before/::after from getComputedStyle and turn each into

0 commit comments

Comments
 (0)