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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user