feat(io): 导出 WebP/PNG 下载,模板存 localStorage 可加载复用

- 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 验
This commit is contained in:
2026-09-04 16:02:28 +08:00
parent 95eeb69ff8
commit fa4a7e92f4
5 changed files with 721 additions and 30 deletions
+159
View File
@@ -0,0 +1,159 @@
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));
}