@akairos/typeset-studio 0.2.1 → 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 +91 -95
- 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 -7734
- 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 -7734
- package/dist/lib/render-xxd348m1.js +11 -0
- package/package.json +3 -7
- package/src/adapter.test.ts +117 -0
- package/src/adapter.ts +190 -281
- package/src/core/canvas-draw.ts +150 -47
- 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 +86 -56
- 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 -10
- package/src/core/template.test.ts +38 -1
- package/src/core/template.ts +53 -41
- package/src/core/types.ts +46 -0
- package/src/core/typography.ts +0 -14
- package/src/index.ts +6 -3
- package/src/server/auth.ts +18 -13
- package/src/server/index.ts +11 -16
- package/src/server/routes/fonts.ts +1 -1
- package/src/server/routes/render.ts +60 -17
- package/src/server/routes/slots.test.ts +96 -0
- package/src/server/routes/templates.ts +37 -48
- package/src/server/routes/upload.ts +2 -1
- package/src/web/App.tsx +15 -6
- package/src/web/components/CanvasViewport.tsx +56 -42
- package/src/web/components/EditorSidebar.tsx +59 -48
- package/src/web/components/Header.tsx +1 -1
- package/src/web/components/TemplateModal.tsx +25 -28
- package/src/web/hooks/image-layout.ts +31 -0
- package/src/web/hooks/useLocalFonts.ts +10 -1
- package/src/web/hooks/useTypeset.ts +139 -100
- 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-Dpnz-F2g.js +0 -317
|
@@ -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,
|
|
@@ -56,6 +62,26 @@ export const EditorSidebar: React.FC<EditorSidebarProps> = ({
|
|
|
56
62
|
const file = e.target.files?.[0];
|
|
57
63
|
if (!file) return;
|
|
58
64
|
|
|
65
|
+
// 上传失败时仍可用本地图片继续编辑。
|
|
66
|
+
const addUploadedImage = (src: string) => onAddImage({
|
|
67
|
+
src,
|
|
68
|
+
alt: file.name,
|
|
69
|
+
caption: "",
|
|
70
|
+
x: 40,
|
|
71
|
+
y: 400,
|
|
72
|
+
width: 440,
|
|
73
|
+
height: 300,
|
|
74
|
+
captionHeight: 36,
|
|
75
|
+
layoutPreset: "auto",
|
|
76
|
+
});
|
|
77
|
+
const readLocalImage = () => {
|
|
78
|
+
const reader = new FileReader();
|
|
79
|
+
reader.onload = () => {
|
|
80
|
+
if (typeof reader.result === "string") addUploadedImage(reader.result);
|
|
81
|
+
};
|
|
82
|
+
reader.readAsDataURL(file);
|
|
83
|
+
};
|
|
84
|
+
|
|
59
85
|
setIsUploading(true);
|
|
60
86
|
try {
|
|
61
87
|
const formData = new FormData();
|
|
@@ -69,56 +95,12 @@ export const EditorSidebar: React.FC<EditorSidebarProps> = ({
|
|
|
69
95
|
|
|
70
96
|
const data = await res.json();
|
|
71
97
|
if (data.ok && data.url) {
|
|
72
|
-
|
|
73
|
-
src: data.url,
|
|
74
|
-
alt: file.name,
|
|
75
|
-
caption: "",
|
|
76
|
-
x: 40,
|
|
77
|
-
y: 400,
|
|
78
|
-
width: 440,
|
|
79
|
-
height: 300,
|
|
80
|
-
captionHeight: 36,
|
|
81
|
-
layoutPreset: "auto",
|
|
82
|
-
});
|
|
98
|
+
addUploadedImage(data.url);
|
|
83
99
|
} else {
|
|
84
|
-
|
|
85
|
-
const reader = new FileReader();
|
|
86
|
-
reader.onload = (event) => {
|
|
87
|
-
if (event.target?.result) {
|
|
88
|
-
onAddImage({
|
|
89
|
-
src: event.target.result as string,
|
|
90
|
-
alt: file.name,
|
|
91
|
-
caption: "",
|
|
92
|
-
x: 40,
|
|
93
|
-
y: 400,
|
|
94
|
-
width: 440,
|
|
95
|
-
height: 300,
|
|
96
|
-
captionHeight: 36,
|
|
97
|
-
layoutPreset: "auto",
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
};
|
|
101
|
-
reader.readAsDataURL(file);
|
|
100
|
+
readLocalImage();
|
|
102
101
|
}
|
|
103
102
|
} catch {
|
|
104
|
-
|
|
105
|
-
const reader = new FileReader();
|
|
106
|
-
reader.onload = (event) => {
|
|
107
|
-
if (event.target?.result) {
|
|
108
|
-
onAddImage({
|
|
109
|
-
src: event.target.result as string,
|
|
110
|
-
alt: file.name,
|
|
111
|
-
caption: "",
|
|
112
|
-
x: 40,
|
|
113
|
-
y: 400,
|
|
114
|
-
width: 440,
|
|
115
|
-
height: 300,
|
|
116
|
-
captionHeight: 36,
|
|
117
|
-
layoutPreset: "auto",
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
};
|
|
121
|
-
reader.readAsDataURL(file);
|
|
103
|
+
readLocalImage();
|
|
122
104
|
} finally {
|
|
123
105
|
setIsUploading(false);
|
|
124
106
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
@@ -366,6 +348,35 @@ export const EditorSidebar: React.FC<EditorSidebarProps> = ({
|
|
|
366
348
|
</button>
|
|
367
349
|
</div>
|
|
368
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
|
+
|
|
369
380
|
{!img.isAbsolute ? (
|
|
370
381
|
<>
|
|
371
382
|
{/* Flow Settings */}
|
|
@@ -375,7 +386,7 @@ export const EditorSidebar: React.FC<EditorSidebarProps> = ({
|
|
|
375
386
|
<select
|
|
376
387
|
value={img.layoutPreset || "auto"}
|
|
377
388
|
onChange={(e) =>
|
|
378
|
-
onUpdateImage(img.id, { layoutPreset: e.target.value as
|
|
389
|
+
onUpdateImage(img.id, { layoutPreset: e.target.value as FlowImage["layoutPreset"] })
|
|
379
390
|
}
|
|
380
391
|
className="w-full bg-white dark:bg-[#181818] border border-[#d4d4d4] dark:border-[#262626] rounded px-1.5 py-1 text-black dark:text-white text-[11px] focus:outline-none"
|
|
381
392
|
>
|
|
@@ -52,7 +52,7 @@ export const Header: React.FC<HeaderProps> = ({
|
|
|
52
52
|
</span>
|
|
53
53
|
<span>Typeset Studio</span>
|
|
54
54
|
</div>
|
|
55
|
-
<span className="text-xs text-[#888888] dark:text-[#555555] font-mono hidden md:inline">v0.
|
|
55
|
+
<span className="text-xs text-[#888888] dark:text-[#555555] font-mono hidden md:inline">v0.2.2</span>
|
|
56
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
57
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 dark:bg-[#00e676] animate-pulse" />
|
|
58
58
|
<span className="font-mono">Ready</span>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React, { useEffect, useState } from "react";
|
|
2
|
-
import { Check, FolderPlus, Trash2, X, Sparkles, Star
|
|
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 };
|
|
@@ -20,12 +23,6 @@ interface SlotsResponse {
|
|
|
20
23
|
};
|
|
21
24
|
}
|
|
22
25
|
|
|
23
|
-
const SLOT_LABELS: Record<TemplateSlot, string> = {
|
|
24
|
-
article_cover: "文章封面 (2.35:1)",
|
|
25
|
-
newspic_cover: "贴图封面 (3:4)",
|
|
26
|
-
newspic_content: "贴图内容 (3:4)",
|
|
27
|
-
};
|
|
28
|
-
|
|
29
26
|
export const TemplateModal: React.FC<TemplateModalProps> = ({
|
|
30
27
|
isOpen,
|
|
31
28
|
onClose,
|
|
@@ -47,13 +44,14 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({
|
|
|
47
44
|
|
|
48
45
|
const fetchSlots = async (acc = account) => {
|
|
49
46
|
try {
|
|
50
|
-
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 } });
|
|
51
48
|
const data: SlotsResponse = await res.json();
|
|
49
|
+
if (!res.ok || !data.ok) throw new Error(data.message || data.error || "获取槽位失败");
|
|
52
50
|
if (data.ok && data.slots) {
|
|
53
51
|
setSlotsData(data.slots);
|
|
54
52
|
}
|
|
55
|
-
} catch {
|
|
56
|
-
|
|
53
|
+
} catch (err) {
|
|
54
|
+
setError(err instanceof Error ? err.message : "模板请求失败");
|
|
57
55
|
}
|
|
58
56
|
};
|
|
59
57
|
|
|
@@ -68,11 +66,12 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({
|
|
|
68
66
|
body: JSON.stringify({ slot, templateId, account }),
|
|
69
67
|
});
|
|
70
68
|
const data = await res.json();
|
|
69
|
+
if (!res.ok || !data.ok) throw new Error(data.message || data.error || "设置默认模板失败");
|
|
71
70
|
if (data.ok) {
|
|
72
71
|
await fetchSlots(account);
|
|
73
72
|
}
|
|
74
|
-
} catch {
|
|
75
|
-
|
|
73
|
+
} catch (err) {
|
|
74
|
+
setError(err instanceof Error ? err.message : "模板请求失败");
|
|
76
75
|
}
|
|
77
76
|
};
|
|
78
77
|
|
|
@@ -84,7 +83,7 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({
|
|
|
84
83
|
headers: adminKey ? { "x-admin-key": adminKey } : {},
|
|
85
84
|
});
|
|
86
85
|
const data = await res.json();
|
|
87
|
-
if (data.ok && Array.isArray(data.templates)) {
|
|
86
|
+
if (res.ok && data.ok && Array.isArray(data.templates)) {
|
|
88
87
|
setTemplates(data.templates);
|
|
89
88
|
} else {
|
|
90
89
|
setError(data.message || "获取模板失败,请检查密钥");
|
|
@@ -103,18 +102,18 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({
|
|
|
103
102
|
setTemplateName(currentConfig.name ? `${currentConfig.name} 副本` : "我的自定义模板");
|
|
104
103
|
setSaveSlot(currentConfig.aspectRatio === "wechat-cover-2.35" ? "article_cover" : "newspic_content");
|
|
105
104
|
}
|
|
106
|
-
}, [isOpen, account]);
|
|
105
|
+
}, [isOpen, account, adminKey]);
|
|
107
106
|
|
|
108
107
|
const handleSave = async () => {
|
|
109
108
|
if (!templateName.trim()) return;
|
|
110
109
|
setIsSaving(true);
|
|
111
110
|
try {
|
|
111
|
+
validateTemplateAssets(currentConfig.images);
|
|
112
112
|
const tplToSave: TypesetConfig = {
|
|
113
113
|
...currentConfig,
|
|
114
114
|
id: undefined, // auto generate
|
|
115
115
|
name: templateName.trim(),
|
|
116
116
|
slot: saveSlot !== "none" ? saveSlot : undefined,
|
|
117
|
-
images: currentConfig.images ? [...currentConfig.images] : undefined,
|
|
118
117
|
};
|
|
119
118
|
|
|
120
119
|
const res = await fetch("/api/templates", {
|
|
@@ -127,14 +126,14 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({
|
|
|
127
126
|
});
|
|
128
127
|
|
|
129
128
|
const data = await res.json();
|
|
130
|
-
if (data.ok) {
|
|
129
|
+
if (res.ok && data.ok) {
|
|
131
130
|
await fetchTemplates();
|
|
132
131
|
await fetchSlots(account);
|
|
133
132
|
} else {
|
|
134
|
-
setError(data.message || "保存模板失败");
|
|
133
|
+
setError(data.message || data.error || "保存模板失败");
|
|
135
134
|
}
|
|
136
|
-
} catch {
|
|
137
|
-
setError("保存模板请求异常");
|
|
135
|
+
} catch (err) {
|
|
136
|
+
setError(err instanceof Error ? err.message : "保存模板请求异常");
|
|
138
137
|
} finally {
|
|
139
138
|
setIsSaving(false);
|
|
140
139
|
}
|
|
@@ -142,14 +141,16 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({
|
|
|
142
141
|
|
|
143
142
|
const handleDelete = async (id: string) => {
|
|
144
143
|
try {
|
|
145
|
-
await fetch(`/api/templates/${id}`, {
|
|
144
|
+
const res = await fetch(`/api/templates/${encodeURIComponent(id)}`, {
|
|
146
145
|
method: "DELETE",
|
|
147
146
|
headers: adminKey ? { "x-admin-key": adminKey } : {},
|
|
148
147
|
});
|
|
148
|
+
const data = await res.json();
|
|
149
|
+
if (!res.ok || !data.ok) throw new Error(data.message || data.error || "删除模板失败");
|
|
149
150
|
setTemplates((prev) => prev.filter((t) => t.id !== id));
|
|
150
151
|
await fetchSlots(account);
|
|
151
|
-
} catch {
|
|
152
|
-
|
|
152
|
+
} catch (err) {
|
|
153
|
+
setError(err instanceof Error ? err.message : "模板请求失败");
|
|
153
154
|
}
|
|
154
155
|
};
|
|
155
156
|
|
|
@@ -192,7 +193,7 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({
|
|
|
192
193
|
/>
|
|
193
194
|
<select
|
|
194
195
|
value={saveSlot}
|
|
195
|
-
onChange={(e) => setSaveSlot(e.target.value as
|
|
196
|
+
onChange={(e) => setSaveSlot(e.target.value as TemplateSlot | "none")}
|
|
196
197
|
className="bg-white dark:bg-[#0a0a0a] border border-[#d4d4d4] dark:border-[#2a2a2a] rounded px-2 py-1.5 text-xs text-black dark:text-white focus:outline-none focus:border-black dark:focus:border-[#444444]"
|
|
197
198
|
>
|
|
198
199
|
<option value="article_cover">槽位: 文章封面 (2.35:1)</option>
|
|
@@ -224,11 +225,7 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({
|
|
|
224
225
|
<span>默认槽位配置账号:</span>
|
|
225
226
|
<select
|
|
226
227
|
value={account}
|
|
227
|
-
onChange={(e) =>
|
|
228
|
-
const acc = e.target.value;
|
|
229
|
-
setAccount(acc);
|
|
230
|
-
fetchSlots(acc);
|
|
231
|
-
}}
|
|
228
|
+
onChange={(e) => setAccount(e.target.value)}
|
|
232
229
|
className="bg-white dark:bg-[#141414] border border-[#d4d4d4] dark:border-[#2a2a2a] rounded px-2 py-0.5 text-xs text-black dark:text-white focus:outline-none font-mono"
|
|
233
230
|
>
|
|
234
231
|
<option value="default">default (主账号)</option>
|
|
@@ -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 {
|