Merge branch 't5-io' into integration
This commit is contained in:
@@ -1,19 +1,324 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 【T5 待实现 — 导出与模板面板】
|
||||
*
|
||||
* 导出:格式(WebP / PNG)、质量滑杆(只对 WebP 生效)、文件名、下载按钮。
|
||||
* 模板:保存当前画布为模板、列出已存模板、加载、删除。
|
||||
*/
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useEditor } from "@/editor/useEditor";
|
||||
import { fonts } from "@/fonts/useFonts";
|
||||
import type { ExportFormat, Template } from "@/types";
|
||||
import {
|
||||
collectUsedFonts,
|
||||
deleteTemplate,
|
||||
exportCanvas,
|
||||
listTemplates,
|
||||
missingFonts,
|
||||
normalizeFilename,
|
||||
saveTemplate,
|
||||
} from "@/io";
|
||||
|
||||
const { getCanvas, size, setSize } = useEditor();
|
||||
|
||||
/* ---------------------------------------------------------------- 导出 */
|
||||
|
||||
const format = ref<ExportFormat>("webp");
|
||||
const quality = ref(0.85);
|
||||
const filename = ref("");
|
||||
const exporting = ref(false);
|
||||
const exportError = ref("");
|
||||
|
||||
const qualityPercent = computed(() => Math.round(quality.value * 100));
|
||||
|
||||
const defaultFilename = computed(() => `cover-${size.value.width}x${size.value.height}`);
|
||||
|
||||
const exportTarget = computed(
|
||||
() => `${normalizeFilename(filename.value || defaultFilename.value)}.${format.value}`,
|
||||
);
|
||||
|
||||
function pickFormat(next: string) {
|
||||
if (next === "webp" || next === "png") format.value = next;
|
||||
}
|
||||
|
||||
async function doExport() {
|
||||
if (exporting.value) return;
|
||||
exportError.value = "";
|
||||
exporting.value = true;
|
||||
try {
|
||||
await exportCanvas(
|
||||
getCanvas(),
|
||||
{ width: size.value.width, height: size.value.height },
|
||||
{
|
||||
format: format.value,
|
||||
quality: quality.value,
|
||||
filename: filename.value || defaultFilename.value,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
exportError.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 模板 */
|
||||
|
||||
const templates = ref<Template[]>([]);
|
||||
const templateName = ref("");
|
||||
const saving = ref(false);
|
||||
const templateMsg = ref("");
|
||||
const missingFontsNow = ref<string[]>([]);
|
||||
const confirmingDeleteId = ref<string | null>(null);
|
||||
|
||||
function refreshTemplates() {
|
||||
templates.value = listTemplates();
|
||||
}
|
||||
|
||||
onMounted(refreshTemplates);
|
||||
|
||||
function shortDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function saveCurrentTemplate() {
|
||||
if (saving.value) return;
|
||||
saving.value = true;
|
||||
templateMsg.value = "";
|
||||
missingFontsNow.value = [];
|
||||
try {
|
||||
const canvas = getCanvas();
|
||||
// 存之前扫一遍文本对象的 fontFamily——换台机器复现不出来的正是这些本机字体。
|
||||
const used = collectUsedFonts(canvas.getObjects());
|
||||
const saved = saveTemplate({
|
||||
name: templateName.value,
|
||||
size: { width: size.value.width, height: size.value.height },
|
||||
canvasJSON: canvas.toJSON(),
|
||||
usedFonts: used,
|
||||
});
|
||||
refreshTemplates();
|
||||
templateName.value = "";
|
||||
templateMsg.value = `已保存模板「${saved.name}」`;
|
||||
} catch (err) {
|
||||
templateMsg.value = `保存失败:${err instanceof Error ? err.message : String(err)}`;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTemplateIntoCanvas(t: Template) {
|
||||
templateMsg.value = "";
|
||||
missingFontsNow.value = [];
|
||||
try {
|
||||
const canvas = getCanvas();
|
||||
// 先按模板尺寸恢复画布,再灌对象(loadFromJSON 不改变画布尺寸)。
|
||||
setSize({ width: t.size.width, height: t.size.height });
|
||||
await canvas.loadFromJSON(t.canvasJSON as Record<string, unknown>);
|
||||
canvas.requestRenderAll();
|
||||
// 字体提醒。T2 的字体列表还没拉出来(空数组)就跳过——
|
||||
// 别让缺字体检查把模板加载一起拖垮。
|
||||
if (fonts.value.length > 0) {
|
||||
missingFontsNow.value = missingFonts(
|
||||
t.usedFonts,
|
||||
fonts.value.map((f) => f.family),
|
||||
);
|
||||
}
|
||||
templateMsg.value = `已加载「${t.name}」`;
|
||||
} catch (err) {
|
||||
templateMsg.value = `加载「${t.name}」失败:${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function askDelete(t: Template) {
|
||||
confirmingDeleteId.value = t.id;
|
||||
}
|
||||
|
||||
function cancelDelete() {
|
||||
confirmingDeleteId.value = null;
|
||||
}
|
||||
|
||||
function doDelete(t: Template) {
|
||||
try {
|
||||
deleteTemplate(t.id);
|
||||
refreshTemplates();
|
||||
confirmingDeleteId.value = null;
|
||||
templateMsg.value = `已删除「${t.name}」`;
|
||||
} catch (err) {
|
||||
templateMsg.value = `删除失败:${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p class="todo">T5 待实现:WebP / PNG 导出,模板保存与加载</p>
|
||||
<!-- ==================== 导出 ==================== -->
|
||||
<div class="field">
|
||||
<label>格式</label>
|
||||
<select :value="format" @change="pickFormat(($event.target as HTMLSelectElement).value)">
|
||||
<option value="webp">WebP(推荐,体积小)</option>
|
||||
<option value="png">PNG(无损,一般用不上)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>质量 {{ qualityPercent }}%(PNG 无损时忽略)</label>
|
||||
<input
|
||||
type="range"
|
||||
v-model.number="quality"
|
||||
min="0.1"
|
||||
max="1"
|
||||
step="0.05"
|
||||
:disabled="format === 'png'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>文件名</label>
|
||||
<input
|
||||
v-model="filename"
|
||||
:placeholder="defaultFilename"
|
||||
@keyup.enter="doExport"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="meta">
|
||||
导出 {{ size.width }}×{{ size.height }}px 全分辨率(预览缩放不影响),
|
||||
下载为 <code>{{ exportTarget }}</code>。
|
||||
</p>
|
||||
|
||||
<button :disabled="exporting" @click="doExport">
|
||||
{{ exporting ? "导出中…" : `下载 ${format.toUpperCase()}` }}
|
||||
</button>
|
||||
<p v-if="exportError" class="msg">⚠ {{ exportError }}</p>
|
||||
|
||||
<!-- ==================== 模板 ==================== -->
|
||||
<div class="sep"></div>
|
||||
|
||||
<div class="field">
|
||||
<label>保存当前画布为模板</label>
|
||||
<div class="row">
|
||||
<input
|
||||
v-model="templateName"
|
||||
class="grow"
|
||||
placeholder="模板名(可留空自动起名)"
|
||||
@keyup.enter="saveCurrentTemplate"
|
||||
/>
|
||||
<button :disabled="saving" @click="saveCurrentTemplate">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="templateMsg" class="msg">{{ templateMsg }}</p>
|
||||
|
||||
<p v-if="missingFontsNow.length" class="msg">
|
||||
⚠ 该模板用了本机字体:{{ missingFontsNow.join("、") }}。当前机器没有这些字体,
|
||||
会退回系统字体显示,文字排版可能对不上。
|
||||
</p>
|
||||
|
||||
<h3 class="sub">已存模板({{ templates.length }})</h3>
|
||||
|
||||
<p v-if="templates.length === 0" class="empty">
|
||||
还没有模板。调好一张封面后点上面的「保存」,之后就能一键加载复用。
|
||||
</p>
|
||||
|
||||
<ul v-else class="tpl-list">
|
||||
<li v-for="t in templates" :key="t.id" class="tpl">
|
||||
<div class="tpl-head">
|
||||
<span class="name" :title="`id: ${t.id}`">{{ t.name }}</span>
|
||||
<span class="dim">{{ t.size.width }}×{{ t.size.height }} · {{ shortDate(t.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="tpl-actions row">
|
||||
<button @click="loadTemplateIntoCanvas(t)">加载</button>
|
||||
<template v-if="confirmingDeleteId === t.id">
|
||||
<span class="dim">确认删除?</span>
|
||||
<button @click="doDelete(t)">删除</button>
|
||||
<button @click="cancelDelete">取消</button>
|
||||
</template>
|
||||
<button v-else @click="askDelete(t)">删除</button>
|
||||
<span v-if="t.usedFonts.length" class="dim fonts">含 {{ t.usedFonts.length }} 个字体</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.todo {
|
||||
.grow {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--fg-dim);
|
||||
margin: 0 0 10px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.meta code {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.msg {
|
||||
color: var(--fg-dim);
|
||||
margin: 8px 0 0;
|
||||
line-height: 1.6;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.sep {
|
||||
border-top: 1px solid var(--line);
|
||||
margin: 14px 0 12px;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 10px 0 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--fg-dim);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.tpl-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tpl {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tpl-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tpl-head .name {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dim {
|
||||
color: var(--fg-dim);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tpl-actions {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 6px;
|
||||
}
|
||||
|
||||
.tpl-actions .fonts {
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { CanvasSize, ExportFormat, ExportOptions } from "@/types";
|
||||
|
||||
/**
|
||||
* 【T5】导出:WebP(默认,带质量)/ PNG(无损),走浏览器下载到下载文件夹。
|
||||
* **不上传图床、不写 frontmatter**——这两条明确不做。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 传给 fabric `canvas.toBlob()` 的参数。
|
||||
*
|
||||
* `multiplier` 恒为 1:画布 backstore 始终是逻辑尺寸(导出像素,如 1200×630),
|
||||
* `useEditor().displayScale` / `fitTo()` 只改 CSS 尺寸。所以 `toBlob` 默认就已经
|
||||
* 是全分辨率,**不要再乘一次 1/displayScale 去“补偿”预览缩放**,那会导出成两倍大小。
|
||||
*/
|
||||
export interface ExportRequest {
|
||||
format: ExportFormat;
|
||||
multiplier: number;
|
||||
/** 0–1,只对 webp 生效。 */
|
||||
quality?: number;
|
||||
/** 导出像素尺寸 = 画布逻辑尺寸;只作展示/自检用,不进 toBlob。 */
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function clampQuality(q: number): number {
|
||||
if (!Number.isFinite(q)) return 0.85;
|
||||
return Math.min(1, Math.max(0, q));
|
||||
}
|
||||
|
||||
/** 纯函数:把画布逻辑尺寸 + 面板选项解析成该传给 `toDataURL`/`toBlob` 的参数。 */
|
||||
export function resolveExportRequest(
|
||||
size: CanvasSize,
|
||||
options: Pick<ExportOptions, "format" | "quality">,
|
||||
): ExportRequest {
|
||||
const base = { multiplier: 1, width: size.width, height: size.height };
|
||||
if (options.format === "png") {
|
||||
// PNG 无损,浏览器会忽略 quality,干脆不带。
|
||||
return { ...base, format: "png" };
|
||||
}
|
||||
return { ...base, format: "webp", quality: clampQuality(options.quality) };
|
||||
}
|
||||
|
||||
/** fabric Canvas 的最小结构面——避免这里直接依赖 fabric 的类型。 */
|
||||
export interface ExportableCanvas {
|
||||
toBlob(options: { format: ExportFormat; multiplier: number; quality?: number }): Promise<Blob | null>;
|
||||
}
|
||||
|
||||
/** 文件名清洗:去掉各平台文件名非法字符与控制字符,空结果退回 fallback。 */
|
||||
export function normalizeFilename(raw: string, fallback = "cover"): string {
|
||||
const cleaned = raw
|
||||
.replace(/[\\/:*?"<>|\u0000-\u001f\u007f]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return cleaned === "" ? fallback : cleaned;
|
||||
}
|
||||
|
||||
/** 触发一次浏览器下载(object URL + 隐藏的 <a download>)。 */
|
||||
export function triggerDownload(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
// 延后释放,保证下载已经真正开始。
|
||||
setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
||||
}
|
||||
|
||||
export async function exportCanvas(
|
||||
canvas: ExportableCanvas,
|
||||
size: CanvasSize,
|
||||
options: ExportOptions,
|
||||
): Promise<void> {
|
||||
const req = resolveExportRequest(size, options);
|
||||
const blob = await canvas.toBlob({
|
||||
format: req.format,
|
||||
multiplier: req.multiplier,
|
||||
...(req.quality !== undefined ? { quality: req.quality } : {}),
|
||||
});
|
||||
if (!blob) {
|
||||
throw new Error(
|
||||
`这个浏览器导不出 ${req.format.toUpperCase()}(canvas.toBlob 返回空),换 PNG 试试`,
|
||||
);
|
||||
}
|
||||
triggerDownload(blob, `${normalizeFilename(options.filename)}.${req.format}`);
|
||||
}
|
||||
+7
-22
@@ -1,25 +1,10 @@
|
||||
import type { ExportOptions, Template } from "@/types";
|
||||
|
||||
/**
|
||||
* 【T5 待实现 — 导出与模板】
|
||||
* 【T5】导出与模板。
|
||||
*
|
||||
* export.ts:
|
||||
* 画布导出成 WebP(默认,带质量滑杆)或 PNG,走浏览器下载到下载文件夹。
|
||||
* **不上传图床、不写 frontmatter**——这两条明确不做。
|
||||
* 导出必须是画布的逻辑尺寸(全分辨率),不受预览缩放影响;
|
||||
* `useEditor().displayScale` 只改 CSS 尺寸,所以 `toDataURL` 默认就是对的,
|
||||
* 但要注意 multiplier 别被重复乘一次。
|
||||
*
|
||||
* templates.ts:
|
||||
* 模板 = `canvas.toJSON()` + 画布尺寸,存 localStorage(见 types.ts 的 Template)。
|
||||
* 没有内置预设包,全靠用户自己存。加载走 `canvas.loadFromJSON()`。
|
||||
* 存之前扫一遍文本对象的 fontFamily 填进 `usedFonts`——用了本机字体的模板
|
||||
* 换台机器复现不出来,加载时要提示。
|
||||
* - `./export`:画布导出成 WebP(默认)/ PNG,浏览器下载。纯函数 `resolveExportRequest`
|
||||
* 负责解析 toBlob 参数(multiplier 恒为 1,见该文件里的注释),`exportCanvas` 组合下载。
|
||||
* - `./templates`:模板 = `canvas.toJSON()` + 画布尺寸,存 localStorage;
|
||||
* `collectUsedFonts` / `missingFonts` 是本机字体相关的纯函数。
|
||||
*/
|
||||
export async function exportCanvas(_options: ExportOptions): Promise<void> {
|
||||
throw new Error("T5 未实现:exportCanvas");
|
||||
}
|
||||
|
||||
export function listTemplates(): Template[] {
|
||||
throw new Error("T5 未实现:listTemplates");
|
||||
}
|
||||
export * from "./export";
|
||||
export * from "./templates";
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 【T5】Node 侧自验。运行:`bun src/io/selfcheck.ts`
|
||||
*
|
||||
* 覆盖任务书要求的四条自验里能脱离浏览器的部分:
|
||||
* 1. 导出 multiplier 恒为 1(不被预览缩放带偏);
|
||||
* 2. 模板在 storage 里序列化往返形状一致;
|
||||
* 3. `usedFonts` 收集能从对象列表里挑出正确的 fontFamily;
|
||||
* 4. 缺字体检查与文件名清洗。
|
||||
*
|
||||
* 浏览器侧行为(真实下载像素尺寸、质量滑杆改变体积、刷新后加载恢复画布)
|
||||
* 这里验不了,需要主控用 Chrome 验。
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { collectUsedFonts, deleteTemplate, listTemplates, missingFonts, saveTemplate } from "./templates";
|
||||
import type { TemplateStore } from "./templates";
|
||||
import { normalizeFilename, resolveExportRequest } from "./export";
|
||||
|
||||
/** 假 localStorage 的底层存储:同一块 Map。 */
|
||||
function makeBacking() {
|
||||
return new Map<string, string>();
|
||||
}
|
||||
|
||||
/** 一块底层存储上的一个视图(localStorage 是每源单例,刷新前后看到的是同一块)。 */
|
||||
function viewOf(backing: Map<string, string>): TemplateStore {
|
||||
return {
|
||||
getItem: (key) => backing.get(key) ?? null,
|
||||
setItem: (key, value) => {
|
||||
backing.set(key, value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------- 1. 导出:multiplier 必须恒为 1 ---------------- */
|
||||
|
||||
const logical = { width: 1200, height: 630 };
|
||||
|
||||
const webp = resolveExportRequest(logical, { format: "webp", quality: 0.8 });
|
||||
assert.equal(webp.multiplier, 1, "WebP 导出的 multiplier 必须是 1(逻辑尺寸 1200×630)");
|
||||
assert.equal(webp.width, 1200, "导出宽 = 逻辑宽");
|
||||
assert.equal(webp.height, 630, "导出高 = 逻辑高");
|
||||
assert.equal(webp.format, "webp");
|
||||
assert.equal(webp.quality, 0.8);
|
||||
|
||||
const png = resolveExportRequest(logical, { format: "png", quality: 0.8 });
|
||||
assert.equal(png.multiplier, 1, "PNG 导出的 multiplier 也必须是 1");
|
||||
assert.equal(png.format, "png");
|
||||
assert.equal(png.quality, undefined, "PNG 无损,不传 quality");
|
||||
|
||||
// quality 钳制在 [0, 1]
|
||||
assert.equal(resolveExportRequest(logical, { format: "webp", quality: 1.4 }).quality, 1);
|
||||
assert.equal(resolveExportRequest(logical, { format: "webp", quality: -0.3 }).quality, 0);
|
||||
assert.equal(resolveExportRequest(logical, { format: "webp", quality: Number.NaN }).quality, 0.85);
|
||||
|
||||
console.log("✓ 导出:1200×630 逻辑尺寸下 multiplier 恒为 1,width/height 与逻辑尺寸一致");
|
||||
|
||||
/* ---------------- 2. 文件名清洗 ---------------- */
|
||||
|
||||
assert.equal(normalizeFilename('我的 封面 a/b\\c:d*e?f"g<h>i|j'), "我的 封面 abcdefghij");
|
||||
assert.equal(normalizeFilename(" "), "cover");
|
||||
assert.equal(normalizeFilename("", "fallback-name"), "fallback-name");
|
||||
|
||||
console.log("✓ 文件名:非法字符被清掉,空名退回 fallback");
|
||||
|
||||
/* ---------------- 3. usedFonts 收集 ---------------- */
|
||||
|
||||
const fakeObjects = [
|
||||
{ type: "Textbox", fontFamily: "阿里巴巴普惠体-H" },
|
||||
{ type: "IText", fontFamily: "阿里巴巴普惠体-H" }, // 重复要合并
|
||||
{ type: "Text", fontFamily: " System Font " }, // 两侧空白要清
|
||||
{ type: "textbox", fontFamily: "legacy-name" }, // 老版本小写 type 也要认
|
||||
{ type: "Image", fontFamily: "Times New Roman" }, // 非文本对象不算
|
||||
{ type: "rect", fontFamily: "RectFont" }, // 非文本对象不算
|
||||
{ type: "Text", fontFamily: "" }, // 空 family 不要
|
||||
{},
|
||||
];
|
||||
|
||||
const collected = collectUsedFonts(fakeObjects);
|
||||
assert.deepEqual(collected, ["阿里巴巴普惠体-H", "System Font", "legacy-name"]);
|
||||
|
||||
console.log("✓ usedFonts:只收文本对象,去重去空,输出:", JSON.stringify(collected));
|
||||
|
||||
/* ---------------- 4. 缺字体检查 ---------------- */
|
||||
|
||||
assert.deepEqual(missingFonts(["A", "B", "C"], ["A", "D"]), ["B", "C"]);
|
||||
assert.deepEqual(missingFonts(["A"], ["A"]), []);
|
||||
assert.deepEqual(missingFonts([], ["A"]), []);
|
||||
|
||||
console.log("✓ missingFonts:只返回当前机器没有的字体");
|
||||
|
||||
/* ---------------- 5. 模板 storage 往返 ---------------- */
|
||||
|
||||
const backing = makeBacking();
|
||||
const storeA = viewOf(backing); // 保存
|
||||
const storeB = viewOf(backing); // 模拟「刷新后重新读到 localStorage」——同一块存储
|
||||
|
||||
const saved = saveTemplate(
|
||||
{
|
||||
name: "tech-cover",
|
||||
size: { width: 1200, height: 630 },
|
||||
canvasJSON: { version: "6.6.0", objects: [{ type: "Rect", width: 10 }] },
|
||||
usedFonts: ["A", " A ", "B", ""],
|
||||
},
|
||||
storeA,
|
||||
);
|
||||
|
||||
// saveTemplate 内部应归一化 usedFonts
|
||||
assert.deepEqual(saved.usedFonts, ["A", "B"]);
|
||||
assert.equal(saved.name, "tech-cover");
|
||||
assert.ok(saved.id.length > 0);
|
||||
assert.ok(!Number.isNaN(new Date(saved.createdAt).getTime()), "createdAt 是合法 ISO 时间");
|
||||
|
||||
// 空名自动起名
|
||||
const autoNamed = saveTemplate(
|
||||
{ name: " ", size: { width: 800, height: 400 }, canvasJSON: { objects: [] }, usedFonts: [] },
|
||||
storeA,
|
||||
);
|
||||
assert.equal(autoNamed.name, "封面 800×400");
|
||||
|
||||
// 刷新后:从 storeB(同一底层)读回来,形状与内容一致
|
||||
const afterReload = listTemplates(storeB);
|
||||
assert.equal(afterReload.length, 2);
|
||||
const found = afterReload.find((t) => t.id === saved.id);
|
||||
assert.ok(found, "刷新后还能找到保存的模板");
|
||||
|
||||
// 每个字段都原样往返(含 canvasJSON 这种嵌套对象)
|
||||
assert.deepEqual(
|
||||
{
|
||||
id: found.id,
|
||||
name: found.name,
|
||||
size: found.size,
|
||||
canvasJSON: found.canvasJSON,
|
||||
createdAt: found.createdAt,
|
||||
usedFonts: found.usedFonts,
|
||||
},
|
||||
{
|
||||
id: saved.id,
|
||||
name: saved.name,
|
||||
size: saved.size,
|
||||
canvasJSON: saved.canvasJSON,
|
||||
createdAt: saved.createdAt,
|
||||
usedFonts: saved.usedFonts,
|
||||
},
|
||||
"Template 存入 storage 的形状与读回的一致(JSON.stringify/parse 往返无损)",
|
||||
);
|
||||
|
||||
// 删除后消失
|
||||
deleteTemplate(saved.id, storeA);
|
||||
const afterDelete = listTemplates(storeB);
|
||||
assert.equal(afterDelete.length, 1);
|
||||
assert.equal(afterDelete[0].id, autoNamed.id, "删掉的只剩 autoNamed");
|
||||
|
||||
console.log("✓ 模板:localStorage 序列化往返形状一致,保存/列出/删除都工作");
|
||||
|
||||
console.log("\n全部 Node 侧自验通过 ✅");
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user