import { FabricText } from "fabric"; import type { CanvasSize, Template } from "@/types"; /** * 【T5】模板:`canvas.toJSON()` 的产物 + 画布尺寸,存 localStorage(见 types.ts 的 Template)。 * * 没有内置预设包,全靠用户自己存。加载走 `canvas.loadFromJSON()`(在面板里拼 `useEditor()`)。 * * 存储相关函数都接收一个可注入的 `store`,默认用浏览器 localStorage—— * Node 侧自检传一个假 store 就能验,不用真的开浏览器。 */ const STORAGE_KEY = "cover-studio:templates.v1"; export interface TemplateStore { getItem(key: string): string | null; setItem(key: string, value: string): void; } export interface SaveTemplateInput { name: string; size: CanvasSize; /** `canvas.toJSON()` 的产物,原样存原样取。 */ canvasJSON: unknown; /** 画布上文本对象用到的 fontFamily(会被去重、去空)。 */ usedFonts: readonly string[]; } function defaultStore(): TemplateStore { if (typeof localStorage === "undefined") { throw new Error("模板需要 localStorage,但当前环境没有"); } return localStorage; } function readAll(store: TemplateStore): Template[] { const raw = store.getItem(STORAGE_KEY); if (!raw) return []; try { const parsed: unknown = JSON.parse(raw); return Array.isArray(parsed) ? (parsed as Template[]) : []; } catch { // 本地数据坏了就当没有,别让一份坏模板拖垮整个面板。 return []; } } function writeAll(store: TemplateStore, list: Template[]): void { try { store.setItem(STORAGE_KEY, JSON.stringify(list)); } catch (err) { // 一份模板里如果塞了 base64 大图,很容易顶爆 localStorage 的额度。 throw new Error("模板存不进去:localStorage 可能已满(模板里的大图很占空间)", { cause: err, }); } } function newId(): string { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; } function normalizeFonts(fonts: readonly string[]): string[] { const seen = new Set(); const out: string[] = []; for (const f of fonts) { const trimmed = (f ?? "").trim(); if (trimmed !== "" && !seen.has(trimmed)) { seen.add(trimmed); out.push(trimmed); } } return out; } export function listTemplates(store: TemplateStore = defaultStore()): Template[] { return readAll(store); } export function getTemplate(id: string, store: TemplateStore = defaultStore()): Template | null { return readAll(store).find((t) => t.id === id) ?? null; } export function saveTemplate( input: SaveTemplateInput, store: TemplateStore = defaultStore(), ): Template { const name = input.name.trim(); const template: Template = { id: newId(), name: name === "" ? `封面 ${input.size.width}×${input.size.height}` : name, size: { width: input.size.width, height: input.size.height }, canvasJSON: input.canvasJSON, createdAt: new Date().toISOString(), usedFonts: normalizeFonts(input.usedFonts), }; writeAll(store, [...readAll(store), template]); return template; } export function deleteTemplate(id: string, store: TemplateStore = defaultStore()): void { writeAll( store, readAll(store).filter((t) => t.id !== id), ); } /* ---------------------------------------------------------------- usedFonts */ /** * 文本对象在 fabric v7 的序列化 JSON 里 type 是 PascalCase * ("Textbox" / "IText" / "Text")。老版本的小写别名("textbox" / "i-text" / "text") * 也认,保证从别处拷来的 JSON 也能识别。 */ const TEXT_TYPE_NAMES = new Set(["Textbox", "IText", "Text", "textbox", "i-text", "text"]); function isTextObject(obj: object): boolean { // 活对象:Text / IText / Textbox 都继承 FabricText(instanceof 最可靠)。 if (obj instanceof FabricText) return true; // 序列化 JSON / 测试假对象:按 type 名认。 const type = (obj as { type?: unknown }).type; return typeof type === "string" && TEXT_TYPE_NAMES.has(type); } /** * 收集画布对象里所有文本对象的 fontFamily(去重、去空)。 * 换台机器复现不出来的正是这些本机字体,存模板前扫一遍填进 `usedFonts`。 */ export function collectUsedFonts(objects: readonly unknown[]): string[] { const seen = new Set(); const out: string[] = []; for (const obj of objects) { if (!obj || typeof obj !== "object") continue; if (!isTextObject(obj)) continue; const fontFamily = (obj as { fontFamily?: unknown }).fontFamily; if (typeof fontFamily !== "string") continue; const key = fontFamily.trim(); if (key !== "" && !seen.has(key)) { seen.add(key); out.push(key); } } return out; } /** * 模板用到的字体里,当前机器没装(不在 `installed` 里)的那些。 * * `installed` 传空数组时应由调用方跳过检查而不是调用这里报缺字—— * 字体列表拉出来之前(T2 未实现时是空数组),`missingFonts` 会把所有 usedFonts * 都判成缺失。调用方自己保证:`installed.length === 0` 就不查。 */ export function missingFonts(usedFonts: readonly string[], installed: readonly string[]): string[] { const set = new Set(installed); return usedFonts.filter((f) => !set.has(f)); }