Skip to content

Commit 37813b2

Browse files
author
rick
committed
feat(export): inline computed iframe styles for WeChat paste
The juice-only path missed class-driven styles (Tailwind via CDN generates them at runtime in the preview iframe, where juice can't reach). New `toWechatHtmlFromDocument(renderedDoc)` walks `getComputedStyle` on the live DOM and inlines a 24-prop visual whitelist — color, font-*, line/letter spacing, background-*, border, border-radius, box-shadow, text-shadow, opacity, list-style, … Deliberately drops layout props (position / display / flex-* / grid-* / width / height / gap). Poster-scale grids would collapse in WeChat's ~375-540px article column anyway — letting the column reflow content as a single stream is the right behaviour. - Clamp margin/padding to 48px (≈8px baseline × 6 lines, comfortable mobile reading max). Negative margins dropped. - Border sides with computed style `none` are skipped to keep the inline blob short. - `getComputedStyle` wrapped in try/catch for cross-origin / detached node safety. - `copyToWechat(html, renderedDoc?)` falls back to the legacy juice path when no renderedDoc is passed. 3 vitest cases (wechat.test.ts) cover: inline of visual props, drop of layout props, and spacing clamp + negative-margin drop.
1 parent 145a40e commit 37813b2

3 files changed

Lines changed: 287 additions & 11 deletions

File tree

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()); }) },
79+
{ id: "wechat", label: t("export.action.wechat"), emoji: "💬", fn: wrap(t("export.toast.wechat"), async () => { await copyToWechat(cleanHtml(), iframeRef.current?.contentDocument); }) },
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);
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { describe, it, expect, afterEach } from "vitest";
2+
import { toWechatHtmlFromDocument } from "../wechat";
3+
4+
function parseFragment(html: string): HTMLBodyElement {
5+
return new DOMParser().parseFromString(`<body>${html}</body>`, "text/html")
6+
.body as HTMLBodyElement;
7+
}
8+
9+
describe("toWechatHtmlFromDocument", () => {
10+
afterEach(() => {
11+
document.head.innerHTML = "";
12+
document.body.innerHTML = "";
13+
});
14+
15+
it("inlines computed styles from the rendered preview DOM", () => {
16+
document.head.innerHTML = `
17+
<style>
18+
.card {
19+
background: rgb(12, 34, 56);
20+
border-radius: 18px;
21+
padding: 24px;
22+
}
23+
.title {
24+
color: rgb(210, 55, 44);
25+
font-size: 32px;
26+
font-weight: 800;
27+
line-height: 1.25;
28+
}
29+
</style>
30+
`;
31+
document.body.innerHTML = `
32+
<article class="card">
33+
<h1 class="title">Styled headline</h1>
34+
</article>
35+
`;
36+
37+
const body = parseFragment(toWechatHtmlFromDocument(document));
38+
const section = body.querySelector("section");
39+
const card = body.querySelector("article");
40+
const title = body.querySelector("h1");
41+
42+
expect(section?.getAttribute("data-tool")).toBe("html-anything");
43+
expect(card?.getAttribute("data-tool")).toBe("html-anything");
44+
expect(card?.getAttribute("style")).toContain("background-color: rgb(12, 34, 56)");
45+
expect(card?.getAttribute("style")).toContain("border-radius: 18px");
46+
expect(card?.getAttribute("style")).toContain("padding: 24px");
47+
expect(title?.getAttribute("style")).toContain("color: rgb(210, 55, 44)");
48+
expect(title?.getAttribute("style")).toContain("font-size: 32px");
49+
expect(title?.getAttribute("style")).toContain("font-weight: 800");
50+
});
51+
52+
it("drops fragile page-layout styles so WeChat paste stays in article flow", () => {
53+
document.head.innerHTML = `
54+
<style>
55+
.layout {
56+
position: absolute;
57+
display: grid;
58+
grid-template-columns: 320px 1fr;
59+
width: 1280px;
60+
height: 720px;
61+
gap: 48px;
62+
background: rgb(250, 248, 240);
63+
padding: 40px;
64+
}
65+
.panel {
66+
display: flex;
67+
min-height: 360px;
68+
color: rgb(25, 28, 32);
69+
border: 2px solid rgb(80, 90, 100);
70+
}
71+
</style>
72+
`;
73+
document.body.innerHTML = `
74+
<section class="layout">
75+
<p class="panel">First paragraph</p>
76+
<p class="panel">Second paragraph</p>
77+
</section>
78+
`;
79+
80+
const html = toWechatHtmlFromDocument(document);
81+
const body = parseFragment(html);
82+
const layoutStyle = body.querySelector(".layout")?.getAttribute("style") ?? "";
83+
const panelStyle = body.querySelector(".panel")?.getAttribute("style") ?? "";
84+
85+
expect(layoutStyle).toContain("background-color: rgb(250, 248, 240)");
86+
expect(layoutStyle).toContain("padding: 40px");
87+
expect(panelStyle).toContain("color: rgb(25, 28, 32)");
88+
expect(panelStyle).toContain("border-top: 2px solid rgb(80, 90, 100)");
89+
expect(`${layoutStyle}; ${panelStyle}`).not.toMatch(
90+
/(?:^|;\s*)(position|display|grid-template-columns|width|height|min-height|gap|flex-direction|flex-wrap|flex):/,
91+
);
92+
});
93+
94+
it("clamps oversized spacing and drops negative margins", () => {
95+
document.head.innerHTML = `
96+
<style>
97+
.loose {
98+
margin-top: -24px;
99+
margin-bottom: 180px;
100+
padding: 96px;
101+
color: rgb(40, 40, 40);
102+
}
103+
</style>
104+
`;
105+
document.body.innerHTML = `<p class="loose">Too much spacing</p>`;
106+
107+
const body = parseFragment(toWechatHtmlFromDocument(document));
108+
const style = body.querySelector("p")?.getAttribute("style") ?? "";
109+
110+
expect(style).toContain("margin-bottom: 48px");
111+
expect(style).toContain("padding: 48px");
112+
expect(style).toContain("color: rgb(40, 40, 40)");
113+
expect(style).not.toContain("-24px");
114+
expect(style).not.toContain("180px");
115+
expect(style).not.toContain("96px");
116+
});
117+
});

next/src/lib/export/wechat.ts

Lines changed: 169 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,41 @@
33
import juice from "juice";
44
import { copyHtml } from "./clipboard";
55

6+
/**
7+
* Clamp `margin` / `padding` to 48px. Poster- and deck-scale templates
8+
* routinely use 80-120px gutters that read as luxurious on a 1080 canvas
9+
* but blow up in WeChat's ~375-540px article flow. 48 ≈ 8px baseline × 6
10+
* lines — the comfortable max for mobile reading.
11+
*/
12+
const MAX_FLOW_SPACING_PX = 48;
13+
14+
const STYLE_PROPS = [
15+
"color",
16+
"background-color",
17+
"background-image",
18+
"background-position",
19+
"background-size",
20+
"background-repeat",
21+
"font-family",
22+
"font-size",
23+
"font-style",
24+
"font-weight",
25+
"line-height",
26+
"letter-spacing",
27+
"text-align",
28+
"text-decoration",
29+
"text-transform",
30+
"white-space",
31+
"word-break",
32+
"overflow-wrap",
33+
"list-style-type",
34+
"list-style-position",
35+
"border-collapse",
36+
"box-shadow",
37+
"text-shadow",
38+
"opacity",
39+
] as const;
40+
641
/**
742
* Take a full HTML document, extract <body> content, inline all CSS via juice,
843
* and tag top-level children with data-tool="html-anything" so WeChat trusts the styles.
@@ -13,21 +48,15 @@ export function toWechatHtml(fullHtml: string): string {
1348

1449
const doc = new DOMParser().parseFromString(fullHtml, "text/html");
1550

16-
// Collect all <style> contents + linked stylesheets we cannot follow
51+
// Collect all <style> contents + linked stylesheets we cannot follow.
1752
const styles: string[] = [];
1853
doc.querySelectorAll("style").forEach((s) => {
1954
styles.push(s.textContent ?? "");
2055
});
2156

22-
// Tailwind via CDN won't be accessible to juice — but the runtime DOM in our
23-
// preview iframe has *generated* inline styles via `getComputedStyle`. Rather
24-
// than trying to scrape them, we let users render the fragment in a hidden
25-
// iframe, walk computed styles, and inline them. Here we do the simple
26-
// <style>-based inlining plus a fallback marker.
2757
const css = styles.join("\n");
2858
const bodyHtml = doc.body?.innerHTML ?? fullHtml;
2959

30-
// Tag top-level children
3160
const wrap = document.createElement("div");
3261
wrap.innerHTML = bodyHtml;
3362
Array.from(wrap.children).forEach((child) => {
@@ -46,11 +75,141 @@ export function toWechatHtml(fullHtml: string): string {
4675
inlined = tagged;
4776
}
4877

49-
// Wrap in a section element so WeChat treats it as a content block
5078
return `<section data-tool="html-anything">${inlined}</section>`;
5179
}
5280

53-
export async function copyToWechat(fullHtml: string): Promise<void> {
54-
const html = toWechatHtml(fullHtml);
81+
/**
82+
* Export the rendered preview DOM. This preserves class/CDN/runtime styles by
83+
* reading computed styles from the browser before writing clipboard HTML.
84+
*/
85+
export function toWechatHtmlFromDocument(renderedDoc: Document): string {
86+
const body = renderedDoc.body;
87+
if (!body) return "";
88+
89+
const view = renderedDoc.defaultView ?? window;
90+
const wrap = document.createElement("div");
91+
for (const child of Array.from(body.childNodes)) {
92+
const clone = child.cloneNode(true);
93+
if (child.nodeType === Node.ELEMENT_NODE && clone.nodeType === Node.ELEMENT_NODE) {
94+
inlineComputedTree(child as Element, clone as Element, view);
95+
(clone as Element).setAttribute("data-tool", "html-anything");
96+
}
97+
wrap.appendChild(clone);
98+
}
99+
100+
const section = document.createElement("section");
101+
section.setAttribute("data-tool", "html-anything");
102+
const bodyStyle = computedStyleText(body, view, { skipMargin: true });
103+
if (bodyStyle) section.setAttribute("style", bodyStyle);
104+
section.innerHTML = wrap.innerHTML;
105+
return section.outerHTML;
106+
}
107+
108+
export async function copyToWechat(fullHtml: string, renderedDoc?: Document | null): Promise<void> {
109+
const html = renderedDoc?.body ? toWechatHtmlFromDocument(renderedDoc) : toWechatHtml(fullHtml);
55110
await copyHtml(html);
56111
}
112+
113+
function inlineComputedTree(source: Element, clone: Element, view: Window): void {
114+
const styleText = computedStyleText(source, view);
115+
if (styleText) clone.setAttribute("style", styleText);
116+
117+
const sourceEls = Array.from(source.querySelectorAll("*"));
118+
const cloneEls = Array.from(clone.querySelectorAll("*"));
119+
for (let i = 0; i < sourceEls.length; i++) {
120+
const cloneEl = cloneEls[i];
121+
if (!cloneEl) continue;
122+
const childStyle = computedStyleText(sourceEls[i], view);
123+
if (childStyle) cloneEl.setAttribute("style", childStyle);
124+
}
125+
}
126+
127+
function computedStyleText(el: Element, view: Window, opts?: { skipMargin?: boolean }): string {
128+
let computed: CSSStyleDeclaration;
129+
try {
130+
computed = view.getComputedStyle(el);
131+
} catch {
132+
// Cross-origin frames or detached nodes throw here. Skip silently rather than abort the export.
133+
return "";
134+
}
135+
const styles: string[] = [];
136+
137+
addBox(styles, computed, "margin", opts?.skipMargin);
138+
addBox(styles, computed, "padding");
139+
addBorder(styles, computed);
140+
addBox(styles, computed, "border-radius");
141+
142+
for (const prop of STYLE_PROPS) {
143+
addProp(styles, computed, prop);
144+
}
145+
146+
return styles.join("; ");
147+
}
148+
149+
function addProp(styles: string[], computed: CSSStyleDeclaration, prop: string): void {
150+
const value = computed.getPropertyValue(prop).trim();
151+
if (!shouldKeep(prop, value)) return;
152+
styles.push(`${prop}: ${value}`);
153+
}
154+
155+
function addBox(
156+
styles: string[],
157+
computed: CSSStyleDeclaration,
158+
prefix: "margin" | "padding" | "border-radius",
159+
skip = false,
160+
): void {
161+
if (skip) return;
162+
const keys =
163+
prefix === "border-radius"
164+
? ["top-left", "top-right", "bottom-right", "bottom-left"].map((x) => `border-${x}-radius`)
165+
: ["top", "right", "bottom", "left"].map((x) => `${prefix}-${x}`);
166+
const values = keys.map((key) => normalizeBoxValue(prefix, computed.getPropertyValue(key).trim()));
167+
if (values.every((value) => !shouldKeep(prefix, value))) return;
168+
if (values.every((value) => value === values[0])) {
169+
styles.push(`${prefix}: ${values[0]}`);
170+
return;
171+
}
172+
keys.forEach((key, idx) => {
173+
if (shouldKeep(prefix, values[idx])) styles.push(`${key}: ${values[idx]}`);
174+
});
175+
}
176+
177+
function addBorder(styles: string[], computed: CSSStyleDeclaration): void {
178+
for (const side of ["top", "right", "bottom", "left"]) {
179+
const width = computed.getPropertyValue(`border-${side}-width`).trim();
180+
const style = computed.getPropertyValue(`border-${side}-style`).trim();
181+
const color = computed.getPropertyValue(`border-${side}-color`).trim();
182+
if (!shouldKeep("border-width", width) || style === "none" || !style) continue;
183+
styles.push(`border-${side}: ${width} ${style} ${color}`);
184+
}
185+
}
186+
187+
function shouldKeep(prop: string, value: string): boolean {
188+
if (!value) return false;
189+
if (value === "initial" || value === "inherit" || value === "unset") return false;
190+
if (prop.includes("color") && (value === "rgba(0, 0, 0, 0)" || value === "transparent")) return false;
191+
if (prop === "opacity" && (value === "1" || value === "1.0")) return false;
192+
if (prop.includes("shadow") && value === "none") return false;
193+
if (prop.includes("image") && value === "none") return false;
194+
if (prop.includes("radius") && isZero(value)) return false;
195+
if ((prop.includes("width") || prop.includes("height")) && value === "auto") return false;
196+
if ((prop === "margin" || prop === "padding") && isZero(value)) return false;
197+
if (prop.startsWith("border") && isZero(value)) return false;
198+
return true;
199+
}
200+
201+
function normalizeBoxValue(prefix: "margin" | "padding" | "border-radius", value: string): string {
202+
if (prefix === "margin" && value.startsWith("-")) return "";
203+
if (prefix !== "margin" && prefix !== "padding") return value;
204+
205+
const px = value.match(/^(-?\d+(?:\.\d+)?)px$/);
206+
if (!px) return value;
207+
208+
const n = Number(px[1]);
209+
if (n > MAX_FLOW_SPACING_PX) return `${MAX_FLOW_SPACING_PX}px`;
210+
return value;
211+
}
212+
213+
function isZero(value: string): boolean {
214+
return /^0(?:px|em|rem|%)?(?:\s+0(?:px|em|rem|%)?){0,3}$/.test(value);
215+
}

0 commit comments

Comments
 (0)