@zzclub/pipeline 0.13.2 → 0.16.0
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/ARTICLE-THEMES.md +86 -0
- package/LICENSE +21 -0
- package/README.md +182 -788
- package/THIRD-PARTY-NOTICES.md +25 -0
- package/package.json +6 -2
- package/src/adapter-types.ts +3 -0
- package/src/adapters/builtin-markdown-renderer.ts +1 -0
- package/src/article-theme.test.ts +87 -0
- package/src/article-theme.ts +115 -0
- package/src/commands/abandon.test.ts +1 -1
- package/src/commands/article-theme.ts +16 -0
- package/src/commands/checkpoint.test.ts +2 -2
- package/src/commands/ingest-handoff.ts +6 -0
- package/src/commands/init.ts +10 -0
- package/src/commands/prepare.ts +5 -1
- package/src/commands/publish.ts +4 -0
- package/src/commands/reset.ts +2 -0
- package/src/commands/wechat-export.ts +4 -0
- package/src/commands/wechat-preview.ts +35 -0
- package/src/plugins.ts +2 -0
- package/src/providers/index.ts +17 -2
- package/src/providers/publish-core.ts +32 -1
- package/src/providers/wechat.test.ts +12 -0
- package/src/providers/wechat.ts +60 -39
- package/src/render-branding.ts +1 -1
- package/src/schema/config.ts +12 -0
- package/src/schema/state.ts +5 -0
- package/src/wechat-preview/assets/browser-dist/.vite/manifest.json +21 -1
- package/src/wechat-preview/assets/browser-dist/assets/style-C9-YtTIm.css +1 -0
- package/src/wechat-preview/assets/browser-dist/assets/wechat-renderer-D1jHoaM2.js +624 -0
- package/src/wechat-preview/assets/browser-dist/editor-export.js +1 -512
- package/src/wechat-preview/assets/browser-dist/src/wechat-preview/browser/studio/studio.html +293 -0
- package/src/wechat-preview/assets/browser-dist/studio.js +332 -0
- package/src/wechat-preview/browser/editor-export.ts +17 -2
- package/src/wechat-preview/browser/highlight.ts +2 -0
- package/src/wechat-preview/browser/studio/color-palettes.test.ts +31 -0
- package/src/wechat-preview/browser/studio/color-palettes.ts +115 -0
- package/src/wechat-preview/browser/studio/studio.css +638 -0
- package/src/wechat-preview/browser/studio/studio.html +292 -0
- package/src/wechat-preview/browser/studio/studio.ts +721 -0
- package/src/wechat-preview/browser/studio/syntax-presets.test.ts +74 -0
- package/src/wechat-preview/browser/studio/syntax-presets.ts +590 -0
- package/src/wechat-preview/fixtures/all-nodes.md +38 -0
- package/src/wechat-preview/index.ts +36 -5
- package/src/wechat-preview/plugins/builtin-badge.ts +152 -0
- package/src/wechat-preview/plugins/builtin-callout.ts +261 -0
- package/src/wechat-preview/plugins/builtin-highlight.ts +46 -0
- package/src/wechat-preview/plugins/builtin-kbd.ts +133 -0
- package/src/wechat-preview/plugins/index.test.ts +313 -0
- package/src/wechat-preview/plugins/index.ts +6 -0
- package/src/wechat-preview/plugins/registry.ts +62 -0
- package/src/wechat-preview/plugins/types.ts +31 -0
- package/src/wechat-preview/server/http.ts +160 -3
- package/src/wechat-preview/server/paths.ts +2 -2
- package/src/wechat-preview/server/singleton.ts +14 -12
- package/src/wechat-preview/server/studio.test.ts +92 -0
- package/src/wechat-preview/server/ui.ts +1 -0
- package/src/wechat-preview/wechat-export.integration.test.ts +9 -1
- package/src/wechat-preview/wechat-renderer.ts +72 -7
- package/src/workflow.test.ts +1 -0
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
import { Editor, defaultValueCtx, rootCtx } from "@milkdown/kit/core";
|
|
2
|
+
import { commonmark } from "@milkdown/kit/preset/commonmark";
|
|
3
|
+
import { gfm } from "@milkdown/kit/preset/gfm";
|
|
4
|
+
import { getHTML } from "@milkdown/kit/utils";
|
|
5
|
+
import articleCss from "@zzclub/milkdown-article-style/article.css?raw";
|
|
6
|
+
import { getCombinedMilkdownPlugins } from "../../plugins/registry";
|
|
7
|
+
import { renderWechatHtml } from "../../wechat-renderer";
|
|
8
|
+
import type { WechatPluginDoc } from "../../plugins/types";
|
|
9
|
+
import type { ArticleStructure } from "../../wechat-renderer";
|
|
10
|
+
import type { WechatExportTheme } from "../../themes";
|
|
11
|
+
import { SYNTAX_CATEGORIES, buildCombinedPresetsCss } from "./syntax-presets";
|
|
12
|
+
import { COLOR_PALETTES } from "./color-palettes";
|
|
13
|
+
|
|
14
|
+
interface AccountData {
|
|
15
|
+
name: string;
|
|
16
|
+
customCssPath: string | null;
|
|
17
|
+
customCssContent: string;
|
|
18
|
+
theme: {
|
|
19
|
+
editorVars: Record<string, string>;
|
|
20
|
+
exportTheme: Partial<WechatExportTheme>;
|
|
21
|
+
syntaxPresets?: Record<string, string>;
|
|
22
|
+
structure?: {
|
|
23
|
+
numberedHeadings?: boolean;
|
|
24
|
+
headingLabel?: string;
|
|
25
|
+
quoteLabel?: string;
|
|
26
|
+
calloutPresets?: Record<string, { title?: string; icon?: string }>;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
articleTheme?: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface StudioConfigResponse {
|
|
33
|
+
defaultAccount: string;
|
|
34
|
+
accounts: Record<string, AccountData>;
|
|
35
|
+
plugins: WechatPluginDoc[];
|
|
36
|
+
sampleMarkdown: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let accounts: Record<string, AccountData> = {};
|
|
40
|
+
let currentAccount = "default";
|
|
41
|
+
let pluginsList: WechatPluginDoc[] = [];
|
|
42
|
+
let latestWechatHtml = "";
|
|
43
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
44
|
+
let showingHtmlSource = false;
|
|
45
|
+
let currentSyntaxPresets: Record<string, string> = {};
|
|
46
|
+
|
|
47
|
+
// DOM Elements
|
|
48
|
+
const accountSelect = document.getElementById("account-select") as HTMLSelectElement;
|
|
49
|
+
const editorMarkdown = document.getElementById("editor-markdown") as HTMLTextAreaElement;
|
|
50
|
+
const previewScreen = document.getElementById("preview-screen") as HTMLElement;
|
|
51
|
+
const editorStats = document.getElementById("editor-stats") as HTMLElement;
|
|
52
|
+
const htmlSourceContainer = document.getElementById("html-source-container") as HTMLElement;
|
|
53
|
+
const htmlSourceCode = document.getElementById("html-source-code") as HTMLTextAreaElement;
|
|
54
|
+
const toastContainer = document.getElementById("toast-container") as HTMLElement;
|
|
55
|
+
const hiddenHost = document.getElementById("studio-editor-host") as HTMLElement;
|
|
56
|
+
|
|
57
|
+
// Controls
|
|
58
|
+
const inputFontSize = document.getElementById("input-font-size") as HTMLInputElement;
|
|
59
|
+
const valFontSize = document.getElementById("val-font-size") as HTMLElement;
|
|
60
|
+
const inputLineHeight = document.getElementById("input-line-height") as HTMLInputElement;
|
|
61
|
+
const valLineHeight = document.getElementById("val-line-height") as HTMLElement;
|
|
62
|
+
const inputLetterSpacing = document.getElementById("input-letter-spacing") as HTMLInputElement;
|
|
63
|
+
const valLetterSpacing = document.getElementById("val-letter-spacing") as HTMLElement;
|
|
64
|
+
const inputParaSpacing = document.getElementById("input-para-spacing") as HTMLInputElement;
|
|
65
|
+
const valParaSpacing = document.getElementById("val-para-spacing") as HTMLElement;
|
|
66
|
+
|
|
67
|
+
const paletteSelect = document.getElementById("palette-select") as HTMLSelectElement;
|
|
68
|
+
const paletteDesc = document.getElementById("palette-desc") as HTMLElement;
|
|
69
|
+
|
|
70
|
+
const pickerBrandColor = document.getElementById("picker-brand-color") as HTMLInputElement;
|
|
71
|
+
const hexBrandColor = document.getElementById("hex-brand-color") as HTMLInputElement;
|
|
72
|
+
const pickerTextColor = document.getElementById("picker-text-color") as HTMLInputElement;
|
|
73
|
+
const hexTextColor = document.getElementById("hex-text-color") as HTMLInputElement;
|
|
74
|
+
const pickerH2Color = document.getElementById("picker-h2-color") as HTMLInputElement;
|
|
75
|
+
const hexH2Color = document.getElementById("hex-h2-color") as HTMLInputElement;
|
|
76
|
+
const pickerH3Color = document.getElementById("picker-h3-color") as HTMLInputElement;
|
|
77
|
+
const hexH3Color = document.getElementById("hex-h3-color") as HTMLInputElement;
|
|
78
|
+
const pickerQuoteColor = document.getElementById("picker-quote-color") as HTMLInputElement;
|
|
79
|
+
const hexQuoteColor = document.getElementById("hex-quote-color") as HTMLInputElement;
|
|
80
|
+
const pickerDividerColor = document.getElementById("picker-divider-color") as HTMLInputElement;
|
|
81
|
+
const hexDividerColor = document.getElementById("hex-divider-color") as HTMLInputElement;
|
|
82
|
+
|
|
83
|
+
const switchNumberedHeadings = document.getElementById("switch-numbered-headings") as HTMLInputElement;
|
|
84
|
+
const inputHeadingLabel = document.getElementById("input-heading-label") as HTMLInputElement;
|
|
85
|
+
const inputQuoteLabel = document.getElementById("input-quote-label") as HTMLInputElement;
|
|
86
|
+
const inputCustomCss = document.getElementById("input-custom-css") as HTMLTextAreaElement;
|
|
87
|
+
|
|
88
|
+
function showToast(message: string, type: "info" | "success" | "error" = "info") {
|
|
89
|
+
const toast = document.createElement("div");
|
|
90
|
+
toast.className = `toast ${type === "success" ? "toast-success" : ""}`;
|
|
91
|
+
toast.textContent = message;
|
|
92
|
+
toastContainer.appendChild(toast);
|
|
93
|
+
requestAnimationFrame(() => toast.classList.add("show"));
|
|
94
|
+
setTimeout(() => {
|
|
95
|
+
toast.classList.remove("show");
|
|
96
|
+
setTimeout(() => toast.remove(), 300);
|
|
97
|
+
}, 3000);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function bindColorPair(picker: HTMLInputElement, hex: HTMLInputElement, onChange: () => void) {
|
|
101
|
+
picker.addEventListener("input", () => {
|
|
102
|
+
hex.value = picker.value;
|
|
103
|
+
onChange();
|
|
104
|
+
});
|
|
105
|
+
hex.addEventListener("input", () => {
|
|
106
|
+
if (/^#[0-9a-f]{6}$/i.test(hex.value)) {
|
|
107
|
+
picker.value = hex.value;
|
|
108
|
+
onChange();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function updateStats() {
|
|
114
|
+
const text = editorMarkdown.value;
|
|
115
|
+
const chars = text.replace(/\s+/g, "").length;
|
|
116
|
+
const minutes = Math.max(1, Math.round(chars / 400));
|
|
117
|
+
editorStats.textContent = `字数: ${chars.toLocaleString()} 字 | 预计阅读: ${minutes} 分钟`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function doRender() {
|
|
121
|
+
const markdown = editorMarkdown.value;
|
|
122
|
+
updateStats();
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
// 1. Run Milkdown in hidden host to obtain semantic HTML
|
|
126
|
+
hiddenHost.innerHTML = "";
|
|
127
|
+
const editor = Editor.make()
|
|
128
|
+
.config((ctx) => {
|
|
129
|
+
ctx.set(rootCtx, hiddenHost);
|
|
130
|
+
ctx.set(defaultValueCtx, markdown);
|
|
131
|
+
})
|
|
132
|
+
.use(commonmark)
|
|
133
|
+
.use(gfm)
|
|
134
|
+
.use(getCombinedMilkdownPlugins());
|
|
135
|
+
|
|
136
|
+
await editor.create();
|
|
137
|
+
const rawSemanticHtml = editor.action(getHTML());
|
|
138
|
+
await editor.destroy();
|
|
139
|
+
|
|
140
|
+
// 2. Build parameters from controls
|
|
141
|
+
const fontSize = Number(inputFontSize.value) || 16;
|
|
142
|
+
const lineHeight = inputLineHeight.value || "1.84";
|
|
143
|
+
const letterSpacing = `${inputLetterSpacing.value}em`;
|
|
144
|
+
const paraSpacing = `${inputParaSpacing.value}em`;
|
|
145
|
+
|
|
146
|
+
const brandColor = hexBrandColor.value || "#ca6093";
|
|
147
|
+
const textColor = hexTextColor.value || "#292526";
|
|
148
|
+
const h2Color = hexH2Color.value || "#1f1b1c";
|
|
149
|
+
const h3Color = hexH3Color.value || "#5c5658";
|
|
150
|
+
const quoteColor = hexQuoteColor.value || "#ca6093";
|
|
151
|
+
const dividerColor = hexDividerColor.value || "#dadce0";
|
|
152
|
+
|
|
153
|
+
const baseExportTheme = accounts[currentAccount]?.theme?.exportTheme || {};
|
|
154
|
+
const baseEditorVars = accounts[currentAccount]?.theme?.editorVars || {};
|
|
155
|
+
|
|
156
|
+
const containerStyle = [
|
|
157
|
+
"max-width: 100%",
|
|
158
|
+
"margin: 0",
|
|
159
|
+
"padding: 10px 4px 30px",
|
|
160
|
+
"background: #ffffff",
|
|
161
|
+
"box-sizing: border-box",
|
|
162
|
+
"font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif",
|
|
163
|
+
`font-size: ${fontSize}px`,
|
|
164
|
+
`color: ${textColor}`,
|
|
165
|
+
"overflow-wrap: break-word",
|
|
166
|
+
"word-break: break-word",
|
|
167
|
+
`line-height: ${lineHeight}`,
|
|
168
|
+
`letter-spacing: ${letterSpacing}`,
|
|
169
|
+
"-webkit-text-size-adjust: 100%",
|
|
170
|
+
].join("; ");
|
|
171
|
+
|
|
172
|
+
const exportTheme: WechatExportTheme = {
|
|
173
|
+
...baseExportTheme,
|
|
174
|
+
containerStyle,
|
|
175
|
+
footerText: baseExportTheme.footerText ?? "",
|
|
176
|
+
footerStyle: baseExportTheme.footerStyle ?? "margin-top: 32px; text-align: center; font-size: 12px; color: #6f696b;",
|
|
177
|
+
fontFamily: baseExportTheme.fontFamily || "-apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif",
|
|
178
|
+
bodyColor: textColor,
|
|
179
|
+
mutedColor: baseExportTheme.mutedColor || "#5f6368",
|
|
180
|
+
h2Color,
|
|
181
|
+
h3Color,
|
|
182
|
+
primaryColor: brandColor,
|
|
183
|
+
dividerColor,
|
|
184
|
+
blockquoteBorderColor: quoteColor,
|
|
185
|
+
bodyLineHeight: lineHeight,
|
|
186
|
+
bodyLetterSpacing: letterSpacing,
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const editorVars: Record<string, string> = {
|
|
190
|
+
...baseEditorVars,
|
|
191
|
+
"--primary": brandColor,
|
|
192
|
+
"--brand": brandColor,
|
|
193
|
+
"--brand-soft": baseEditorVars["--brand-soft"] || brandColor,
|
|
194
|
+
"--brand-bg": baseEditorVars["--brand-bg"] || "rgba(202, 96, 147, 0.08)",
|
|
195
|
+
"--text": textColor,
|
|
196
|
+
"--divider": dividerColor,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const structure: ArticleStructure = {
|
|
200
|
+
numberedHeadings: switchNumberedHeadings.checked,
|
|
201
|
+
headingLabel: inputHeadingLabel.value.trim() || undefined,
|
|
202
|
+
quoteLabel: inputQuoteLabel.value.trim() || undefined,
|
|
203
|
+
calloutPresets: accounts[currentAccount]?.theme?.structure?.calloutPresets,
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const combinedPresetsCss = buildCombinedPresetsCss(currentSyntaxPresets);
|
|
207
|
+
|
|
208
|
+
const customCssWithPara = [
|
|
209
|
+
combinedPresetsCss,
|
|
210
|
+
inputCustomCss.value,
|
|
211
|
+
`.milkdown .editor p { margin: 0 0 ${paraSpacing}; }`,
|
|
212
|
+
].filter(Boolean).join("\n\n");
|
|
213
|
+
|
|
214
|
+
// 3. Render inlined WeChat HTML
|
|
215
|
+
const inlinedHtml = await renderWechatHtml({
|
|
216
|
+
semanticHtml: rawSemanticHtml,
|
|
217
|
+
baseCss: articleCss,
|
|
218
|
+
customCss: customCssWithPara,
|
|
219
|
+
structure,
|
|
220
|
+
editorVars,
|
|
221
|
+
theme: exportTheme,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
latestWechatHtml = inlinedHtml;
|
|
225
|
+
previewScreen.innerHTML = inlinedHtml;
|
|
226
|
+
htmlSourceCode.value = inlinedHtml;
|
|
227
|
+
} catch (error) {
|
|
228
|
+
console.error("Studio render error:", error);
|
|
229
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
230
|
+
previewScreen.innerHTML = `<div style="color:#b91c1c;padding:16px;background:#fef2f2;border-radius:8px;">渲染错误: ${msg}</div>`;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function scheduleRender(delay = 60) {
|
|
235
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
236
|
+
debounceTimer = setTimeout(() => {
|
|
237
|
+
void doRender();
|
|
238
|
+
}, delay);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function renderSyntaxCards(plugins: WechatPluginDoc[]) {
|
|
242
|
+
const container = document.getElementById("syntax-card-list");
|
|
243
|
+
if (!container) return;
|
|
244
|
+
container.innerHTML = "";
|
|
245
|
+
|
|
246
|
+
for (const doc of plugins) {
|
|
247
|
+
const card = document.createElement("div");
|
|
248
|
+
card.className = "syntax-card";
|
|
249
|
+
|
|
250
|
+
const header = document.createElement("div");
|
|
251
|
+
header.className = "syntax-card-header";
|
|
252
|
+
|
|
253
|
+
const title = document.createElement("span");
|
|
254
|
+
title.className = "syntax-card-title";
|
|
255
|
+
title.textContent = doc.name;
|
|
256
|
+
|
|
257
|
+
const badge = document.createElement("span");
|
|
258
|
+
badge.className = "syntax-card-badge";
|
|
259
|
+
badge.textContent = doc.category === "inline" ? "行内扩展" : "块级组件";
|
|
260
|
+
|
|
261
|
+
header.appendChild(title);
|
|
262
|
+
header.appendChild(badge);
|
|
263
|
+
card.appendChild(header);
|
|
264
|
+
|
|
265
|
+
const desc = document.createElement("p");
|
|
266
|
+
desc.className = "syntax-card-desc";
|
|
267
|
+
desc.textContent = doc.description;
|
|
268
|
+
card.appendChild(desc);
|
|
269
|
+
|
|
270
|
+
const code = document.createElement("pre");
|
|
271
|
+
code.className = "syntax-code-snippet";
|
|
272
|
+
code.textContent = doc.sampleMarkdown;
|
|
273
|
+
card.appendChild(code);
|
|
274
|
+
|
|
275
|
+
const insertBtn = document.createElement("button");
|
|
276
|
+
insertBtn.className = "syntax-btn-insert";
|
|
277
|
+
insertBtn.textContent = "+ 插入示例到文章中";
|
|
278
|
+
insertBtn.addEventListener("click", () => {
|
|
279
|
+
insertTextAtCursor(doc.sampleMarkdown);
|
|
280
|
+
showToast(`已插入「${doc.name}」示例`, "info");
|
|
281
|
+
});
|
|
282
|
+
card.appendChild(insertBtn);
|
|
283
|
+
|
|
284
|
+
container.appendChild(card);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function insertTextAtCursor(text: string) {
|
|
289
|
+
const start = editorMarkdown.selectionStart;
|
|
290
|
+
const end = editorMarkdown.selectionEnd;
|
|
291
|
+
const original = editorMarkdown.value;
|
|
292
|
+
const insertText = `\n\n${text}\n\n`;
|
|
293
|
+
editorMarkdown.value = original.slice(0, start) + insertText + original.slice(end);
|
|
294
|
+
editorMarkdown.focus();
|
|
295
|
+
editorMarkdown.selectionStart = editorMarkdown.selectionEnd = start + insertText.length;
|
|
296
|
+
scheduleRender(20);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function renderSyntaxPresetControls() {
|
|
300
|
+
const container = document.getElementById("syntax-preset-container");
|
|
301
|
+
if (!container) return;
|
|
302
|
+
container.innerHTML = "";
|
|
303
|
+
|
|
304
|
+
for (const cat of SYNTAX_CATEGORIES) {
|
|
305
|
+
const group = document.createElement("div");
|
|
306
|
+
group.className = "preset-group";
|
|
307
|
+
|
|
308
|
+
const header = document.createElement("div");
|
|
309
|
+
header.className = "preset-header";
|
|
310
|
+
|
|
311
|
+
const title = document.createElement("span");
|
|
312
|
+
title.className = "preset-title";
|
|
313
|
+
title.textContent = `${cat.icon} ${cat.label}`;
|
|
314
|
+
header.appendChild(title);
|
|
315
|
+
|
|
316
|
+
const hasMultiplePresets = Boolean(cat.presets && cat.presets.length > 1);
|
|
317
|
+
|
|
318
|
+
const extractBtn = document.createElement("button");
|
|
319
|
+
extractBtn.type = "button";
|
|
320
|
+
extractBtn.className = "btn-extract-css";
|
|
321
|
+
extractBtn.textContent = "📋 提取 CSS";
|
|
322
|
+
extractBtn.title = "将此语法的 CSS 追加到底部自定义编辑器,方便微调覆盖";
|
|
323
|
+
extractBtn.addEventListener("click", () => {
|
|
324
|
+
let snippet = "";
|
|
325
|
+
if (hasMultiplePresets && cat.presets) {
|
|
326
|
+
const activeId = currentSyntaxPresets[cat.key] || cat.defaultPresetId || cat.presets[0].id;
|
|
327
|
+
const p = cat.presets.find((preset) => preset.id === activeId) || cat.presets[0];
|
|
328
|
+
snippet = `/* [${cat.label}] - ${p.name} */\n${p.css.trim()}\n`;
|
|
329
|
+
} else {
|
|
330
|
+
const cssContent = cat.css || (cat.presets && cat.presets[0]?.css) || "";
|
|
331
|
+
snippet = `/* [${cat.label}] 基础样式 */\n${cssContent.trim()}\n`;
|
|
332
|
+
}
|
|
333
|
+
if (!snippet) return;
|
|
334
|
+
inputCustomCss.value = inputCustomCss.value ? `${inputCustomCss.value.trim()}\n\n${snippet}` : snippet;
|
|
335
|
+
inputCustomCss.focus();
|
|
336
|
+
inputCustomCss.scrollTop = inputCustomCss.scrollHeight;
|
|
337
|
+
showToast(`已将「${cat.label}」CSS 追加到底部编辑框!`, "info");
|
|
338
|
+
scheduleRender(20);
|
|
339
|
+
});
|
|
340
|
+
header.appendChild(extractBtn);
|
|
341
|
+
|
|
342
|
+
group.appendChild(header);
|
|
343
|
+
|
|
344
|
+
if (hasMultiplePresets && cat.presets) {
|
|
345
|
+
const select = document.createElement("select");
|
|
346
|
+
select.className = "preset-select";
|
|
347
|
+
select.dataset.category = cat.key;
|
|
348
|
+
|
|
349
|
+
for (const preset of cat.presets) {
|
|
350
|
+
const opt = document.createElement("option");
|
|
351
|
+
opt.value = preset.id;
|
|
352
|
+
opt.textContent = preset.name;
|
|
353
|
+
select.appendChild(opt);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const activePresetId = currentSyntaxPresets[cat.key] || cat.defaultPresetId || cat.presets[0].id;
|
|
357
|
+
select.value = activePresetId;
|
|
358
|
+
group.appendChild(select);
|
|
359
|
+
|
|
360
|
+
const desc = document.createElement("div");
|
|
361
|
+
desc.className = "preset-desc";
|
|
362
|
+
const activePreset = cat.presets.find((p) => p.id === activePresetId) || cat.presets[0];
|
|
363
|
+
desc.textContent = activePreset?.description || "";
|
|
364
|
+
group.appendChild(desc);
|
|
365
|
+
|
|
366
|
+
select.addEventListener("change", () => {
|
|
367
|
+
currentSyntaxPresets[cat.key] = select.value;
|
|
368
|
+
const p = cat.presets!.find((preset) => preset.id === select.value);
|
|
369
|
+
desc.textContent = p?.description || "";
|
|
370
|
+
scheduleRender(20);
|
|
371
|
+
});
|
|
372
|
+
} else if (cat.description) {
|
|
373
|
+
const desc = document.createElement("div");
|
|
374
|
+
desc.className = "preset-desc";
|
|
375
|
+
desc.textContent = cat.description;
|
|
376
|
+
group.appendChild(desc);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
container.appendChild(group);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function applyAccountConfig(accountKey: string) {
|
|
384
|
+
const account = accounts[accountKey];
|
|
385
|
+
if (!account) return;
|
|
386
|
+
|
|
387
|
+
const exportTheme = account.theme?.exportTheme || {};
|
|
388
|
+
const editorVars = account.theme?.editorVars || {};
|
|
389
|
+
|
|
390
|
+
// 1. Font size
|
|
391
|
+
if (exportTheme.containerStyle) {
|
|
392
|
+
const match = exportTheme.containerStyle.match(/font-size:\s*(\d+)px/);
|
|
393
|
+
if (match) {
|
|
394
|
+
inputFontSize.value = match[1];
|
|
395
|
+
valFontSize.textContent = `${match[1]}px`;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// 2. Line height
|
|
400
|
+
if (exportTheme.bodyLineHeight) {
|
|
401
|
+
inputLineHeight.value = exportTheme.bodyLineHeight;
|
|
402
|
+
valLineHeight.textContent = exportTheme.bodyLineHeight;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// 3. Letter spacing (e.g. "0.03em")
|
|
406
|
+
if (exportTheme.bodyLetterSpacing) {
|
|
407
|
+
const spacingNum = parseFloat(exportTheme.bodyLetterSpacing);
|
|
408
|
+
if (!isNaN(spacingNum)) {
|
|
409
|
+
inputLetterSpacing.value = String(spacingNum);
|
|
410
|
+
valLetterSpacing.textContent = `${spacingNum}em`;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// 4. Colors
|
|
415
|
+
const brand = editorVars["--brand"] || exportTheme.primaryColor;
|
|
416
|
+
if (brand) {
|
|
417
|
+
pickerBrandColor.value = brand;
|
|
418
|
+
hexBrandColor.value = brand;
|
|
419
|
+
}
|
|
420
|
+
const textColor = exportTheme.bodyColor || editorVars["--text"];
|
|
421
|
+
if (textColor) {
|
|
422
|
+
pickerTextColor.value = textColor;
|
|
423
|
+
hexTextColor.value = textColor;
|
|
424
|
+
}
|
|
425
|
+
if (exportTheme.h2Color) {
|
|
426
|
+
pickerH2Color.value = exportTheme.h2Color;
|
|
427
|
+
hexH2Color.value = exportTheme.h2Color;
|
|
428
|
+
}
|
|
429
|
+
if (exportTheme.h3Color) {
|
|
430
|
+
pickerH3Color.value = exportTheme.h3Color;
|
|
431
|
+
hexH3Color.value = exportTheme.h3Color;
|
|
432
|
+
}
|
|
433
|
+
const quoteColor = exportTheme.blockquoteBorderColor || brand;
|
|
434
|
+
if (quoteColor) {
|
|
435
|
+
pickerQuoteColor.value = quoteColor;
|
|
436
|
+
hexQuoteColor.value = quoteColor;
|
|
437
|
+
}
|
|
438
|
+
const dividerColor = exportTheme.dividerColor || editorVars["--divider"];
|
|
439
|
+
if (dividerColor) {
|
|
440
|
+
pickerDividerColor.value = dividerColor;
|
|
441
|
+
hexDividerColor.value = dividerColor;
|
|
442
|
+
}
|
|
443
|
+
if (account.customCssContent) {
|
|
444
|
+
inputCustomCss.value = account.customCssContent;
|
|
445
|
+
} else {
|
|
446
|
+
inputCustomCss.value = "";
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// 5. Syntax Presets
|
|
450
|
+
const savedPresets = account.theme?.syntaxPresets || {};
|
|
451
|
+
currentSyntaxPresets = {};
|
|
452
|
+
for (const cat of SYNTAX_CATEGORIES) {
|
|
453
|
+
if (cat.defaultPresetId) {
|
|
454
|
+
currentSyntaxPresets[cat.key] = savedPresets[cat.key] || cat.defaultPresetId;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
renderSyntaxPresetControls();
|
|
458
|
+
|
|
459
|
+
// 6. Structure
|
|
460
|
+
const structure = account.theme?.structure || {};
|
|
461
|
+
switchNumberedHeadings.checked = Boolean(structure.numberedHeadings);
|
|
462
|
+
inputHeadingLabel.value = structure.headingLabel || "";
|
|
463
|
+
inputQuoteLabel.value = structure.quoteLabel || "";
|
|
464
|
+
|
|
465
|
+
scheduleRender(20);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function initColorPalettes() {
|
|
469
|
+
if (!paletteSelect) return;
|
|
470
|
+
paletteSelect.innerHTML = `<option value="">-- 选择推荐调色盘 (一键应用) --</option>`;
|
|
471
|
+
for (const p of COLOR_PALETTES) {
|
|
472
|
+
const opt = document.createElement("option");
|
|
473
|
+
opt.value = p.id;
|
|
474
|
+
opt.textContent = `${p.name} · ${p.category}`;
|
|
475
|
+
paletteSelect.appendChild(opt);
|
|
476
|
+
}
|
|
477
|
+
paletteSelect.addEventListener("change", () => {
|
|
478
|
+
const pal = COLOR_PALETTES.find((p) => p.id === paletteSelect.value);
|
|
479
|
+
if (!pal) return;
|
|
480
|
+
pickerBrandColor.value = hexBrandColor.value = pal.colors.brand;
|
|
481
|
+
pickerTextColor.value = hexTextColor.value = pal.colors.text;
|
|
482
|
+
pickerH2Color.value = hexH2Color.value = pal.colors.h2;
|
|
483
|
+
pickerH3Color.value = hexH3Color.value = pal.colors.h3;
|
|
484
|
+
pickerQuoteColor.value = hexQuoteColor.value = pal.colors.quote;
|
|
485
|
+
pickerDividerColor.value = hexDividerColor.value = pal.colors.divider;
|
|
486
|
+
if (paletteDesc) paletteDesc.textContent = pal.description;
|
|
487
|
+
showToast(`已应用「${pal.name}」高级配色方案!`, "info");
|
|
488
|
+
scheduleRender(20);
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function initStudio() {
|
|
493
|
+
initColorPalettes();
|
|
494
|
+
try {
|
|
495
|
+
const res = await fetch("/api/studio/config");
|
|
496
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
497
|
+
const data = (await res.json()) as StudioConfigResponse;
|
|
498
|
+
|
|
499
|
+
accounts = data.accounts || {};
|
|
500
|
+
currentAccount = data.defaultAccount || "default";
|
|
501
|
+
pluginsList = data.plugins || [];
|
|
502
|
+
|
|
503
|
+
// Populate account selector
|
|
504
|
+
accountSelect.innerHTML = "";
|
|
505
|
+
for (const [key, acc] of Object.entries(accounts)) {
|
|
506
|
+
const opt = document.createElement("option");
|
|
507
|
+
opt.value = key;
|
|
508
|
+
opt.textContent = `${acc.name || key} (${key})`;
|
|
509
|
+
if (key === currentAccount) opt.selected = true;
|
|
510
|
+
accountSelect.appendChild(opt);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Prefill Markdown
|
|
514
|
+
editorMarkdown.value = data.sampleMarkdown || "# 微信排版标题\n\n欢迎使用 WeChat Visual Studio!";
|
|
515
|
+
|
|
516
|
+
// Render plugins cheatsheet
|
|
517
|
+
renderSyntaxCards(pluginsList);
|
|
518
|
+
|
|
519
|
+
// Apply active account config
|
|
520
|
+
applyAccountConfig(currentAccount);
|
|
521
|
+
} catch (err) {
|
|
522
|
+
console.error("Failed to load studio config:", err);
|
|
523
|
+
showToast("无法加载配置,使用默认设置", "error");
|
|
524
|
+
scheduleRender(20);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Event Listeners
|
|
529
|
+
accountSelect.addEventListener("change", () => {
|
|
530
|
+
currentAccount = accountSelect.value;
|
|
531
|
+
applyAccountConfig(currentAccount);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
editorMarkdown.addEventListener("input", () => scheduleRender(40));
|
|
535
|
+
|
|
536
|
+
// Sliders
|
|
537
|
+
inputFontSize.addEventListener("input", () => {
|
|
538
|
+
valFontSize.textContent = `${inputFontSize.value}px`;
|
|
539
|
+
scheduleRender(20);
|
|
540
|
+
});
|
|
541
|
+
inputLineHeight.addEventListener("input", () => {
|
|
542
|
+
valLineHeight.textContent = inputLineHeight.value;
|
|
543
|
+
scheduleRender(20);
|
|
544
|
+
});
|
|
545
|
+
inputLetterSpacing.addEventListener("input", () => {
|
|
546
|
+
valLetterSpacing.textContent = `${inputLetterSpacing.value}em`;
|
|
547
|
+
scheduleRender(20);
|
|
548
|
+
});
|
|
549
|
+
inputParaSpacing.addEventListener("input", () => {
|
|
550
|
+
valParaSpacing.textContent = `${inputParaSpacing.value}em`;
|
|
551
|
+
scheduleRender(20);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
// Color Pickers
|
|
555
|
+
bindColorPair(pickerBrandColor, hexBrandColor, () => scheduleRender(20));
|
|
556
|
+
bindColorPair(pickerTextColor, hexTextColor, () => scheduleRender(20));
|
|
557
|
+
bindColorPair(pickerH2Color, hexH2Color, () => scheduleRender(20));
|
|
558
|
+
bindColorPair(pickerH3Color, hexH3Color, () => scheduleRender(20));
|
|
559
|
+
bindColorPair(pickerQuoteColor, hexQuoteColor, () => scheduleRender(20));
|
|
560
|
+
bindColorPair(pickerDividerColor, hexDividerColor, () => scheduleRender(20));
|
|
561
|
+
|
|
562
|
+
// Switches & text inputs
|
|
563
|
+
switchNumberedHeadings.addEventListener("change", () => scheduleRender(20));
|
|
564
|
+
inputHeadingLabel.addEventListener("input", () => scheduleRender(40));
|
|
565
|
+
inputQuoteLabel.addEventListener("input", () => scheduleRender(40));
|
|
566
|
+
inputCustomCss.addEventListener("input", () => scheduleRender(60));
|
|
567
|
+
|
|
568
|
+
// Tabs
|
|
569
|
+
const tabBtnStyle = document.getElementById("tab-btn-style") as HTMLButtonElement;
|
|
570
|
+
const tabBtnSyntax = document.getElementById("tab-btn-syntax") as HTMLButtonElement;
|
|
571
|
+
const tabContentStyle = document.getElementById("tab-content-style") as HTMLElement;
|
|
572
|
+
const tabContentSyntax = document.getElementById("tab-content-syntax") as HTMLElement;
|
|
573
|
+
|
|
574
|
+
tabBtnStyle.addEventListener("click", () => {
|
|
575
|
+
tabBtnStyle.classList.add("active");
|
|
576
|
+
tabBtnSyntax.classList.remove("active");
|
|
577
|
+
tabContentStyle.style.display = "block";
|
|
578
|
+
tabContentSyntax.style.display = "none";
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
tabBtnSyntax.addEventListener("click", () => {
|
|
582
|
+
tabBtnSyntax.classList.add("active");
|
|
583
|
+
tabBtnStyle.classList.remove("active");
|
|
584
|
+
tabContentStyle.style.display = "none";
|
|
585
|
+
tabContentSyntax.style.display = "block";
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
// Load sample / clear
|
|
589
|
+
document.getElementById("btn-load-sample")?.addEventListener("click", async () => {
|
|
590
|
+
const res = await fetch("/api/studio/config");
|
|
591
|
+
const data = (await res.json()) as StudioConfigResponse;
|
|
592
|
+
editorMarkdown.value = data.sampleMarkdown || "";
|
|
593
|
+
scheduleRender(20);
|
|
594
|
+
showToast("已加载官方全语法测试稿", "info");
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
document.getElementById("btn-clear-md")?.addEventListener("click", () => {
|
|
598
|
+
editorMarkdown.value = "";
|
|
599
|
+
scheduleRender(20);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
// Toggle HTML view
|
|
603
|
+
document.getElementById("btn-toggle-view")?.addEventListener("click", (e) => {
|
|
604
|
+
const btn = e.currentTarget as HTMLButtonElement;
|
|
605
|
+
showingHtmlSource = !showingHtmlSource;
|
|
606
|
+
if (showingHtmlSource) {
|
|
607
|
+
htmlSourceContainer.style.display = "block";
|
|
608
|
+
btn.textContent = "📱 返回视图";
|
|
609
|
+
} else {
|
|
610
|
+
htmlSourceContainer.style.display = "none";
|
|
611
|
+
btn.textContent = "🔍 查看 HTML 源码";
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
// Reset
|
|
616
|
+
document.getElementById("btn-reset")?.addEventListener("click", () => {
|
|
617
|
+
applyAccountConfig(currentAccount);
|
|
618
|
+
showToast("已重置为当前账号默认样式", "info");
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
// Save to config.json
|
|
622
|
+
document.getElementById("btn-save")?.addEventListener("click", async () => {
|
|
623
|
+
const fontSize = Number(inputFontSize.value) || 16;
|
|
624
|
+
const lineHeight = inputLineHeight.value || "1.84";
|
|
625
|
+
const letterSpacing = `${inputLetterSpacing.value}em`;
|
|
626
|
+
|
|
627
|
+
const brandColor = hexBrandColor.value;
|
|
628
|
+
const textColor = hexTextColor.value;
|
|
629
|
+
const h2Color = hexH2Color.value;
|
|
630
|
+
const h3Color = hexH3Color.value;
|
|
631
|
+
const quoteColor = hexQuoteColor.value;
|
|
632
|
+
const dividerColor = hexDividerColor.value;
|
|
633
|
+
|
|
634
|
+
const baseEditorVars = accounts[currentAccount]?.theme?.editorVars || {};
|
|
635
|
+
const baseExportTheme = accounts[currentAccount]?.theme?.exportTheme || {};
|
|
636
|
+
|
|
637
|
+
const payload = {
|
|
638
|
+
account: currentAccount,
|
|
639
|
+
editorVars: {
|
|
640
|
+
...baseEditorVars,
|
|
641
|
+
"--primary": brandColor,
|
|
642
|
+
"--brand": brandColor,
|
|
643
|
+
"--text": textColor,
|
|
644
|
+
"--divider": dividerColor,
|
|
645
|
+
},
|
|
646
|
+
exportTheme: {
|
|
647
|
+
...baseExportTheme,
|
|
648
|
+
fontSize: `${fontSize}px`,
|
|
649
|
+
bodyColor: textColor,
|
|
650
|
+
h2Color,
|
|
651
|
+
h3Color,
|
|
652
|
+
primaryColor: brandColor,
|
|
653
|
+
dividerColor,
|
|
654
|
+
blockquoteBorderColor: quoteColor,
|
|
655
|
+
bodyLineHeight: lineHeight,
|
|
656
|
+
bodyLetterSpacing: letterSpacing,
|
|
657
|
+
},
|
|
658
|
+
syntaxPresets: currentSyntaxPresets,
|
|
659
|
+
customCss: inputCustomCss.value,
|
|
660
|
+
structure: {
|
|
661
|
+
...accounts[currentAccount]?.theme?.structure,
|
|
662
|
+
numberedHeadings: switchNumberedHeadings.checked,
|
|
663
|
+
headingLabel: inputHeadingLabel.value.trim() || undefined,
|
|
664
|
+
quoteLabel: inputQuoteLabel.value.trim() || undefined,
|
|
665
|
+
},
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
try {
|
|
669
|
+
const res = await fetch("/api/studio/save-config", {
|
|
670
|
+
method: "POST",
|
|
671
|
+
headers: { "content-type": "application/json" },
|
|
672
|
+
body: JSON.stringify(payload),
|
|
673
|
+
});
|
|
674
|
+
const data = await res.json();
|
|
675
|
+
if (res.ok && data.ok) {
|
|
676
|
+
if (accounts[currentAccount]?.theme) {
|
|
677
|
+
accounts[currentAccount].theme.syntaxPresets = { ...currentSyntaxPresets };
|
|
678
|
+
}
|
|
679
|
+
showToast("✅ 配置已成功保存至本地 config.json!", "success");
|
|
680
|
+
} else {
|
|
681
|
+
showToast(`保存失败: ${data.error || "未知错误"}`, "error");
|
|
682
|
+
}
|
|
683
|
+
} catch (err) {
|
|
684
|
+
showToast(`网络请求失败: ${String(err)}`, "error");
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
// Copy to WeChat
|
|
689
|
+
document.getElementById("btn-copy")?.addEventListener("click", async () => {
|
|
690
|
+
if (!latestWechatHtml) {
|
|
691
|
+
showToast("文章内容为空", "error");
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
try {
|
|
696
|
+
const blobHtml = new Blob([latestWechatHtml], { type: "text/html" });
|
|
697
|
+
const blobText = new Blob([latestWechatHtml], { type: "text/plain" });
|
|
698
|
+
await navigator.clipboard.write([
|
|
699
|
+
new ClipboardItem({
|
|
700
|
+
"text/html": blobHtml,
|
|
701
|
+
"text/plain": blobText,
|
|
702
|
+
}),
|
|
703
|
+
]);
|
|
704
|
+
showToast("📋 已复制富文本!可在微信公众号后台直接粘贴 (Cmd+V)", "success");
|
|
705
|
+
} catch (err) {
|
|
706
|
+
console.error("Clipboard write error:", err);
|
|
707
|
+
// Fallback: execCommand copy
|
|
708
|
+
const listener = (e: ClipboardEvent) => {
|
|
709
|
+
e.clipboardData?.setData("text/html", latestWechatHtml);
|
|
710
|
+
e.clipboardData?.setData("text/plain", latestWechatHtml);
|
|
711
|
+
e.preventDefault();
|
|
712
|
+
};
|
|
713
|
+
document.addEventListener("copy", listener);
|
|
714
|
+
document.execCommand("copy");
|
|
715
|
+
document.removeEventListener("copy", listener);
|
|
716
|
+
showToast("📋 已复制富文本(后备通道)", "success");
|
|
717
|
+
}
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
// Start
|
|
721
|
+
void initStudio();
|