@akairos/typeset-studio 0.2.5 → 0.2.7
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/README.md +67 -17
- package/dist/assets/index-CVzTRIjV.js +317 -0
- package/dist/assets/index-JCv_QmUn.css +1 -0
- package/dist/index.html +2 -2
- package/dist/lib/adapter.js +1 -1
- package/dist/lib/{canvas-renderer-cxnar75n.js → canvas-renderer-wce6xgb2.js} +1 -1
- package/dist/lib/{index-zdhqg5f0.js → index-697fkv5y.js} +294 -57
- package/dist/lib/{index-h2580xma.js → index-dkwbnaj7.js} +3 -3
- package/dist/lib/{index-t9w3p2p9.js → index-qszn98j2.js} +73 -5
- package/dist/lib/index.js +3 -3
- package/dist/lib/{render-xxd348m1.js → render-064weavx.js} +2 -2
- package/package.json +3 -3
- package/src/adapter.test.ts +3 -1
- package/src/adapter.ts +1 -1
- package/src/core/canvas-draw.ts +53 -43
- package/src/core/canvas-presets.ts +37 -0
- package/src/core/canvas-renderer.test.ts +60 -1
- package/src/core/canvas-style.ts +149 -0
- package/src/core/engine.ts +2 -0
- package/src/core/geometry.ts +4 -3
- package/src/core/types.ts +30 -3
- package/src/index.ts +4 -0
- package/src/server/index.ts +1 -1
- package/src/server/routes/slots.test.ts +24 -0
- package/src/server/routes/templates.ts +17 -2
- package/src/web/App.tsx +27 -7
- package/src/web/components/CanvasStyleInspector.tsx +95 -0
- package/src/web/components/Header.tsx +25 -10
- package/src/web/components/ParamsInspector.tsx +6 -1
- package/src/web/components/SaveTemplateModal.tsx +106 -0
- package/src/web/components/TemplateDrawer.tsx +323 -0
- package/src/web/hooks/useTypeset.ts +3 -1
- package/dist/assets/index-3Ko1UCBU.css +0 -1
- package/dist/assets/index-De-aHsvp.js +0 -317
- package/src/web/components/TemplateModal.tsx +0 -393
package/src/core/engine.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { paginateBlocks } from "./obstacle-flow";
|
|
|
4
4
|
import { computeBlockStyles } from "./typography";
|
|
5
5
|
import { resolveImagePlacement } from "./image-layout";
|
|
6
6
|
import { validateTemplateAssets } from "./template";
|
|
7
|
+
import { validateCanvasStyle } from "./canvas-style";
|
|
7
8
|
import {
|
|
8
9
|
FlowBlock,
|
|
9
10
|
FlowImage,
|
|
@@ -86,6 +87,7 @@ export function runTypeset(params: {
|
|
|
86
87
|
throw new RangeError("Invalid page constraints: maxPages must be 0 or at least minPages");
|
|
87
88
|
}
|
|
88
89
|
validateTemplateAssets(images);
|
|
90
|
+
validateCanvasStyle(config.canvasStyle);
|
|
89
91
|
if (images.some((image) => !image.repeatEveryPage && image.pageIndex != null && (!Number.isInteger(image.pageIndex) || image.pageIndex < 1 || image.pageIndex > 50))) {
|
|
90
92
|
throw new RangeError("Image pageIndex must be an integer between 1 and 50");
|
|
91
93
|
}
|
package/src/core/geometry.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { AspectRatioType, CanvasSpec } from "./types";
|
|
2
2
|
|
|
3
3
|
export const CANVAS_SPECS: Record<AspectRatioType, CanvasSpec> = {
|
|
4
|
+
// 保留旧标识兼容已保存模板;画布包含左侧大图与右侧方形小图。
|
|
4
5
|
"wechat-cover-2.35": {
|
|
5
6
|
id: "wechat-cover-2.35",
|
|
6
7
|
name: "微信文章封面",
|
|
7
|
-
ratio:
|
|
8
|
-
width:
|
|
8
|
+
ratio: 3.35,
|
|
9
|
+
width: 3350,
|
|
9
10
|
height: 1000,
|
|
10
|
-
description: "
|
|
11
|
+
description: "微信文章封面原图 (3.35:1):左侧 2.35:1 大图,右侧 1:1 小图",
|
|
11
12
|
defaultSafeArea: { top: 120, right: 140, bottom: 120, left: 140 },
|
|
12
13
|
},
|
|
13
14
|
"longform-3-4": {
|
package/src/core/types.ts
CHANGED
|
@@ -152,6 +152,34 @@ export interface TypesetChromeConfig {
|
|
|
152
152
|
|
|
153
153
|
export type TemplateSlot = "article_cover" | "newspic_cover" | "newspic_content";
|
|
154
154
|
|
|
155
|
+
export type CanvasFill = { type: "solid"; color: string } |
|
|
156
|
+
{ type: "linear"; angle: number; stops: Array<{ color: string; offset: number }> } |
|
|
157
|
+
{ type: "radial"; position: { x: number; y: number }; stops: Array<{ color: string; offset: number }> };
|
|
158
|
+
|
|
159
|
+
export interface CanvasPattern {
|
|
160
|
+
pattern: "none" | "lines" | "grid" | "dots" | "rings" | "burst" | "diagonal" | "waves" | "checker" | "grain";
|
|
161
|
+
color: string;
|
|
162
|
+
opacity: number;
|
|
163
|
+
size: number;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface CanvasBorder {
|
|
167
|
+
style: "none" | "solid" | "dashed" | "double" | "corners" | "gradient";
|
|
168
|
+
color: string;
|
|
169
|
+
endColor: string;
|
|
170
|
+
width: number;
|
|
171
|
+
inset: number;
|
|
172
|
+
radius: number;
|
|
173
|
+
opacity: number;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 背景装饰独立于文字主题,封面和正文模板共用。
|
|
177
|
+
export interface CanvasStyle {
|
|
178
|
+
fill?: CanvasFill;
|
|
179
|
+
pattern?: CanvasPattern;
|
|
180
|
+
border?: CanvasBorder;
|
|
181
|
+
}
|
|
182
|
+
|
|
155
183
|
export interface TypesetConfig {
|
|
156
184
|
id?: string;
|
|
157
185
|
name: string;
|
|
@@ -168,6 +196,7 @@ export interface TypesetConfig {
|
|
|
168
196
|
chrome?: TypesetChromeConfig;
|
|
169
197
|
images?: FlowImage[];
|
|
170
198
|
coverTheme?: PipelineCoverTheme;
|
|
199
|
+
canvasStyle?: CanvasStyle;
|
|
171
200
|
}
|
|
172
201
|
|
|
173
202
|
export interface TypesetResult {
|
|
@@ -198,9 +227,7 @@ export interface PipelineCoverTheme {
|
|
|
198
227
|
typography: { title: CoverFont; subtitle: CoverFont; footer: CoverFont };
|
|
199
228
|
colors: { text: string; accent: string; muted: string };
|
|
200
229
|
background: {
|
|
201
|
-
fill:
|
|
202
|
-
{ type: "linear"; angle: number; stops: Array<{ color: string; offset: number }> } |
|
|
203
|
-
{ type: "radial"; position: { x: number; y: number }; stops: Array<{ color: string; offset: number }> };
|
|
230
|
+
fill: CanvasFill;
|
|
204
231
|
image: CoverImage | null;
|
|
205
232
|
overlay: { color: string; opacity: number } | null;
|
|
206
233
|
};
|
package/src/index.ts
CHANGED
package/src/server/index.ts
CHANGED
|
@@ -37,6 +37,8 @@ describe("Template Slots & Fallback Compatibility", () => {
|
|
|
37
37
|
expect(data.ok).toBe(true);
|
|
38
38
|
expect(data.slot).toBe("article_cover");
|
|
39
39
|
expect(data.result.canvasSpec.id).toBe("wechat-cover-2.35");
|
|
40
|
+
expect(data.result.canvasSpec.width).toBe(3350);
|
|
41
|
+
expect(data.result.canvasSpec.height).toBe(1000);
|
|
40
42
|
});
|
|
41
43
|
|
|
42
44
|
it("renders wechat-newspic content route by auto-resolving newspic_content slot", async () => {
|
|
@@ -183,3 +185,25 @@ describe("Fixed asset rendering API", () => {
|
|
|
183
185
|
}
|
|
184
186
|
});
|
|
185
187
|
});
|
|
188
|
+
|
|
189
|
+
it("adds Canvas presets once, preserves edits and renders saved colors through the template API", async () => {
|
|
190
|
+
const { CANVAS_PRESETS } = await import("../../core/canvas-presets");
|
|
191
|
+
const list = await (await app.request("/api/templates", { headers: authHeaders })).json();
|
|
192
|
+
for (const preset of CANVAS_PRESETS) expect(list.templates.some((template: { id: string }) => template.id === preset.id)).toBe(true);
|
|
193
|
+
const preset = CANVAS_PRESETS[0];
|
|
194
|
+
try {
|
|
195
|
+
const custom = { ...preset, canvasStyle: { ...preset.canvasStyle, fill: { type: "solid", color: "#123456" } } };
|
|
196
|
+
expect((await app.request("/api/templates", { method: "POST", headers: authHeaders, body: JSON.stringify(custom) })).status).toBe(200);
|
|
197
|
+
const loaded = await (await app.request(`/api/templates/${preset.id}`, { headers: authHeaders })).json();
|
|
198
|
+
expect(loaded.template.canvasStyle.fill.color).toBe("#123456");
|
|
199
|
+
const rendered = await (await app.request("/api/render", { method: "POST", headers: authHeaders, body: JSON.stringify({ template: preset.id, markdown: "# 自定义背景", preserveTemplateStyle: true, config: { canvasStyle: { fill: { type: "solid", color: "#ffffff" } } } }) })).json();
|
|
200
|
+
expect(rendered.result.config.canvasStyle.fill.color).toBe("#123456");
|
|
201
|
+
const invalid = await app.request("/api/templates", { method: "POST", headers: authHeaders, body: JSON.stringify({ ...custom, canvasStyle: { pattern: { pattern: "grid", color: "#123456", opacity: 1, size: 0 } } }) });
|
|
202
|
+
expect(invalid.status).toBe(400);
|
|
203
|
+
await app.request(`/api/templates/${preset.id}`, { method: "DELETE", headers: authHeaders });
|
|
204
|
+
const again = await (await app.request("/api/templates", { headers: authHeaders })).json();
|
|
205
|
+
expect(again.templates.some((template: { id: string }) => template.id === preset.id)).toBe(false);
|
|
206
|
+
} finally {
|
|
207
|
+
await app.request("/api/templates", { method: "POST", headers: authHeaders, body: JSON.stringify(preset) });
|
|
208
|
+
}
|
|
209
|
+
});
|
|
@@ -5,6 +5,8 @@ import { getDefaultConfig } from "../../core/engine";
|
|
|
5
5
|
import { TemplateSlot, TypesetConfig } from "../../core/types";
|
|
6
6
|
import { resolveDataDir } from "../../core/data-dir";
|
|
7
7
|
import { validateTemplateAssets } from "../../core/template";
|
|
8
|
+
import { validateCanvasStyle } from "../../core/canvas-style";
|
|
9
|
+
import { CANVAS_PRESETS } from "../../core/canvas-presets";
|
|
8
10
|
|
|
9
11
|
export { resolveDataDir } from "../../core/data-dir";
|
|
10
12
|
|
|
@@ -23,13 +25,24 @@ function ensureTemplatesDir(): void {
|
|
|
23
25
|
seedDefaultTemplates();
|
|
24
26
|
try { writeFileSync(seedMarker, "1"); } catch { /* 只读安装目录保留读取能力。 */ }
|
|
25
27
|
}
|
|
28
|
+
// 新预设仅补充一次,不覆盖已编辑模板,也不重新生成用户删除的预设。
|
|
29
|
+
const canvasMarker = join(TEMPLATES_DIR, ".canvas-presets-v1");
|
|
30
|
+
if (!existsSync(canvasMarker)) {
|
|
31
|
+
try {
|
|
32
|
+
for (const preset of CANVAS_PRESETS) {
|
|
33
|
+
const path = join(TEMPLATES_DIR, `${preset.id}.json`);
|
|
34
|
+
if (!existsSync(path)) writeFileSync(path, JSON.stringify(preset, null, 2), "utf-8");
|
|
35
|
+
}
|
|
36
|
+
writeFileSync(canvasMarker, "1");
|
|
37
|
+
} catch { /* 只读安装目录继续使用现有模板。 */ }
|
|
38
|
+
}
|
|
26
39
|
}
|
|
27
40
|
|
|
28
41
|
export const BUILTIN_PRESETS: TypesetConfig[] = [
|
|
29
42
|
{
|
|
30
43
|
...getDefaultConfig("wechat-cover-2.35"),
|
|
31
44
|
id: "tpl_wechat_cover_minimal",
|
|
32
|
-
name: "微信纯净大图封面 (
|
|
45
|
+
name: "微信纯净大图封面 (3.35:1)",
|
|
33
46
|
slot: "article_cover",
|
|
34
47
|
algorithm: {
|
|
35
48
|
...getDefaultConfig("wechat-cover-2.35").algorithm,
|
|
@@ -84,7 +97,7 @@ export const BUILTIN_PRESETS: TypesetConfig[] = [
|
|
|
84
97
|
{
|
|
85
98
|
...getDefaultConfig("wechat-cover-2.35"),
|
|
86
99
|
id: "tpl_wechat_cover_editorial",
|
|
87
|
-
name: "微信杂志标封面 (
|
|
100
|
+
name: "微信杂志标封面 (3.35:1)",
|
|
88
101
|
slot: "article_cover",
|
|
89
102
|
algorithm: {
|
|
90
103
|
...getDefaultConfig("wechat-cover-2.35").algorithm,
|
|
@@ -334,6 +347,7 @@ export const BUILTIN_PRESETS: TypesetConfig[] = [
|
|
|
334
347
|
showFillRatio: false,
|
|
335
348
|
},
|
|
336
349
|
},
|
|
350
|
+
...CANVAS_PRESETS,
|
|
337
351
|
];
|
|
338
352
|
|
|
339
353
|
function seedDefaultTemplates(): void {
|
|
@@ -611,6 +625,7 @@ templatesRouter.post("/", async (c) => {
|
|
|
611
625
|
|
|
612
626
|
try {
|
|
613
627
|
validateTemplateAssets(body.images);
|
|
628
|
+
validateCanvasStyle(body.canvasStyle);
|
|
614
629
|
} catch (error) {
|
|
615
630
|
return c.json({ ok: false, error: error instanceof Error ? error.message : "无效的模板素材" }, 400);
|
|
616
631
|
}
|
package/src/web/App.tsx
CHANGED
|
@@ -4,7 +4,8 @@ import { CanvasViewport } from "./components/CanvasViewport";
|
|
|
4
4
|
import { EditorSidebar } from "./components/EditorSidebar";
|
|
5
5
|
import { Header } from "./components/Header";
|
|
6
6
|
import { ParamsInspector } from "./components/ParamsInspector";
|
|
7
|
-
import {
|
|
7
|
+
import { TemplateDrawer } from "./components/TemplateDrawer";
|
|
8
|
+
import { SaveTemplateModal } from "./components/SaveTemplateModal";
|
|
8
9
|
import { useAuth } from "./hooks/useAuth";
|
|
9
10
|
import { useTypeset } from "./hooks/useTypeset";
|
|
10
11
|
|
|
@@ -57,7 +58,9 @@ export const App: React.FC = () => {
|
|
|
57
58
|
const [viewMode, setViewMode] = useState<"grid" | "stack">("grid");
|
|
58
59
|
const [zoom, setZoom] = useState<number>(0.45);
|
|
59
60
|
const [isAutoFit, setIsAutoFit] = useState<boolean>(true);
|
|
60
|
-
const [
|
|
61
|
+
const [isTemplateDrawerOpen, setIsTemplateDrawerOpen] = useState(false);
|
|
62
|
+
const [isSaveTemplateOpen, setIsSaveTemplateOpen] = useState(false);
|
|
63
|
+
const [templateRefreshKey, setTemplateRefreshKey] = useState(0);
|
|
61
64
|
const [isAuthModalOpen, setIsAuthModalOpen] = useState(false);
|
|
62
65
|
const [isExporting, setIsExporting] = useState(false);
|
|
63
66
|
const viewportRef = useRef<HTMLDivElement>(null);
|
|
@@ -251,7 +254,10 @@ export const App: React.FC = () => {
|
|
|
251
254
|
onZoomChange={handleZoomChange}
|
|
252
255
|
onAutoFit={handleAutoFit}
|
|
253
256
|
isAutoFit={isAutoFit}
|
|
254
|
-
onOpenTemplates={() =>
|
|
257
|
+
onOpenTemplates={() => setIsTemplateDrawerOpen((open) => !open)}
|
|
258
|
+
isTemplateDrawerOpen={isTemplateDrawerOpen}
|
|
259
|
+
templateName={config.name}
|
|
260
|
+
onSaveTemplate={() => setIsSaveTemplateOpen(true)}
|
|
255
261
|
onOpenAuth={() => setIsAuthModalOpen(true)}
|
|
256
262
|
onExport={handleExport}
|
|
257
263
|
isExporting={isExporting}
|
|
@@ -300,6 +306,10 @@ export const App: React.FC = () => {
|
|
|
300
306
|
config={config}
|
|
301
307
|
onUpdateAlgorithm={updateAlgorithm}
|
|
302
308
|
onUpdateTheme={updateTheme}
|
|
309
|
+
onUpdateCanvasStyle={(canvasStyle) => setConfig((prev) => ({
|
|
310
|
+
...prev, canvasStyle,
|
|
311
|
+
theme: { ...prev.theme, bgColor: canvasStyle.fill?.type === "solid" ? canvasStyle.fill.color : canvasStyle.fill?.stops[0].color || prev.theme.bgColor },
|
|
312
|
+
}))}
|
|
303
313
|
onUpdateTypography={updateTypography}
|
|
304
314
|
onUpdateSafeArea={updateSafeArea}
|
|
305
315
|
onUpdateBranding={(footer, watermark) => {
|
|
@@ -319,17 +329,27 @@ export const App: React.FC = () => {
|
|
|
319
329
|
}));
|
|
320
330
|
}}
|
|
321
331
|
/>
|
|
332
|
+
<TemplateDrawer
|
|
333
|
+
isOpen={isTemplateDrawerOpen}
|
|
334
|
+
onClose={() => setIsTemplateDrawerOpen(false)}
|
|
335
|
+
refreshKey={templateRefreshKey}
|
|
336
|
+
onApplyTemplate={loadTemplate}
|
|
337
|
+
adminKey={adminKey}
|
|
338
|
+
/>
|
|
322
339
|
</div>
|
|
323
340
|
|
|
324
341
|
{/* Modals */}
|
|
325
|
-
<
|
|
326
|
-
isOpen={
|
|
327
|
-
onClose={() =>
|
|
342
|
+
<SaveTemplateModal
|
|
343
|
+
isOpen={isSaveTemplateOpen}
|
|
344
|
+
onClose={() => setIsSaveTemplateOpen(false)}
|
|
328
345
|
currentConfig={{
|
|
329
346
|
...config,
|
|
330
347
|
images,
|
|
331
348
|
}}
|
|
332
|
-
|
|
349
|
+
onSaved={(template) => {
|
|
350
|
+
setConfig((prev) => ({ ...prev, id: template.id, name: template.name, slot: template.slot }));
|
|
351
|
+
setTemplateRefreshKey((key) => key + 1);
|
|
352
|
+
}}
|
|
333
353
|
adminKey={adminKey}
|
|
334
354
|
/>
|
|
335
355
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { BORDER_LABELS, DEFAULT_BORDER, DEFAULT_PATTERN, PATTERN_LABELS } from "../../core/canvas-style";
|
|
3
|
+
import type { CanvasFill, CanvasStyle, TypesetConfig } from "../../core/types";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
config: TypesetConfig;
|
|
7
|
+
onChange: (style: CanvasStyle) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const control = "w-full rounded border border-[#dfdfdf] dark:border-[#333333] bg-white dark:bg-[#141414] px-2 py-1 text-[11px]";
|
|
11
|
+
|
|
12
|
+
// 装饰配置独立保存,切换背景不会改变字体和正文排版。
|
|
13
|
+
export function CanvasStyleInspector({ config, onChange }: Props) {
|
|
14
|
+
const style = config.canvasStyle || {};
|
|
15
|
+
const fill = style.fill || config.coverTheme?.background.fill || { type: "solid", color: config.theme.bgColor };
|
|
16
|
+
const pattern = style.pattern || config.coverTheme?.decoration || DEFAULT_PATTERN;
|
|
17
|
+
const border = style.border || DEFAULT_BORDER;
|
|
18
|
+
const updateFill = (next: CanvasFill) => onChange({ ...style, fill: next });
|
|
19
|
+
const updatePattern = (partial: Partial<typeof pattern>) => onChange({ ...style, pattern: { ...pattern, ...partial } });
|
|
20
|
+
const updateBorder = (partial: Partial<typeof border>) => onChange({ ...style, border: { ...border, ...partial } });
|
|
21
|
+
const colorInput = (label: string, value: string, update: (value: string) => void) => (
|
|
22
|
+
<label className="flex items-center justify-between gap-2">
|
|
23
|
+
<span>{label}</span><span className="flex items-center gap-1.5 font-mono">
|
|
24
|
+
<span>{value}</span><input aria-label={label} type="color" value={value} onChange={(event) => update(event.target.value)} className="h-6 w-7 cursor-pointer bg-transparent" />
|
|
25
|
+
</span>
|
|
26
|
+
</label>
|
|
27
|
+
);
|
|
28
|
+
const slider = (label: string, value: number, min: number, max: number, update: (value: number) => void, step = 1) => (
|
|
29
|
+
<label className="flex flex-col gap-1">
|
|
30
|
+
<span className="flex justify-between"><span>{label}</span><span className="font-mono">{step < 1 ? `${Math.round(value * 100)}%` : value}</span></span>
|
|
31
|
+
<input aria-label={label} type="range" min={min} max={max} step={step} value={value} onChange={(event) => update(Number(event.target.value))} className="w-full accent-emerald-600" />
|
|
32
|
+
</label>
|
|
33
|
+
);
|
|
34
|
+
const switchFill = (type: CanvasFill["type"]) => {
|
|
35
|
+
const color = fill.type === "solid" ? fill.color : fill.stops[0].color;
|
|
36
|
+
const stops = fill.type === "solid" ? [{ color, offset: 0 }, { color: config.theme.accentColor, offset: 100 }] : fill.stops;
|
|
37
|
+
updateFill(type === "solid" ? { type, color } : type === "linear" ? { type, angle: 90, stops } : { type, position: { x: 50, y: 30 }, stops });
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<section className="border-b border-[#f0f0f0] dark:border-[#1c1c1c] p-3.5 text-xs">
|
|
42
|
+
<div className="mb-3 font-semibold text-[#171717] dark:text-white">背景、花纹与边框</div>
|
|
43
|
+
<div className="flex flex-col gap-3 text-[11px] text-[#666666] dark:text-[#aaaaaa]">
|
|
44
|
+
<label className="flex flex-col gap-1">背景类型
|
|
45
|
+
<select className={control} value={fill.type} onChange={(event) => switchFill(event.target.value as CanvasFill["type"])}>
|
|
46
|
+
<option value="solid">纯色</option><option value="linear">线性渐变</option><option value="radial">径向渐变</option>
|
|
47
|
+
</select>
|
|
48
|
+
</label>
|
|
49
|
+
{fill.type === "solid" ? colorInput("背景颜色", fill.color, (color) => updateFill({ type: "solid", color })) : <>
|
|
50
|
+
<div className="flex flex-col gap-2 rounded border border-[#eaeaea] dark:border-[#333333] p-2">
|
|
51
|
+
{fill.stops.map((stop, index) => <div key={index} className="flex flex-col gap-1.5">
|
|
52
|
+
<div className="flex items-center gap-2">
|
|
53
|
+
<label className="flex flex-1 items-center gap-1.5">
|
|
54
|
+
<input aria-label={`渐变颜色 ${index + 1}`} type="color" value={stop.color} onChange={(event) => updateFill({ ...fill, stops: fill.stops.map((item, i) => i === index ? { ...item, color: event.target.value } : item) })} className="h-6 w-7 bg-transparent" />
|
|
55
|
+
<span className="font-mono">{stop.color}</span>
|
|
56
|
+
</label>
|
|
57
|
+
<button type="button" disabled={fill.stops.length <= 2} onClick={() => updateFill({ ...fill, stops: fill.stops.filter((_, i) => i !== index) })} className="disabled:opacity-30" aria-label={`删除渐变颜色 ${index + 1}`}>移除</button>
|
|
58
|
+
</div>
|
|
59
|
+
{slider(`节点 ${index + 1} 位置`, stop.offset, 0, 100, (offset) => updateFill({ ...fill, stops: fill.stops.map((item, i) => i === index ? { ...item, offset } : item) }))}
|
|
60
|
+
</div>)}
|
|
61
|
+
<button type="button" disabled={fill.stops.length >= 8} onClick={() => updateFill({ ...fill, stops: [...fill.stops, { color: config.theme.accentColor, offset: 50 }] })} className="rounded border border-[#dfdfdf] dark:border-[#333333] py-1 disabled:opacity-30">添加颜色节点</button>
|
|
62
|
+
</div>
|
|
63
|
+
{fill.type === "linear" ? slider("渐变角度", fill.angle, 0, 360, (angle) => updateFill({ ...fill, angle })) : <>
|
|
64
|
+
{slider("渐变中心 X", fill.position.x, 0, 100, (x) => updateFill({ ...fill, position: { ...fill.position, x } }))}
|
|
65
|
+
{slider("渐变中心 Y", fill.position.y, 0, 100, (y) => updateFill({ ...fill, position: { ...fill.position, y } }))}
|
|
66
|
+
</>}
|
|
67
|
+
</>}
|
|
68
|
+
<label className="flex flex-col gap-1">几何花纹
|
|
69
|
+
<select className={control} value={pattern.pattern} onChange={(event) => updatePattern({ pattern: event.target.value as typeof pattern.pattern })}>
|
|
70
|
+
{Object.entries(PATTERN_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
|
71
|
+
</select>
|
|
72
|
+
</label>
|
|
73
|
+
{pattern.pattern !== "none" && <>
|
|
74
|
+
{colorInput("花纹颜色", pattern.color, (color) => updatePattern({ color }))}
|
|
75
|
+
{slider("花纹透明度", pattern.opacity, 0, 1, (opacity) => updatePattern({ opacity }), 0.01)}
|
|
76
|
+
{slider("花纹间距", pattern.size, 16, 240, (size) => updatePattern({ size }))}
|
|
77
|
+
</>}
|
|
78
|
+
<label className="flex flex-col gap-1">边框样式
|
|
79
|
+
<select className={control} value={border.style} onChange={(event) => updateBorder({ style: event.target.value as typeof border.style })}>
|
|
80
|
+
{Object.entries(BORDER_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
|
81
|
+
</select>
|
|
82
|
+
</label>
|
|
83
|
+
{border.style !== "none" && <>
|
|
84
|
+
{colorInput("边框颜色", border.color, (color) => updateBorder({ color }))}
|
|
85
|
+
{border.style === "gradient" && colorInput("边框渐变终色", border.endColor, (endColor) => updateBorder({ endColor }))}
|
|
86
|
+
{slider("边框粗细", border.width, 1, 24, (width) => updateBorder({ width }))}
|
|
87
|
+
{slider("边框内缩", border.inset, 0, 200, (inset) => updateBorder({ inset }))}
|
|
88
|
+
{border.style !== "corners" && slider("边框圆角", border.radius, 0, 200, (radius) => updateBorder({ radius }))}
|
|
89
|
+
{slider("边框透明度", border.opacity, 0, 1, (opacity) => updateBorder({ opacity }), 0.01)}
|
|
90
|
+
</>}
|
|
91
|
+
<p className="leading-relaxed text-[#888888]">配置随模板保存,作用于每页。背景图会覆盖底色与花纹;边框绘制在最上层。</p>
|
|
92
|
+
</div>
|
|
93
|
+
</section>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { Download, FolderOpen, Key, Maximize2, Moon, RefreshCw, Sun, ZoomIn, ZoomOut } from "lucide-react";
|
|
2
|
+
import { Download, FolderOpen, Key, Maximize2, Moon, RefreshCw, Save, Sun, ZoomIn, ZoomOut } from "lucide-react";
|
|
3
3
|
import { AspectRatioType } from "../../core/types";
|
|
4
4
|
|
|
5
5
|
interface HeaderProps {
|
|
@@ -10,6 +10,9 @@ interface HeaderProps {
|
|
|
10
10
|
onAutoFit?: () => void;
|
|
11
11
|
isAutoFit?: boolean;
|
|
12
12
|
onOpenTemplates: () => void;
|
|
13
|
+
isTemplateDrawerOpen: boolean;
|
|
14
|
+
templateName?: string;
|
|
15
|
+
onSaveTemplate: () => void;
|
|
13
16
|
onOpenAuth: () => void;
|
|
14
17
|
onExport: () => void;
|
|
15
18
|
isExporting: boolean;
|
|
@@ -20,7 +23,7 @@ interface HeaderProps {
|
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
const RATIO_ITEMS: Array<{ id: AspectRatioType; label: string; ratioTag: string }> = [
|
|
23
|
-
{ id: "wechat-cover-2.35", label: "微信封面", ratioTag: "
|
|
26
|
+
{ id: "wechat-cover-2.35", label: "微信封面", ratioTag: "3.35:1" },
|
|
24
27
|
{ id: "longform-3-4", label: "通用长图文", ratioTag: "3:4" },
|
|
25
28
|
{ id: "wide-16-9", label: "通用宽屏", ratioTag: "16:9" },
|
|
26
29
|
{ id: "square-1-1", label: "通用正方形", ratioTag: "1:1" },
|
|
@@ -34,6 +37,9 @@ export const Header: React.FC<HeaderProps> = ({
|
|
|
34
37
|
onAutoFit,
|
|
35
38
|
isAutoFit,
|
|
36
39
|
onOpenTemplates,
|
|
40
|
+
isTemplateDrawerOpen,
|
|
41
|
+
templateName,
|
|
42
|
+
onSaveTemplate,
|
|
37
43
|
onOpenAuth,
|
|
38
44
|
onExport,
|
|
39
45
|
isExporting,
|
|
@@ -45,17 +51,17 @@ export const Header: React.FC<HeaderProps> = ({
|
|
|
45
51
|
return (
|
|
46
52
|
<header className="h-14 border-b border-[#eaeaea] dark:border-[#222222] bg-white/80 dark:bg-[#000000]/80 backdrop-blur-md px-4 flex items-center justify-between select-none z-30 shrink-0 transition-colors">
|
|
47
53
|
{/* Brand & Status */}
|
|
48
|
-
<div className="flex items-center gap-3">
|
|
54
|
+
<div className="flex min-w-0 items-center gap-3">
|
|
49
55
|
<div className="flex items-center gap-2 font-semibold text-[#171717] dark:text-white tracking-tight text-sm">
|
|
50
56
|
<span className="flex items-center justify-center w-6 h-6 rounded bg-black dark:bg-white text-white dark:text-black font-black text-xs shadow-sm">
|
|
51
57
|
▲
|
|
52
58
|
</span>
|
|
53
|
-
<span>Typeset Studio</span>
|
|
59
|
+
<span className="hidden 2xl:inline">Typeset Studio</span>
|
|
54
60
|
</div>
|
|
55
|
-
<span className="text-xs text-[#888888] dark:text-[#555555] font-mono hidden
|
|
56
|
-
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-[#f5f5f5] dark:bg-[#111111] border border-[#e5e5e5] dark:border-[#222222] text-[11px] text-emerald-600 dark:text-[#00e676]">
|
|
57
|
-
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 dark:bg-[#00e676]
|
|
58
|
-
<span className="
|
|
61
|
+
<span className="text-xs text-[#888888] dark:text-[#555555] font-mono hidden 2xl:inline">v0.2.7</span>
|
|
62
|
+
<div title={templateName || "未命名模板"} className="flex min-w-0 items-center gap-1.5 px-2 py-0.5 rounded-full bg-[#f5f5f5] dark:bg-[#111111] border border-[#e5e5e5] dark:border-[#222222] text-[11px] text-emerald-600 dark:text-[#00e676]">
|
|
63
|
+
<span className="w-1.5 h-1.5 shrink-0 rounded-full bg-emerald-500 dark:bg-[#00e676]" />
|
|
64
|
+
<span aria-live="polite" className="max-w-40 truncate">{templateName || "未命名模板"}</span>
|
|
59
65
|
</div>
|
|
60
66
|
</div>
|
|
61
67
|
|
|
@@ -73,7 +79,7 @@ export const Header: React.FC<HeaderProps> = ({
|
|
|
73
79
|
: "text-[#666666] dark:text-[#888888] hover:text-[#171717] dark:hover:text-[#cccccc] hover:bg-[#eaeaea] dark:hover:bg-[#141414]"
|
|
74
80
|
}`}
|
|
75
81
|
>
|
|
76
|
-
<span>{item.label}</span>
|
|
82
|
+
<span className="hidden xl:inline">{item.label}</span>
|
|
77
83
|
<span
|
|
78
84
|
className={`text-[10px] font-mono px-1 py-0.2 rounded ${
|
|
79
85
|
isActive
|
|
@@ -146,15 +152,24 @@ export const Header: React.FC<HeaderProps> = ({
|
|
|
146
152
|
)}
|
|
147
153
|
</button>
|
|
148
154
|
|
|
149
|
-
{/*
|
|
155
|
+
{/* 模板库开关 */}
|
|
150
156
|
<button
|
|
151
157
|
onClick={onOpenTemplates}
|
|
158
|
+
aria-expanded={isTemplateDrawerOpen}
|
|
152
159
|
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-[#eaeaea] dark:border-[#222222] bg-white dark:bg-[#0a0a0a] hover:bg-[#f5f5f5] dark:hover:bg-[#161616] text-xs text-[#333333] dark:text-[#cccccc] hover:text-black dark:hover:text-white transition-all btn-vercel"
|
|
153
160
|
>
|
|
154
161
|
<FolderOpen className="w-3.5 h-3.5 text-[#777777] dark:text-[#888888]" />
|
|
155
162
|
<span>模板库</span>
|
|
156
163
|
</button>
|
|
157
164
|
|
|
165
|
+
<button
|
|
166
|
+
onClick={onSaveTemplate}
|
|
167
|
+
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-[#eaeaea] dark:border-[#222222] bg-white dark:bg-[#0a0a0a] hover:bg-[#f5f5f5] dark:hover:bg-[#161616] text-xs text-[#333333] dark:text-[#cccccc] transition-all btn-vercel"
|
|
168
|
+
>
|
|
169
|
+
<Save className="w-3.5 h-3.5" />
|
|
170
|
+
<span>保存模板</span>
|
|
171
|
+
</button>
|
|
172
|
+
|
|
158
173
|
{/* Export image button */}
|
|
159
174
|
<button
|
|
160
175
|
onClick={onExport}
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
TypesetConfig,
|
|
19
19
|
} from "../../core/types";
|
|
20
20
|
import { useLocalFonts } from "../hooks/useLocalFonts";
|
|
21
|
+
import { CanvasStyleInspector } from "./CanvasStyleInspector";
|
|
21
22
|
|
|
22
23
|
interface ParamsInspectorProps {
|
|
23
24
|
config: TypesetConfig;
|
|
@@ -27,6 +28,7 @@ interface ParamsInspectorProps {
|
|
|
27
28
|
onUpdateSafeArea: (partial: Partial<AlgorithmParams["safeArea"]>) => void;
|
|
28
29
|
onUpdateBranding: (footer: string, watermark: string) => void;
|
|
29
30
|
onUpdateChrome: (partial: Partial<TypesetChromeConfig>) => void;
|
|
31
|
+
onUpdateCanvasStyle: (style: NonNullable<TypesetConfig["canvasStyle"]>) => void;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
const PRESET_FONT_OPTIONS = [
|
|
@@ -48,6 +50,7 @@ export const ParamsInspector: React.FC<ParamsInspectorProps> = ({
|
|
|
48
50
|
onUpdateSafeArea,
|
|
49
51
|
onUpdateBranding,
|
|
50
52
|
onUpdateChrome,
|
|
53
|
+
onUpdateCanvasStyle,
|
|
51
54
|
}) => {
|
|
52
55
|
const { algorithm, theme, typography } = config;
|
|
53
56
|
const { fonts: localFonts, loading: loadingFonts, refreshFonts } = useLocalFonts();
|
|
@@ -479,7 +482,7 @@ export const ParamsInspector: React.FC<ParamsInspectorProps> = ({
|
|
|
479
482
|
<div className="flex items-center gap-1.5">
|
|
480
483
|
<input
|
|
481
484
|
type="color"
|
|
482
|
-
value={theme.bgColor}
|
|
485
|
+
value={config.canvasStyle?.fill?.type === "solid" ? config.canvasStyle.fill.color : theme.bgColor}
|
|
483
486
|
onChange={(e) => onUpdateTheme({ bgColor: e.target.value })}
|
|
484
487
|
className="w-6 h-6 rounded cursor-pointer bg-transparent border-0"
|
|
485
488
|
/>
|
|
@@ -491,6 +494,8 @@ export const ParamsInspector: React.FC<ParamsInspectorProps> = ({
|
|
|
491
494
|
</div>
|
|
492
495
|
</div>
|
|
493
496
|
|
|
497
|
+
<CanvasStyleInspector config={config} onChange={onUpdateCanvasStyle} />
|
|
498
|
+
|
|
494
499
|
{/* Section 6: Header, Footer & Chrome Decor */}
|
|
495
500
|
<div className="p-3.5">
|
|
496
501
|
<div className="flex items-center gap-2 text-xs font-semibold text-[#171717] dark:text-white mb-3">
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Save, X } from "lucide-react";
|
|
3
|
+
import { validateTemplateAssets } from "../../core/template";
|
|
4
|
+
import { TemplateSlot, TypesetConfig } from "../../core/types";
|
|
5
|
+
|
|
6
|
+
interface SaveTemplateModalProps {
|
|
7
|
+
isOpen: boolean;
|
|
8
|
+
onClose: () => void;
|
|
9
|
+
currentConfig: TypesetConfig;
|
|
10
|
+
onSaved: (template: TypesetConfig) => void;
|
|
11
|
+
adminKey: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const SaveTemplateModal: React.FC<SaveTemplateModalProps> = ({
|
|
15
|
+
isOpen, onClose, currentConfig, onSaved, adminKey,
|
|
16
|
+
}) => {
|
|
17
|
+
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
18
|
+
const [templateName, setTemplateName] = useState("");
|
|
19
|
+
const [saveSlot, setSaveSlot] = useState<TemplateSlot | "none">("none");
|
|
20
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
21
|
+
const [error, setError] = useState<string | null>(null);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (isOpen) {
|
|
25
|
+
setTemplateName(currentConfig.name || "我的自定义模板");
|
|
26
|
+
setSaveSlot(currentConfig.slot || (currentConfig.aspectRatio === "wechat-cover-2.35" ? "article_cover" : "newspic_content"));
|
|
27
|
+
setError(null);
|
|
28
|
+
dialogRef.current?.showModal();
|
|
29
|
+
} else {
|
|
30
|
+
dialogRef.current?.close();
|
|
31
|
+
}
|
|
32
|
+
}, [isOpen]);
|
|
33
|
+
|
|
34
|
+
// 始终另存为新模板,保留当前图片与排版,不覆盖来源模板。
|
|
35
|
+
const handleSave = async (event: React.FormEvent) => {
|
|
36
|
+
event.preventDefault();
|
|
37
|
+
if (!templateName.trim() || isSaving) return;
|
|
38
|
+
setIsSaving(true);
|
|
39
|
+
setError(null);
|
|
40
|
+
try {
|
|
41
|
+
validateTemplateAssets(currentConfig.images);
|
|
42
|
+
const res = await fetch("/api/templates", {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: {
|
|
45
|
+
"Content-Type": "application/json",
|
|
46
|
+
...(adminKey ? { "x-admin-key": adminKey } : {}),
|
|
47
|
+
},
|
|
48
|
+
body: JSON.stringify({
|
|
49
|
+
...currentConfig,
|
|
50
|
+
id: undefined,
|
|
51
|
+
name: templateName.trim(),
|
|
52
|
+
slot: saveSlot === "none" ? undefined : saveSlot,
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
const data = await res.json();
|
|
56
|
+
if (!res.ok || !data.ok) throw new Error(data.message || data.error || "保存模板失败");
|
|
57
|
+
onSaved(data.template);
|
|
58
|
+
onClose();
|
|
59
|
+
} catch (err) {
|
|
60
|
+
setError(err instanceof Error ? err.message : "保存模板请求异常");
|
|
61
|
+
} finally {
|
|
62
|
+
setIsSaving(false);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<dialog
|
|
68
|
+
ref={dialogRef}
|
|
69
|
+
aria-labelledby="save-template-title"
|
|
70
|
+
onCancel={(event) => {
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
if (!isSaving) onClose();
|
|
73
|
+
}}
|
|
74
|
+
className="m-auto w-[calc(100%-2rem)] max-w-sm p-0 rounded-xl border border-[#eaeaea] dark:border-[#262626] bg-white dark:bg-[#0f0f0f] text-[#171717] dark:text-white shadow-2xl backdrop:bg-black/40"
|
|
75
|
+
>
|
|
76
|
+
<form onSubmit={handleSave}>
|
|
77
|
+
<div className="flex items-center justify-between px-5 py-3.5 border-b border-[#eaeaea] dark:border-[#222222]">
|
|
78
|
+
<h2 id="save-template-title" className="text-sm font-semibold">保存模板</h2>
|
|
79
|
+
<button type="button" onClick={onClose} disabled={isSaving} aria-label="关闭保存模板" className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/10 disabled:opacity-50">
|
|
80
|
+
<X className="w-4 h-4" />
|
|
81
|
+
</button>
|
|
82
|
+
</div>
|
|
83
|
+
<div className="p-5 flex flex-col gap-4">
|
|
84
|
+
<label className="flex flex-col gap-2 text-xs">
|
|
85
|
+
模板名称
|
|
86
|
+
<input autoFocus required value={templateName} onChange={(event) => setTemplateName(event.target.value)} placeholder="输入模板名称" className="bg-transparent border border-[#d4d4d4] dark:border-[#333333] rounded px-3 py-2 focus:outline-none focus:border-emerald-500" />
|
|
87
|
+
</label>
|
|
88
|
+
<label className="flex flex-col gap-2 text-xs">
|
|
89
|
+
保存到槽位
|
|
90
|
+
<select value={saveSlot} onChange={(event) => setSaveSlot(event.target.value as TemplateSlot | "none")} className="bg-white dark:bg-[#141414] border border-[#d4d4d4] dark:border-[#333333] rounded px-3 py-2">
|
|
91
|
+
<option value="article_cover">文章封面 (3.35:1)</option>
|
|
92
|
+
<option value="newspic_cover">贴图封面 (3:4)</option>
|
|
93
|
+
<option value="newspic_content">贴图内容 (3:4)</option>
|
|
94
|
+
<option value="none">通用模板</option>
|
|
95
|
+
</select>
|
|
96
|
+
</label>
|
|
97
|
+
{error && <p role="alert" className="text-xs text-red-500">{error}</p>}
|
|
98
|
+
<button type="submit" disabled={isSaving || !templateName.trim()} className="flex items-center justify-center gap-2 px-3 py-2 rounded bg-black text-white dark:bg-white dark:text-black text-xs font-semibold disabled:opacity-50">
|
|
99
|
+
<Save className="w-3.5 h-3.5" />
|
|
100
|
+
{isSaving ? "保存中..." : "保存模板"}
|
|
101
|
+
</button>
|
|
102
|
+
</div>
|
|
103
|
+
</form>
|
|
104
|
+
</dialog>
|
|
105
|
+
);
|
|
106
|
+
};
|