@akairos/typeset-studio 0.2.2 → 0.2.3
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 +90 -93
- package/dist/assets/index-3Ko1UCBU.css +1 -0
- package/dist/assets/index-C9J0f4Lf.js +317 -0
- package/dist/index.html +2 -2
- package/dist/lib/adapter.js +6 -7582
- package/dist/lib/canvas-renderer-cxnar75n.js +23 -0
- package/dist/lib/font-loader-j28mt43t.js +11 -0
- package/dist/lib/index-0370qz9e.js +303 -0
- package/dist/lib/index-37x76zdn.js +4 -0
- package/dist/lib/index-6c43c7h4.js +3880 -0
- package/dist/lib/index-t9w3p2p9.js +3366 -0
- package/dist/lib/index-zdhqg5f0.js +459 -0
- package/dist/lib/index.js +26 -7582
- package/dist/lib/render-xxd348m1.js +11 -0
- package/package.json +2 -3
- package/src/adapter.test.ts +117 -0
- package/src/adapter.ts +121 -30
- package/src/core/canvas-draw.ts +141 -38
- package/src/core/canvas-renderer.test.ts +58 -0
- package/src/core/canvas-renderer.ts +2 -1
- package/src/core/data-dir.ts +13 -0
- package/src/core/engine.ts +74 -36
- package/src/core/font-loader.test.ts +20 -0
- package/src/core/font-loader.ts +6 -2
- package/src/core/image-layout.ts +4 -0
- package/src/core/inline-text.ts +6 -4
- package/src/core/obstacle-flow.ts +29 -15
- package/src/core/page-images.test.ts +90 -0
- package/src/core/pretext.ts +4 -2
- package/src/core/template.test.ts +38 -1
- package/src/core/template.ts +52 -40
- package/src/core/types.ts +46 -0
- package/src/index.ts +4 -1
- package/src/server/auth.ts +18 -13
- package/src/server/index.ts +10 -15
- package/src/server/routes/render.ts +60 -17
- package/src/server/routes/slots.test.ts +96 -0
- package/src/server/routes/templates.ts +34 -35
- package/src/server/routes/upload.ts +2 -1
- package/src/web/App.tsx +15 -4
- package/src/web/components/CanvasViewport.tsx +56 -42
- package/src/web/components/EditorSidebar.tsx +35 -0
- package/src/web/components/TemplateModal.tsx +22 -14
- package/src/web/hooks/image-layout.ts +31 -0
- package/src/web/hooks/useLocalFonts.ts +10 -1
- package/src/web/hooks/useTypeset.ts +119 -32
- package/data/templates/.gitkeep +0 -0
- package/data/templates/tpl_1791681833354_iofsk.json +0 -46
- package/data/templates/tpl_1791682603199_875ny.json +0 -66
- package/data/templates/tpl_1791682780929_qnuiy.json +0 -66
- package/data/templates/tpl_longform_paper_sage.json +0 -47
- package/data/templates/tpl_longform_vintage_news.json +0 -47
- package/data/templates/tpl_newspic_poster_cover.json +0 -64
- package/data/templates/tpl_square_quote_card.json +0 -46
- package/data/templates/tpl_wechat_cover_editorial.json +0 -64
- package/data/templates/tpl_wechat_cover_minimal.json +0 -64
- package/data/templates/tpl_wide_tech_banner.json +0 -46
- package/dist/assets/index-CZ352D7X.css +0 -1
- package/dist/assets/index-DDeEGeHD.js +0 -317
|
@@ -1,23 +1,12 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
2
|
-
import { dirname, join
|
|
3
|
-
import { fileURLToPath } from "url";
|
|
2
|
+
import { dirname, join } from "path";
|
|
4
3
|
import { Hono } from "hono";
|
|
5
4
|
import { getDefaultConfig } from "../../core/engine";
|
|
6
5
|
import { TemplateSlot, TypesetConfig } from "../../core/types";
|
|
6
|
+
import { resolveDataDir } from "../../core/data-dir";
|
|
7
|
+
import { validateTemplateAssets } from "../../core/template";
|
|
7
8
|
|
|
8
|
-
export
|
|
9
|
-
if (process.env.TYPESET_DATA_DIR) {
|
|
10
|
-
return process.env.TYPESET_DATA_DIR;
|
|
11
|
-
}
|
|
12
|
-
const currentDir = typeof __dirname !== "undefined" ? __dirname : dirname(fileURLToPath(import.meta.url));
|
|
13
|
-
const candidateLib = resolve(currentDir, "../../data");
|
|
14
|
-
if (existsSync(candidateLib)) return candidateLib;
|
|
15
|
-
const candidateSrc = resolve(currentDir, "../../../data");
|
|
16
|
-
if (existsSync(candidateSrc)) return candidateSrc;
|
|
17
|
-
const candidateCwd = resolve(process.cwd(), "data");
|
|
18
|
-
if (existsSync(candidateCwd)) return candidateCwd;
|
|
19
|
-
return candidateLib;
|
|
20
|
-
}
|
|
9
|
+
export { resolveDataDir } from "../../core/data-dir";
|
|
21
10
|
|
|
22
11
|
const TEMPLATES_DIR = join(resolveDataDir(), "templates");
|
|
23
12
|
|
|
@@ -29,7 +18,11 @@ function ensureTemplatesDir(): void {
|
|
|
29
18
|
// Ignore write errors in read-only environments
|
|
30
19
|
}
|
|
31
20
|
}
|
|
32
|
-
|
|
21
|
+
const seedMarker = join(TEMPLATES_DIR, ".initialized");
|
|
22
|
+
if (!existsSync(seedMarker)) {
|
|
23
|
+
seedDefaultTemplates();
|
|
24
|
+
try { writeFileSync(seedMarker, "1"); } catch { /* 只读安装目录保留读取能力。 */ }
|
|
25
|
+
}
|
|
33
26
|
}
|
|
34
27
|
|
|
35
28
|
export const BUILTIN_PRESETS: TypesetConfig[] = [
|
|
@@ -383,7 +376,7 @@ export function getSlotDefaultTemplateId(slot: TemplateSlot, account = "default"
|
|
|
383
376
|
}
|
|
384
377
|
|
|
385
378
|
export function setSlotDefaultTemplateId(slot: TemplateSlot, templateId: string, account = "default"): void {
|
|
386
|
-
const dir =
|
|
379
|
+
const dir = dirname(DEFAULT_SETTINGS_PATH);
|
|
387
380
|
if (!existsSync(dir)) {
|
|
388
381
|
mkdirSync(dir, { recursive: true });
|
|
389
382
|
}
|
|
@@ -420,7 +413,7 @@ export function getDefaultTemplateId(): string | null {
|
|
|
420
413
|
}
|
|
421
414
|
|
|
422
415
|
export function setDefaultTemplateId(id: string): void {
|
|
423
|
-
const dir =
|
|
416
|
+
const dir = dirname(DEFAULT_SETTINGS_PATH);
|
|
424
417
|
if (!existsSync(dir)) {
|
|
425
418
|
mkdirSync(dir, { recursive: true });
|
|
426
419
|
}
|
|
@@ -439,21 +432,13 @@ export function setDefaultTemplateId(id: string): void {
|
|
|
439
432
|
export function findTemplateByKeyOrName(key: string): TypesetConfig | null {
|
|
440
433
|
const lowerKey = key.trim().toLowerCase();
|
|
441
434
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
if (builtin) return builtin;
|
|
447
|
-
|
|
448
|
-
// 2. Partial match in built-in memory presets
|
|
449
|
-
const builtinPartial = BUILTIN_PRESETS.find((p) => p.name.toLowerCase().includes(lowerKey));
|
|
450
|
-
if (builtinPartial) return builtinPartial;
|
|
451
|
-
|
|
452
|
-
// 3. Scan disk if templates directory exists
|
|
453
|
-
if (!existsSync(TEMPLATES_DIR)) return null;
|
|
435
|
+
if (!lowerKey) return null;
|
|
436
|
+
ensureTemplatesDir();
|
|
437
|
+
if (!existsSync(TEMPLATES_DIR)) return BUILTIN_PRESETS.find((p) =>
|
|
438
|
+
p.id?.toLowerCase() === lowerKey || p.name.toLowerCase() === lowerKey) ?? null;
|
|
454
439
|
|
|
455
|
-
const directPath =
|
|
456
|
-
if (existsSync(directPath)) {
|
|
440
|
+
const directPath = templatePath(key);
|
|
441
|
+
if (directPath && existsSync(directPath)) {
|
|
457
442
|
try {
|
|
458
443
|
return JSON.parse(readFileSync(directPath, "utf-8"));
|
|
459
444
|
} catch {
|
|
@@ -482,6 +467,11 @@ export function findTemplateByKeyOrName(key: string): TypesetConfig | null {
|
|
|
482
467
|
return templates.find((tpl) => tpl.name.toLowerCase().includes(lowerKey)) ?? null;
|
|
483
468
|
}
|
|
484
469
|
|
|
470
|
+
// 模板标识只用作文件名,名称查询继续支持中文和模糊匹配。
|
|
471
|
+
function templatePath(id: string): string | null {
|
|
472
|
+
return /^[a-zA-Z0-9_-]+$/.test(id) ? join(TEMPLATES_DIR, `${id}.json`) : null;
|
|
473
|
+
}
|
|
474
|
+
|
|
485
475
|
export const templatesRouter = new Hono();
|
|
486
476
|
|
|
487
477
|
// List all templates
|
|
@@ -599,7 +589,8 @@ templatesRouter.get("/resolve/:key", (c) => {
|
|
|
599
589
|
templatesRouter.get("/:id", (c) => {
|
|
600
590
|
ensureTemplatesDir();
|
|
601
591
|
const id = c.req.param("id");
|
|
602
|
-
const filePath =
|
|
592
|
+
const filePath = templatePath(id);
|
|
593
|
+
if (!filePath) return c.json({ ok: false, error: "Invalid template id" }, 400);
|
|
603
594
|
|
|
604
595
|
if (!existsSync(filePath)) {
|
|
605
596
|
return c.json({ ok: false, error: "Template not found" }, 404);
|
|
@@ -618,13 +609,20 @@ templatesRouter.post("/", async (c) => {
|
|
|
618
609
|
return c.json({ ok: false, error: "Template name is required" }, 400);
|
|
619
610
|
}
|
|
620
611
|
|
|
612
|
+
try {
|
|
613
|
+
validateTemplateAssets(body.images);
|
|
614
|
+
} catch (error) {
|
|
615
|
+
return c.json({ ok: false, error: error instanceof Error ? error.message : "无效的模板素材" }, 400);
|
|
616
|
+
}
|
|
617
|
+
|
|
621
618
|
const id = body.id || `tpl_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
|
|
622
619
|
const templateToSave: TypesetConfig = {
|
|
623
620
|
...body,
|
|
624
621
|
id,
|
|
625
622
|
};
|
|
626
623
|
|
|
627
|
-
const filePath =
|
|
624
|
+
const filePath = templatePath(id);
|
|
625
|
+
if (!filePath) return c.json({ ok: false, error: "Invalid template id" }, 400);
|
|
628
626
|
writeFileSync(filePath, JSON.stringify(templateToSave, null, 2), "utf-8");
|
|
629
627
|
|
|
630
628
|
return c.json({ ok: true, template: templateToSave });
|
|
@@ -634,7 +632,8 @@ templatesRouter.post("/", async (c) => {
|
|
|
634
632
|
templatesRouter.delete("/:id", (c) => {
|
|
635
633
|
ensureTemplatesDir();
|
|
636
634
|
const id = c.req.param("id");
|
|
637
|
-
const filePath =
|
|
635
|
+
const filePath = templatePath(id);
|
|
636
|
+
if (!filePath) return c.json({ ok: false, error: "Invalid template id" }, 400);
|
|
638
637
|
|
|
639
638
|
if (existsSync(filePath)) {
|
|
640
639
|
unlinkSync(filePath);
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import { Hono } from "hono";
|
|
4
|
+
import { resolveDataDir } from "../../core/data-dir";
|
|
4
5
|
|
|
5
|
-
const UPLOAD_DIR = join(
|
|
6
|
+
const UPLOAD_DIR = join(resolveDataDir(), "uploads");
|
|
6
7
|
|
|
7
8
|
function ensureUploadDir(): void {
|
|
8
9
|
if (!existsSync(UPLOAD_DIR)) {
|
package/src/web/App.tsx
CHANGED
|
@@ -9,7 +9,9 @@ import { useAuth } from "./hooks/useAuth";
|
|
|
9
9
|
import { useTypeset } from "./hooks/useTypeset";
|
|
10
10
|
|
|
11
11
|
export const App: React.FC = () => {
|
|
12
|
+
const { adminKey, setAdminKey, isVerified } = useAuth();
|
|
12
13
|
const {
|
|
14
|
+
error: templateError,
|
|
13
15
|
aspectRatio,
|
|
14
16
|
switchAspectRatio,
|
|
15
17
|
config,
|
|
@@ -18,6 +20,9 @@ export const App: React.FC = () => {
|
|
|
18
20
|
images,
|
|
19
21
|
addImage,
|
|
20
22
|
updateImage,
|
|
23
|
+
toggleImageBackground,
|
|
24
|
+
resetImageLayout,
|
|
25
|
+
backgroundImageIds,
|
|
21
26
|
removeImage,
|
|
22
27
|
moveImage,
|
|
23
28
|
highlightWords,
|
|
@@ -29,9 +34,7 @@ export const App: React.FC = () => {
|
|
|
29
34
|
updateSafeArea,
|
|
30
35
|
loadTemplate,
|
|
31
36
|
setConfig,
|
|
32
|
-
} = useTypeset();
|
|
33
|
-
|
|
34
|
-
const { adminKey, setAdminKey, isVerified } = useAuth();
|
|
37
|
+
} = useTypeset(adminKey, isVerified);
|
|
35
38
|
|
|
36
39
|
// Default to Light Mode per user requirement
|
|
37
40
|
const [themeMode, setThemeMode] = useState<"light" | "dark">(() => {
|
|
@@ -179,6 +182,9 @@ export const App: React.FC = () => {
|
|
|
179
182
|
setIsExporting(true);
|
|
180
183
|
try {
|
|
181
184
|
const canvasElements = document.querySelectorAll<HTMLCanvasElement>("canvas.typeset-page");
|
|
185
|
+
if ([...canvasElements].some((canvas) => canvas.dataset.renderState !== "ready")) {
|
|
186
|
+
throw new Error("画布尚未完成绘制,请检查素材或稍后再导出");
|
|
187
|
+
}
|
|
182
188
|
if (canvasElements.length > 0) {
|
|
183
189
|
let exportSucceeded = true;
|
|
184
190
|
for (let i = 0; i < canvasElements.length; i++) {
|
|
@@ -229,7 +235,7 @@ export const App: React.FC = () => {
|
|
|
229
235
|
}
|
|
230
236
|
} catch (err) {
|
|
231
237
|
console.error("Export failed:", err);
|
|
232
|
-
alert("导出失败,请检查网络或后端服务连接");
|
|
238
|
+
alert(err instanceof Error ? err.message : "导出失败,请检查网络或后端服务连接");
|
|
233
239
|
} finally {
|
|
234
240
|
setIsExporting(false);
|
|
235
241
|
}
|
|
@@ -255,6 +261,8 @@ export const App: React.FC = () => {
|
|
|
255
261
|
onToggleThemeMode={toggleThemeMode}
|
|
256
262
|
/>
|
|
257
263
|
|
|
264
|
+
{templateError && <div role="alert" className="px-4 py-2 text-sm text-red-600">{templateError}</div>}
|
|
265
|
+
|
|
258
266
|
{/* Main Workspace (3-column layout) */}
|
|
259
267
|
<div className="flex flex-1 min-h-0 overflow-hidden relative">
|
|
260
268
|
{/* Left: Content Editor Sidebar */}
|
|
@@ -263,6 +271,9 @@ export const App: React.FC = () => {
|
|
|
263
271
|
onMarkdownChange={setMarkdown}
|
|
264
272
|
images={images}
|
|
265
273
|
onAddImage={addImage}
|
|
274
|
+
onToggleBackground={toggleImageBackground}
|
|
275
|
+
onResetImageLayout={resetImageLayout}
|
|
276
|
+
backgroundImageIds={backgroundImageIds}
|
|
266
277
|
onUpdateImage={updateImage}
|
|
267
278
|
onRemoveImage={removeImage}
|
|
268
279
|
onMoveImage={moveImage}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useEffect, useRef } from "react";
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
2
2
|
import { LayoutGrid, Rows, ZoomIn } from "lucide-react";
|
|
3
3
|
import { drawPageToContext } from "../../core/canvas-draw";
|
|
4
4
|
import { CanvasSpec, FlowPage, TypesetConfig, TypesetResult } from "../../core/types";
|
|
@@ -19,6 +19,7 @@ const CanvasCard: React.FC<CanvasCardProps> = ({
|
|
|
19
19
|
zoom,
|
|
20
20
|
}) => {
|
|
21
21
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
22
|
+
const [paintError, setPaintError] = useState<string | null>(null);
|
|
22
23
|
|
|
23
24
|
useEffect(() => {
|
|
24
25
|
const canvas = canvasRef.current;
|
|
@@ -26,69 +27,82 @@ const CanvasCard: React.FC<CanvasCardProps> = ({
|
|
|
26
27
|
const ctx = canvas.getContext("2d");
|
|
27
28
|
if (!ctx) return;
|
|
28
29
|
|
|
30
|
+
canvas.dataset.renderState = "loading";
|
|
31
|
+
setPaintError(null);
|
|
29
32
|
let cancelled = false;
|
|
33
|
+
const controller = new AbortController();
|
|
30
34
|
|
|
31
35
|
const getImage = (src: string) => {
|
|
32
36
|
if (!src) return Promise.resolve(null);
|
|
33
37
|
return new Promise((resolve) => {
|
|
34
38
|
const img = new Image();
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
let fallback: HTMLImageElement | undefined;
|
|
40
|
+
const finish = (image: HTMLImageElement | null) => {
|
|
41
|
+
controller.signal.removeEventListener("abort", abort);
|
|
42
|
+
img.onload = img.onerror = null;
|
|
43
|
+
if (fallback) fallback.onload = fallback.onerror = null;
|
|
44
|
+
resolve(image);
|
|
38
45
|
};
|
|
46
|
+
const abort = () => finish(null);
|
|
47
|
+
if (controller.signal.aborted) { finish(null); return; }
|
|
48
|
+
controller.signal.addEventListener("abort", abort, { once: true });
|
|
49
|
+
img.crossOrigin = "anonymous";
|
|
50
|
+
img.onload = () => finish(img);
|
|
39
51
|
img.onerror = () => {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
img2.onerror = () => {
|
|
45
|
-
if (!cancelled) resolve(null);
|
|
46
|
-
};
|
|
47
|
-
img2.src = src;
|
|
52
|
+
fallback = new Image();
|
|
53
|
+
fallback.onload = () => finish(fallback!);
|
|
54
|
+
fallback.onerror = () => finish(null);
|
|
55
|
+
fallback.src = src;
|
|
48
56
|
};
|
|
49
57
|
img.src = src;
|
|
50
58
|
});
|
|
51
59
|
};
|
|
52
60
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
isServer: false,
|
|
61
|
+
// 每次绘制使用独立缓冲画布,只提交当前代的完整结果。
|
|
62
|
+
const paint = async () => {
|
|
63
|
+
const buffer = document.createElement("canvas");
|
|
64
|
+
buffer.width = spec.width;
|
|
65
|
+
buffer.height = spec.height;
|
|
66
|
+
const bufferContext = buffer.getContext("2d");
|
|
67
|
+
if (!bufferContext) return;
|
|
68
|
+
await drawPageToContext(bufferContext, {
|
|
69
|
+
page, spec, config, totalPageCount, getImage, isServer: false,
|
|
62
70
|
});
|
|
71
|
+
if (!cancelled) {
|
|
72
|
+
ctx.clearRect(0, 0, spec.width, spec.height);
|
|
73
|
+
ctx.drawImage(buffer, 0, 0);
|
|
74
|
+
canvas.dataset.renderState = "ready";
|
|
75
|
+
}
|
|
63
76
|
};
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
paint();
|
|
71
|
-
});
|
|
72
|
-
}
|
|
77
|
+
paint().catch((error) => {
|
|
78
|
+
if (cancelled) return;
|
|
79
|
+
canvas.dataset.renderState = "error";
|
|
80
|
+
ctx.clearRect(0, 0, spec.width, spec.height);
|
|
81
|
+
setPaintError(error instanceof Error ? error.message : "画布绘制失败");
|
|
82
|
+
});
|
|
73
83
|
|
|
74
84
|
return () => {
|
|
75
85
|
cancelled = true;
|
|
86
|
+
controller.abort();
|
|
76
87
|
};
|
|
77
88
|
}, [page, spec, config, totalPageCount]);
|
|
78
89
|
|
|
79
90
|
return (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
91
|
+
<>
|
|
92
|
+
<canvas
|
|
93
|
+
id={`typeset-page-${page.pageNumber}`}
|
|
94
|
+
ref={canvasRef}
|
|
95
|
+
width={spec.width}
|
|
96
|
+
height={spec.height}
|
|
97
|
+
className="typeset-page absolute top-0 left-0 border border-[#e0e0e0] dark:border-[#222222] shadow-sm select-none"
|
|
98
|
+
style={{
|
|
99
|
+
width: spec.width * zoom,
|
|
100
|
+
height: spec.height * zoom,
|
|
101
|
+
backgroundColor: config.theme.bgColor,
|
|
102
|
+
}}
|
|
103
|
+
/>
|
|
104
|
+
{paintError && <div role="alert" className="absolute inset-0 flex items-center justify-center bg-white/90 p-4 text-sm text-red-600">{paintError}</div>}
|
|
105
|
+
</>
|
|
92
106
|
);
|
|
93
107
|
};
|
|
94
108
|
|
|
@@ -28,6 +28,9 @@ interface EditorSidebarProps {
|
|
|
28
28
|
highlightWords: string[];
|
|
29
29
|
onHighlightWordsChange: (words: string[]) => void;
|
|
30
30
|
adminKey: string;
|
|
31
|
+
onToggleBackground: (id: string, enabled: boolean) => void;
|
|
32
|
+
onResetImageLayout: (id: string) => void;
|
|
33
|
+
backgroundImageIds: string[];
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
export const EditorSidebar: React.FC<EditorSidebarProps> = ({
|
|
@@ -35,6 +38,9 @@ export const EditorSidebar: React.FC<EditorSidebarProps> = ({
|
|
|
35
38
|
onMarkdownChange,
|
|
36
39
|
images,
|
|
37
40
|
onAddImage,
|
|
41
|
+
onToggleBackground,
|
|
42
|
+
onResetImageLayout,
|
|
43
|
+
backgroundImageIds,
|
|
38
44
|
onUpdateImage,
|
|
39
45
|
onRemoveImage,
|
|
40
46
|
onMoveImage,
|
|
@@ -342,6 +348,35 @@ export const EditorSidebar: React.FC<EditorSidebarProps> = ({
|
|
|
342
348
|
</button>
|
|
343
349
|
</div>
|
|
344
350
|
|
|
351
|
+
<div className="flex flex-col gap-2 text-[11px]">
|
|
352
|
+
<label className="flex items-center gap-2">
|
|
353
|
+
<input type="checkbox" checked={img.templateAssetKey !== undefined} onChange={(event) => onUpdateImage(img.id, {
|
|
354
|
+
templateAssetKey: event.target.checked ? `asset_${img.id.replace(/[^a-zA-Z0-9_-]/g, "_")}` : undefined,
|
|
355
|
+
repeatEveryPage: false,
|
|
356
|
+
})} />
|
|
357
|
+
固定模板素材
|
|
358
|
+
</label>
|
|
359
|
+
{img.templateAssetKey !== undefined && <>
|
|
360
|
+
<label className="flex flex-col gap-1">素材标识
|
|
361
|
+
<input aria-label="素材标识" value={img.templateAssetKey} onChange={(event) => onUpdateImage(img.id, { templateAssetKey: event.target.value })}
|
|
362
|
+
className="w-full bg-white dark:bg-[#181818] border border-[#d4d4d4] dark:border-[#262626] rounded px-1.5 py-1" />
|
|
363
|
+
</label>
|
|
364
|
+
{images.some((other) => other.id !== img.id && other.templateAssetKey === img.templateAssetKey) &&
|
|
365
|
+
<span role="alert" className="text-red-600">素材标识不能重复</span>}
|
|
366
|
+
<label className="flex items-center gap-2">
|
|
367
|
+
<input type="checkbox" disabled={!img.isAbsolute} checked={Boolean(img.isAbsolute && img.repeatEveryPage)} onChange={(event) => onUpdateImage(img.id, { repeatEveryPage: event.target.checked })} />
|
|
368
|
+
每页重复
|
|
369
|
+
</label>
|
|
370
|
+
{!img.isAbsolute && <span className="text-[#888888]">每页重复仅支持自由图层,避免阻塞文字排版。</span>}
|
|
371
|
+
<span className="text-[#888888]">默认保留此图,仅按素材标识明确替换。</span>
|
|
372
|
+
</>}
|
|
373
|
+
{img.isAbsolute && <label className="flex items-center gap-2">
|
|
374
|
+
<input type="checkbox" checked={backgroundImageIds.includes(img.id)} onChange={(event) => onToggleBackground(img.id, event.target.checked)} />
|
|
375
|
+
设为整页背景
|
|
376
|
+
</label>}
|
|
377
|
+
<button type="button" onClick={() => onResetImageLayout(img.id)} title="恢复上传或应用模板时的尺寸、位置和图层设置" className="rounded border border-[#d4d4d4] dark:border-[#333333] px-2 py-1.5 hover:bg-[#eeeeee] dark:hover:bg-[#222222]">重置布局</button>
|
|
378
|
+
</div>
|
|
379
|
+
|
|
345
380
|
{!img.isAbsolute ? (
|
|
346
381
|
<>
|
|
347
382
|
{/* Flow Settings */}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React, { useEffect, useState } from "react";
|
|
2
2
|
import { Check, FolderPlus, Trash2, X, Sparkles, Star } from "lucide-react";
|
|
3
|
+
import { validateTemplateAssets } from "../../core/template";
|
|
3
4
|
import { TemplateSlot, TypesetConfig } from "../../core/types";
|
|
4
5
|
|
|
5
6
|
interface TemplateModalProps {
|
|
@@ -13,6 +14,8 @@ interface TemplateModalProps {
|
|
|
13
14
|
interface SlotsResponse {
|
|
14
15
|
ok: boolean;
|
|
15
16
|
account: string;
|
|
17
|
+
message?: string;
|
|
18
|
+
error?: string;
|
|
16
19
|
slots: {
|
|
17
20
|
article_cover?: { id: string; template?: TypesetConfig };
|
|
18
21
|
newspic_cover?: { id: string; template?: TypesetConfig };
|
|
@@ -41,13 +44,14 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({
|
|
|
41
44
|
|
|
42
45
|
const fetchSlots = async (acc = account) => {
|
|
43
46
|
try {
|
|
44
|
-
const res = await fetch(`/api/templates/slots?account=${encodeURIComponent(acc)}
|
|
47
|
+
const res = await fetch(`/api/templates/slots?account=${encodeURIComponent(acc)}`, { headers: { "x-admin-key": adminKey } });
|
|
45
48
|
const data: SlotsResponse = await res.json();
|
|
49
|
+
if (!res.ok || !data.ok) throw new Error(data.message || data.error || "获取槽位失败");
|
|
46
50
|
if (data.ok && data.slots) {
|
|
47
51
|
setSlotsData(data.slots);
|
|
48
52
|
}
|
|
49
|
-
} catch {
|
|
50
|
-
|
|
53
|
+
} catch (err) {
|
|
54
|
+
setError(err instanceof Error ? err.message : "模板请求失败");
|
|
51
55
|
}
|
|
52
56
|
};
|
|
53
57
|
|
|
@@ -62,11 +66,12 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({
|
|
|
62
66
|
body: JSON.stringify({ slot, templateId, account }),
|
|
63
67
|
});
|
|
64
68
|
const data = await res.json();
|
|
69
|
+
if (!res.ok || !data.ok) throw new Error(data.message || data.error || "设置默认模板失败");
|
|
65
70
|
if (data.ok) {
|
|
66
71
|
await fetchSlots(account);
|
|
67
72
|
}
|
|
68
|
-
} catch {
|
|
69
|
-
|
|
73
|
+
} catch (err) {
|
|
74
|
+
setError(err instanceof Error ? err.message : "模板请求失败");
|
|
70
75
|
}
|
|
71
76
|
};
|
|
72
77
|
|
|
@@ -78,7 +83,7 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({
|
|
|
78
83
|
headers: adminKey ? { "x-admin-key": adminKey } : {},
|
|
79
84
|
});
|
|
80
85
|
const data = await res.json();
|
|
81
|
-
if (data.ok && Array.isArray(data.templates)) {
|
|
86
|
+
if (res.ok && data.ok && Array.isArray(data.templates)) {
|
|
82
87
|
setTemplates(data.templates);
|
|
83
88
|
} else {
|
|
84
89
|
setError(data.message || "获取模板失败,请检查密钥");
|
|
@@ -97,12 +102,13 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({
|
|
|
97
102
|
setTemplateName(currentConfig.name ? `${currentConfig.name} 副本` : "我的自定义模板");
|
|
98
103
|
setSaveSlot(currentConfig.aspectRatio === "wechat-cover-2.35" ? "article_cover" : "newspic_content");
|
|
99
104
|
}
|
|
100
|
-
}, [isOpen, account]);
|
|
105
|
+
}, [isOpen, account, adminKey]);
|
|
101
106
|
|
|
102
107
|
const handleSave = async () => {
|
|
103
108
|
if (!templateName.trim()) return;
|
|
104
109
|
setIsSaving(true);
|
|
105
110
|
try {
|
|
111
|
+
validateTemplateAssets(currentConfig.images);
|
|
106
112
|
const tplToSave: TypesetConfig = {
|
|
107
113
|
...currentConfig,
|
|
108
114
|
id: undefined, // auto generate
|
|
@@ -120,14 +126,14 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({
|
|
|
120
126
|
});
|
|
121
127
|
|
|
122
128
|
const data = await res.json();
|
|
123
|
-
if (data.ok) {
|
|
129
|
+
if (res.ok && data.ok) {
|
|
124
130
|
await fetchTemplates();
|
|
125
131
|
await fetchSlots(account);
|
|
126
132
|
} else {
|
|
127
|
-
setError(data.message || "保存模板失败");
|
|
133
|
+
setError(data.message || data.error || "保存模板失败");
|
|
128
134
|
}
|
|
129
|
-
} catch {
|
|
130
|
-
setError("保存模板请求异常");
|
|
135
|
+
} catch (err) {
|
|
136
|
+
setError(err instanceof Error ? err.message : "保存模板请求异常");
|
|
131
137
|
} finally {
|
|
132
138
|
setIsSaving(false);
|
|
133
139
|
}
|
|
@@ -135,14 +141,16 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({
|
|
|
135
141
|
|
|
136
142
|
const handleDelete = async (id: string) => {
|
|
137
143
|
try {
|
|
138
|
-
await fetch(`/api/templates/${id}`, {
|
|
144
|
+
const res = await fetch(`/api/templates/${encodeURIComponent(id)}`, {
|
|
139
145
|
method: "DELETE",
|
|
140
146
|
headers: adminKey ? { "x-admin-key": adminKey } : {},
|
|
141
147
|
});
|
|
148
|
+
const data = await res.json();
|
|
149
|
+
if (!res.ok || !data.ok) throw new Error(data.message || data.error || "删除模板失败");
|
|
142
150
|
setTemplates((prev) => prev.filter((t) => t.id !== id));
|
|
143
151
|
await fetchSlots(account);
|
|
144
|
-
} catch {
|
|
145
|
-
|
|
152
|
+
} catch (err) {
|
|
153
|
+
setError(err instanceof Error ? err.message : "模板请求失败");
|
|
146
154
|
}
|
|
147
155
|
};
|
|
148
156
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { FlowImage, TypesetConfig } from "../../core/types";
|
|
2
|
+
import { getCanvasSpec } from "../../core/geometry";
|
|
3
|
+
|
|
4
|
+
export type ImageLayout = Pick<FlowImage, "x" | "y" | "width" | "height" | "isAbsolute" | "layoutPreset" | "pageIndex" | "zIndex" | "rotate" | "opacity" | "borderRadius" | "captionHeight">;
|
|
5
|
+
|
|
6
|
+
// 仅保存布局,恢复时不覆盖图片来源、说明和固定素材标识。
|
|
7
|
+
export function snapshotImageLayout(image: FlowImage): ImageLayout {
|
|
8
|
+
return {
|
|
9
|
+
x: image.x, y: image.y, width: image.width, height: image.height,
|
|
10
|
+
isAbsolute: image.isAbsolute, layoutPreset: image.layoutPreset, pageIndex: image.pageIndex,
|
|
11
|
+
zIndex: image.zIndex, rotate: image.rotate, opacity: image.opacity,
|
|
12
|
+
borderRadius: image.borderRadius, captionHeight: image.captionHeight,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// 切回绕排或取消固定素材时关闭重复,防止图片占满每一页。
|
|
17
|
+
export function restoreImageLayout(image: FlowImage, layout: Partial<FlowImage>): FlowImage {
|
|
18
|
+
const restored = { ...image, ...layout };
|
|
19
|
+
if (!restored.isAbsolute || !restored.templateAssetKey) restored.repeatEveryPage = false;
|
|
20
|
+
return restored;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 背景使用整张画布,旋转归零,取消后由布局快照还原。
|
|
24
|
+
export function backgroundImageLayout(config: TypesetConfig): Partial<FlowImage> {
|
|
25
|
+
const spec = getCanvasSpec(config.aspectRatio);
|
|
26
|
+
return {
|
|
27
|
+
isAbsolute: true, layoutPreset: "custom", zIndex: 0, rotate: 0, borderRadius: 0, captionHeight: 0,
|
|
28
|
+
x: -config.algorithm.safeArea.left, y: -config.algorithm.safeArea.top,
|
|
29
|
+
width: spec.width, height: spec.height,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -14,6 +14,7 @@ export function useLocalFonts() {
|
|
|
14
14
|
if (!data.ok || !Array.isArray(data.fonts)) return;
|
|
15
15
|
|
|
16
16
|
setFonts(data.fonts);
|
|
17
|
+
let added = false;
|
|
17
18
|
|
|
18
19
|
// Register newly discovered fonts dynamically in browser DOM via FontFace API
|
|
19
20
|
if (typeof window !== "undefined" && "FontFace" in window && (document as any).fonts) {
|
|
@@ -21,22 +22,30 @@ export function useLocalFonts() {
|
|
|
21
22
|
try {
|
|
22
23
|
// Check if font is already added
|
|
23
24
|
let alreadyAdded = false;
|
|
25
|
+
const existing: FontFace[] = [];
|
|
24
26
|
(document.fonts as any).forEach((f: any) => {
|
|
25
27
|
if (f.family === font.family || f.family === `"${font.family}"`) {
|
|
26
28
|
alreadyAdded = true;
|
|
29
|
+
existing.push(f);
|
|
27
30
|
}
|
|
28
31
|
});
|
|
29
32
|
|
|
33
|
+
if (refresh) {
|
|
34
|
+
existing.forEach((face) => document.fonts.delete(face));
|
|
35
|
+
alreadyAdded = false;
|
|
36
|
+
}
|
|
30
37
|
if (!alreadyAdded) {
|
|
31
|
-
const fontFace = new FontFace(font.family, `url(${font.url})`);
|
|
38
|
+
const fontFace = new FontFace(font.family, `url(${font.url}${refresh ? `?v=${Date.now()}` : ""})`);
|
|
32
39
|
const loaded = await fontFace.load();
|
|
33
40
|
(document.fonts as any).add(loaded);
|
|
41
|
+
added = true;
|
|
34
42
|
}
|
|
35
43
|
} catch (err) {
|
|
36
44
|
console.warn(`[useLocalFonts] Failed to load web font ${font.family}:`, err);
|
|
37
45
|
}
|
|
38
46
|
}
|
|
39
47
|
}
|
|
48
|
+
if (added) window.dispatchEvent(new Event("typeset-fonts-loaded"));
|
|
40
49
|
} catch (err) {
|
|
41
50
|
console.warn("[useLocalFonts] Failed to fetch local fonts:", err);
|
|
42
51
|
} finally {
|