- export.ts:toBlob 参数纯函数 resolveExportRequest——multiplier 恒为 1, 画布 backstore 本就是逻辑尺寸,不再乘预览缩放;WebP 带质量、PNG 无损不带 - templates.ts:模板 = canvas.toJSON() + 尺寸,存 localStorage(key 单键数组); save/list/delete 可注入 store 便于 Node 侧自检;collectUsedFonts 扫文本对象 fontFamily(instanceof FabricText + type 名兜底),missingFonts 查缺字 - ExportPanel.vue:格式/质量/文件名/下载;模板保存命名、列表、加载 (loadFromJSON + setSize 恢复尺寸)、删除(二次确认);加载时若字体列表 已就绪且模板用了本机没有的字体则提示;字体列表为空(T2 未实现)跳过检查 - selfcheck.ts:bun 直接跑的 Node 断言,覆盖 multiplier、storage 往返、 usedFonts 收集、缺字体与文件名清洗 浏览器下载、质量滑杆体积、刷新后加载恢复需主控用 Chrome 验
160 lines
5.3 KiB
TypeScript
160 lines
5.3 KiB
TypeScript
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<string>();
|
||
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<string>();
|
||
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));
|
||
}
|