feat: 骨架——Vite + Vue3 + Fabric v7,左工作区右侧栏

共享外壳与类型契约,四条并行开发线在此之上各自填空:
- types.ts 是唯一的跨线接口,并行期间冻结
- editor/ 画布单例、选中态、尺寸预设与比例锁
- Sidebar 按选中对象切换面板,五个面板位已接好
- fonts/ filters/ sources/ io/ 留桩,各线只改自己目录

导出定 WebP:实测 540x287 PNG 134KB vs 1200x630 WebP 23KB。
字体接口挂在 Vite 中间件上,工具只在 dev 下完整可用。
This commit is contained in:
Jz0ojiang
2026-09-04 15:42:27 +08:00
commit 95eeb69ff8
26 changed files with 1390 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import Workspace from "./components/Workspace.vue";
import Sidebar from "./components/Sidebar.vue";
</script>
<template>
<div class="app">
<Workspace />
<Sidebar />
</div>
</template>
<style scoped>
/* 左侧大部分版面是预览 / 工作区,右侧是工具与配置侧边栏。 */
.app {
display: grid;
grid-template-columns: 1fr var(--sidebar-w);
height: 100vh;
overflow: hidden;
}
</style>
+82
View File
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { computed } from "vue";
import { useEditor } from "@/editor/useEditor";
import CanvasPanel from "./panels/CanvasPanel.vue";
import SourcePanel from "./panels/SourcePanel.vue";
import FilterPanel from "./panels/FilterPanel.vue";
import TextPanel from "./panels/TextPanel.vue";
import ExportPanel from "./panels/ExportPanel.vue";
/**
* 右侧栏。面板的显示与否由画布当前选中对象决定——这是 Fabric 的对象模型白送的,
* 不需要自己做状态机。
*
* **这个文件在并行开发期间由主控独占。** 各条线只实现自己的面板组件,
* 面板的挂载位置和显示条件已经在这里接好了。
*/
const { selectionKind } = useEditor();
const showFilter = computed(() => selectionKind.value === "image");
const showText = computed(() => selectionKind.value === "text");
</script>
<template>
<aside class="sidebar">
<section class="panel">
<h2>画布</h2>
<CanvasPanel />
</section>
<section class="panel">
<h2>背景</h2>
<SourcePanel />
</section>
<section v-if="showFilter" class="panel">
<h2>滤镜</h2>
<FilterPanel />
</section>
<section v-if="showText" class="panel">
<h2>文字</h2>
<TextPanel />
</section>
<section class="panel">
<h2>导出与模板</h2>
<ExportPanel />
</section>
<p v-if="selectionKind === 'none'" class="hint">选中画布上的图片或文字,右栏会出现对应的面板。</p>
</aside>
</template>
<style scoped>
.sidebar {
border-left: 1px solid var(--line);
background: var(--bg2);
overflow-y: auto;
padding-bottom: 24px;
}
.panel {
padding: 12px 14px;
border-bottom: 1px solid var(--line);
}
.panel h2 {
margin: 0 0 10px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
color: var(--fg-dim);
text-transform: uppercase;
}
.hint {
padding: 12px 14px;
margin: 0;
color: var(--fg-dim);
line-height: 1.6;
}
</style>
+62
View File
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useEditor } from "@/editor/useEditor";
const { mount, unmount, fitTo, size } = useEditor();
const canvasEl = ref<HTMLCanvasElement | null>(null);
const stage = ref<HTMLDivElement | null>(null);
let observer: ResizeObserver | null = null;
function refit() {
const box = stage.value;
if (!box) return;
fitTo(box.clientWidth, box.clientHeight);
}
onMounted(() => {
if (!canvasEl.value) return;
mount(canvasEl.value);
refit();
observer = new ResizeObserver(refit);
if (stage.value) observer.observe(stage.value);
});
onBeforeUnmount(() => {
observer?.disconnect();
unmount();
});
// 逻辑尺寸变了要重新算显示缩放。
watch(size, refit, { deep: true });
</script>
<template>
<div class="workspace">
<div class="stage" ref="stage">
<canvas ref="canvasEl"></canvas>
</div>
</div>
</template>
<style scoped>
.workspace {
display: flex;
flex-direction: column;
min-width: 0;
background: var(--bg);
}
.stage {
flex: 1;
display: grid;
place-items: center;
min-height: 0;
overflow: hidden;
}
/* Fabric 会在 canvas 外面包一层 .canvas-container,阴影打在那上面才跟着缩放走。 */
.stage :deep(.canvas-container) {
box-shadow: 0 0 0 1px var(--line), 0 8px 32px rgb(0 0 0 / 0.4);
}
</style>
+74
View File
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useEditor } from "@/editor/useEditor";
import { RATIOS, SIZE_PRESETS, applyRatio } from "@/editor/canvasSize";
const { size, setSize, addText, displayScale } = useEditor();
const presetId = ref<string>(SIZE_PRESETS[0].id);
const ratioId = ref<string>("free");
const ratio = computed(() => RATIOS.find((r) => r.id === ratioId.value)?.value ?? 0);
function pickPreset(id: string) {
presetId.value = id;
const p = SIZE_PRESETS.find((s) => s.id === id);
if (p) setSize({ width: p.width, height: p.height });
}
function edit(dimension: "width" | "height", raw: string) {
const n = Number(raw);
if (!Number.isFinite(n) || n < 16) return;
presetId.value = "custom";
setSize(applyRatio({ ...size.value, [dimension]: n }, ratio.value, dimension));
}
</script>
<template>
<div class="field">
<label>尺寸预设</label>
<select :value="presetId" @change="pickPreset(($event.target as HTMLSelectElement).value)">
<option v-for="p in SIZE_PRESETS" :key="p.id" :value="p.id">
{{ p.label }}({{ p.width }}×{{ p.height }})
</option>
<option value="custom">自定义</option>
</select>
</div>
<div class="field">
<label>锁定比例</label>
<select v-model="ratioId">
<option v-for="r in RATIOS" :key="r.id" :value="r.id">{{ r.label }}</option>
</select>
</div>
<div class="field row">
<input
type="number"
:value="size.width"
@change="edit('width', ($event.target as HTMLInputElement).value)"
/>
<span class="times">×</span>
<input
type="number"
:value="size.height"
@change="edit('height', ($event.target as HTMLInputElement).value)"
/>
</div>
<p class="meta">预览缩放 {{ Math.round(displayScale * 100) }}%,导出始终是上面的全分辨率。</p>
<button @click="addText()">加一个文本框</button>
</template>
<style scoped>
.times {
color: var(--fg-dim);
}
.meta {
color: var(--fg-dim);
margin: 0 0 10px;
line-height: 1.6;
}
</style>
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
/**
* 【T5 待实现 — 导出与模板面板】
*
* 导出:格式(WebP / PNG)、质量滑杆(只对 WebP 生效)、文件名、下载按钮。
* 模板:保存当前画布为模板、列出已存模板、加载、删除。
*/
</script>
<template>
<p class="todo">T5 待实现:WebP / PNG 导出,模板保存与加载</p>
</template>
<style scoped>
.todo {
color: var(--fg-dim);
margin: 0;
}
</style>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
/**
* 【T3 待实现 — 滤镜面板】
*
* 选中图片时显示。读 `@/editor/filters` 的 FILTERS 注册表,按每个 FilterDef 的
* `params` 自动渲染滑杆;开关某个滤镜就往 `image.filters` 里增删,然后
* `image.applyFilters()` + `canvas.requestRenderAll()`。
*
* 注意 `applyFilters()` 是重活,滑杆要节流,不要每个像素移动都跑一遍。
*/
</script>
<template>
<p class="todo">T3 待实现:模糊 / 亮度 / 对比度 / 饱和度 / 像素化 / 晶格化</p>
</template>
<style scoped>
.todo {
color: var(--fg-dim);
margin: 0;
}
</style>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
/**
* 【T4 待实现 — 背景来源面板】
*
* 三个来源:本地上传、预设几何、Unsplash 搜索。选中后调
* `useEditor().addImage(asset.src)` 装进画布。
*
* Unsplash 的署名**不画进图里**:把 `asset.attribution.text` 显示在面板上,
* 旁边一个复制按钮,用户自己贴进文章。
*/
</script>
<template>
<p class="todo">T4 待实现:上传 / 预设几何 / Unsplash</p>
</template>
<style scoped>
.todo {
color: var(--fg-dim);
margin: 0;
}
</style>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
/**
* 【T2 待实现 — 文字面板】
*
* 选中 Textbox 时显示。要有:字体下拉(来自 useFonts)、字号、字重/颜色、
* 对齐、行高、描边与阴影(现有封面靠这两样保证白字在图上读得清)。
*
* 拿当前选中对象用 `useEditor().activeObject`,改完记得 `canvas.requestRenderAll()`。
*/
</script>
<template>
<p class="todo">T2 待实现:字体选择与文字样式</p>
</template>
<style scoped>
.todo {
color: var(--fg-dim);
margin: 0;
}
</style>
+36
View File
@@ -0,0 +1,36 @@
import type { CanvasSizePreset } from "@/types";
/**
* 画布尺寸预设。宽高是导出像素。
*
* 前两个是博客的真实消费框(v3 的列表卡走 OG 那张,文章顶部图带是 780×260,
* 这里给的是它的 @2x),第三个是方图。选「自定义」时这张表不参与。
*/
export const SIZE_PRESETS: CanvasSizePreset[] = [
{ id: "og", label: "封面 / OG", width: 1200, height: 630 },
{ id: "band", label: "文章图带 @2x", width: 1560, height: 520 },
{ id: "square", label: "方图", width: 1080, height: 1080 },
];
export const DEFAULT_SIZE = SIZE_PRESETS[0];
/** 常用比例,锁定后改一边自动算另一边。 */
export const RATIOS = [
{ id: "free", label: "自由", value: 0 },
{ id: "1.91", label: "1.91:1(OG)", value: 1200 / 630 },
{ id: "3", label: "3:1", value: 3 },
{ id: "16-9", label: "16:9", value: 16 / 9 },
{ id: "1", label: "1:1", value: 1 },
];
/** 按锁定的比例,从一边推另一边。`ratio <= 0` 表示不锁。 */
export function applyRatio(
size: { width: number; height: number },
ratio: number,
changed: "width" | "height",
): { width: number; height: number } {
if (ratio <= 0) return size;
return changed === "width"
? { width: size.width, height: Math.round(size.width / ratio) }
: { width: Math.round(size.height * ratio), height: size.height };
}
+19
View File
@@ -0,0 +1,19 @@
/**
* 【T3 待实现 — 晶格化滤镜】
*
* 目标是视觉上接近 Photoshop 的「晶格化」:把画面切成多边形色块,每块填一个纯色。
* 做法用抖动网格 Voronoi——把画面划成规则网格,每格的种子点在格内随机偏移,
* 每个像素取最近的种子点,填该种子点处的颜色。视觉上和真 Voronoi 难分辨,
* 但只需要查邻近 9 格,复杂度可控。
*
* 实现要继承 fabric 的 `filters.BaseFilter`,模板见
* `node_modules/fabric/dist/src/filters/Boilerplate.d.ts`。
*
* **必须同时实现两条路径**(fabric 会按后端能力二选一,只写一条会在另一条上静默不生效):
* - `getFragmentSource()` + `static uniformLocations` + `sendUniformData()` → WebGL
* - `applyTo2d(options)` → Canvas2D 降级
*
* 参数至少要有格子大小(cellSize),注册进 ../index.ts 的 FILTERS。
*/
export {};
+13
View File
@@ -0,0 +1,13 @@
import type { FilterDef } from "@/types";
/**
* 【T3 待实现 — 滤镜注册表】
*
* 往这个数组里注册滤镜,FilterPanel 会按 `params` 自动渲染滑杆,
* 不需要为每个滤镜单独写界面。
*
* 第一版要有:高斯模糊、亮度、对比度、饱和度、像素化(都是 fabric 内建的
* `filters.Blur / Brightness / Contrast / Saturation / Pixelate`),
* 外加自定义的晶格化(见 ./crystallize)。
*/
export const FILTERS: FilterDef[] = [];
+150
View File
@@ -0,0 +1,150 @@
import { Canvas, FabricImage, Textbox, type FabricObject } from "fabric";
import { ref, shallowRef } from "vue";
import type { CanvasSize, SelectionKind } from "@/types";
import { DEFAULT_SIZE } from "./canvasSize";
/**
* 编辑器核心:一个模块级单例。
*
* 画布是**逻辑尺寸**(导出像素,如 1200×630),显示时按容器缩放——
* Fabric 的 backstore 尺寸和 CSS 尺寸分开设置,所以预览缩放不影响导出分辨率。
*
* 各条开发线通过 `getCanvas()` 拿到 Fabric 画布实例。**不要另外 new 一个 Canvas。**
*/
const canvas = shallowRef<Canvas | null>(null);
const activeObject = shallowRef<FabricObject | null>(null);
const selectionKind = ref<SelectionKind>("none");
const size = ref<CanvasSize>({ width: DEFAULT_SIZE.width, height: DEFAULT_SIZE.height });
/** 预览区相对逻辑尺寸的缩放比,只影响显示。 */
const displayScale = ref(1);
function kindOf(obj: FabricObject | null | undefined): SelectionKind {
if (!obj) return "none";
if (obj instanceof FabricImage) return "image";
if (obj instanceof Textbox) return "text";
return "other";
}
function syncSelection() {
const c = canvas.value;
const obj = c?.getActiveObject() ?? null;
activeObject.value = obj;
selectionKind.value = kindOf(obj);
}
/** 挂载画布。只在 Workspace.vue 里调一次。 */
function mount(el: HTMLCanvasElement): Canvas {
const c = new Canvas(el, {
width: size.value.width,
height: size.value.height,
backgroundColor: "#1b1b1f",
preserveObjectStacking: true,
});
c.on("selection:created", syncSelection);
c.on("selection:updated", syncSelection);
c.on("selection:cleared", syncSelection);
canvas.value = c;
return c;
}
function unmount() {
canvas.value?.dispose();
canvas.value = null;
activeObject.value = null;
selectionKind.value = "none";
}
/** 改逻辑尺寸(导出像素)。显示缩放由 `fitTo` 单独算。 */
function setSize(next: CanvasSize) {
size.value = next;
const c = canvas.value;
if (!c) return;
c.setDimensions({ width: next.width, height: next.height });
c.requestRenderAll();
}
/**
* 把画布缩放到容器里显示。改的是 CSS 尺寸,backstore 不动,
* 所以导出的仍然是逻辑尺寸的全分辨率。
*/
function fitTo(containerWidth: number, containerHeight: number, padding = 32) {
const c = canvas.value;
if (!c) return;
const scale = Math.min(
(containerWidth - padding * 2) / size.value.width,
(containerHeight - padding * 2) / size.value.height,
1,
);
displayScale.value = Math.max(scale, 0.05);
c.setDimensions(
{
width: `${size.value.width * displayScale.value}px`,
height: `${size.value.height * displayScale.value}px`,
},
{ cssOnly: true },
);
}
/** 装一张图进画布并铺满(object-fit: cover 的等效)。 */
async function addImage(src: string): Promise<FabricImage> {
const c = canvas.value;
if (!c) throw new Error("画布还没挂载");
const img = await FabricImage.fromURL(src, { crossOrigin: "anonymous" });
const scale = Math.max(size.value.width / img.width, size.value.height / img.height);
img.set({
scaleX: scale,
scaleY: scale,
left: size.value.width / 2,
top: size.value.height / 2,
originX: "center",
originY: "center",
});
c.add(img);
c.setActiveObject(img);
c.requestRenderAll();
return img;
}
/** 加一个文本框,默认居中。 */
function addText(text = "标题"): Textbox {
const c = canvas.value;
if (!c) throw new Error("画布还没挂载");
const box = new Textbox(text, {
left: size.value.width / 2,
top: size.value.height / 2,
originX: "center",
originY: "center",
width: size.value.width * 0.8,
fontSize: Math.round(size.value.height * 0.12),
fill: "#ffffff",
textAlign: "center",
});
c.add(box);
c.setActiveObject(box);
c.requestRenderAll();
return box;
}
function getCanvas(): Canvas {
const c = canvas.value;
if (!c) throw new Error("画布还没挂载");
return c;
}
export function useEditor() {
return {
canvas,
activeObject,
selectionKind,
size,
displayScale,
mount,
unmount,
setSize,
fitTo,
addImage,
addText,
getCanvas,
};
}
+23
View File
@@ -0,0 +1,23 @@
import { ref } from "vue";
import type { FontEntry } from "@/types";
/**
* 【T2 待实现 — 字体子系统】
*
* 前端这一半:向 `/__fonts` 取列表,选中某款时向 `/__fonts/:id` 取文件,
* 用 `FontFace` API 动态挂载到 `document.fonts`,之后 Fabric 的 Textbox 就能用
* `fontFamily: entry.family` 渲染。
*
* 注意 Fabric 在字体真正 ready 之前渲染会退到 fallback 字形,
* 挂载后要 `await document.fonts.load(...)` 再 `canvas.requestRenderAll()`。
*/
export const fonts = ref<FontEntry[]>([]);
export const loadingFonts = ref(false);
export async function listFonts(): Promise<FontEntry[]> {
throw new Error("T2 未实现:listFonts");
}
export async function ensureFontLoaded(_entry: FontEntry): Promise<void> {
throw new Error("T2 未实现:ensureFontLoaded");
}
+28
View File
@@ -0,0 +1,28 @@
import type { Plugin } from "vite";
/**
* 【T2 待实现 — 字体子系统】
*
* Vite 开发服务器中间件,给前端提供本机安装的字体。两个接口:
*
* GET /__fonts -> FontListResponse(见 src/types.ts)
* GET /__fonts/:id -> 字体文件二进制,Content-Type 按 ext 给
*
* 枚举来源是 Windows 注册表的两个键(本机实测共 651 条):
* HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts 系统字体,值是文件名,目录 C:\Windows\Fonts
* HKCU\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts 用户字体,值多为绝对路径
*
* 注意 `.ttc` 是合集包(如 msyh.ttc 里有微软雅黑的多个 face),浏览器不能直接当 webfont 用,
* 需要抽出单个 face 再返回;`FontEntry.needsExtraction` 标记这种情况。
*
* 只在 `apply: "serve"` 下生效,构建产物里不存在这两个接口。
*/
export function fontsPlugin(): Plugin {
return {
name: "cover-studio:fonts",
apply: "serve",
configureServer() {
// T2 在这里挂 server.middlewares.use(...)
},
};
}
+25
View File
@@ -0,0 +1,25 @@
import type { ExportOptions, Template } from "@/types";
/**
* 【T5 待实现 — 导出与模板】
*
* export.ts:
* 画布导出成 WebP(默认,带质量滑杆)或 PNG,走浏览器下载到下载文件夹。
* **不上传图床、不写 frontmatter**——这两条明确不做。
* 导出必须是画布的逻辑尺寸(全分辨率),不受预览缩放影响;
* `useEditor().displayScale` 只改 CSS 尺寸,所以 `toDataURL` 默认就是对的,
* 但要注意 multiplier 别被重复乘一次。
*
* templates.ts:
* 模板 = `canvas.toJSON()` + 画布尺寸,存 localStorage(见 types.ts 的 Template)。
* 没有内置预设包,全靠用户自己存。加载走 `canvas.loadFromJSON()`。
* 存之前扫一遍文本对象的 fontFamily 填进 `usedFonts`——用了本机字体的模板
* 换台机器复现不出来,加载时要提示。
*/
export async function exportCanvas(_options: ExportOptions): Promise<void> {
throw new Error("T5 未实现:exportCanvas");
}
export function listTemplates(): Template[] {
throw new Error("T5 未实现:listTemplates");
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./style.css";
createApp(App).mount("#app");
+18
View File
@@ -0,0 +1,18 @@
import type { BackgroundAsset } from "@/types";
/**
* 【T4 待实现 — 背景来源】
*
* 三个来源各写一个模块,都返回 BackgroundAsset:
* - upload.ts 本地文件 -> object URL
* - geometry.ts 预设几何底纹,程序生成(画到离屏 canvas 再 toDataURL)
* - unsplash.ts Unsplash 搜索。Access Key 从 import.meta.env.VITE_UNSPLASH_KEY 读,
* 放 .env.local(已 gitignore)。条款强制署名,返回时填 attribution。
*/
export async function fromUpload(_file: File): Promise<BackgroundAsset> {
throw new Error("T4 未实现:fromUpload");
}
export async function searchUnsplash(_query: string): Promise<BackgroundAsset[]> {
throw new Error("T4 未实现:searchUnsplash");
}
+74
View File
@@ -0,0 +1,74 @@
/* 全局样式与设计 token。并行开发期间**只有主控改这个文件**,
* 各面板用这里的变量,不要自带一套颜色。 */
:root {
--bg: #17171a;
--bg2: #1f1f23;
--bg3: #27272c;
--fg: #e8e8ea;
--fg-dim: #9a9aa2;
--line: #33333a;
--accent: #6ea8fe;
--radius: 6px;
--sidebar-w: 320px;
color-scheme: dark;
font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
font-size: 13px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
}
button {
font: inherit;
color: var(--fg);
background: var(--bg3);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 5px 10px;
cursor: pointer;
}
button:hover {
border-color: var(--accent);
}
input,
select {
font: inherit;
color: var(--fg);
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 4px 6px;
width: 100%;
}
input[type="range"] {
padding: 0;
}
label {
display: block;
color: var(--fg-dim);
margin-bottom: 4px;
}
/* 面板通用:一行「标签 + 控件」 */
.field {
margin-bottom: 10px;
}
.row {
display: flex;
gap: 8px;
align-items: center;
}
+142
View File
@@ -0,0 +1,142 @@
/**
* 全仓共享的类型契约。
*
* **这个文件是四条并行开发线之间唯一的接口。** 每条线只实现自己目录下的文件,
* 通过这里的类型互相衔接,因此:
*
* - 并行开发期间**不要改动本文件**。需要新增字段先在任务书里提出,由主控统一改。
* - 每条线的实现必须满足这里声明的形状,不要在自己的目录里另立一套同义类型。
*/
/* ---------------------------------------------------------------- 画布 */
/** 画布尺寸预设。宽高是导出像素,不是显示尺寸。 */
export interface CanvasSizePreset {
id: string;
label: string;
width: number;
height: number;
}
export interface CanvasSize {
width: number;
height: number;
}
/* ---------------------------------------------------------------- 字体(T2) */
/**
* 一款可用字体。由 Vite 中间件 `GET /__fonts` 枚举本机安装的字体后返回。
* `id` 是稳定标识,前端用它向 `GET /__fonts/:id` 取字体文件并 `@font-face` 挂载。
*/
export interface FontEntry {
/** 稳定 id,用于取文件;不要假设它等于文件名。 */
id: string;
/** 用于 CSS font-family 和界面显示的字族名,如 "阿里巴巴普惠体-H"。 */
family: string;
/** 字体文件绝对路径(仅供调试与配方记录,前端不直接读)。 */
path: string;
/** 文件后缀,小写不带点:ttf / otf / ttc / woff2 … */
ext: string;
/**
* `.ttc` 是合集包,一个文件里多个 face,不能直接当 webfont。
* 中间件负责抽出单个 face 再吐给前端;这个字段标记它需要抽取,
* 前端据此可以在下拉里给个提示。
*/
needsExtraction: boolean;
}
/** `GET /__fonts` 的响应体。 */
export interface FontListResponse {
fonts: FontEntry[];
}
/* ---------------------------------------------------------------- 滤镜(T3) */
/** 一个滤镜参数的取值范围,用来在侧栏自动生成滑杆。 */
export interface FilterParam {
key: string;
label: string;
min: number;
max: number;
step: number;
default: number;
}
/**
* 滤镜定义。注册进 `src/editor/filters/index.ts` 的注册表后,
* FilterPanel 会自动按 `params` 渲染滑杆,不需要为每个滤镜写界面。
*
* `build` 返回一个 Fabric 滤镜实例(`fabric.filters.*` 或自定义的 BaseFilter 子类)。
* 类型故意留成 unknown,避免这个契约文件依赖 fabric 的内部类型。
*/
export interface FilterDef {
id: string;
label: string;
params: FilterParam[];
build: (values: Record<string, number>) => unknown;
}
/* ---------------------------------------------------------------- 背景来源(T4) */
export type BackgroundSourceKind = "upload" | "geometry" | "unsplash";
/**
* 素材署名。**不画进图里**——由界面输出成可复制的文本,用户自己贴进文章。
* Unsplash 的 API 条款强制要求署名,`geometry` 和 `upload` 通常没有。
*/
export interface Attribution {
/** 直接给用户复制的成品文本,如 "Photo by X on Unsplash"。 */
text: string;
/** 带 utm_source 的原图链接,没有就省略。 */
url?: string;
}
/** 一份背景素材。`objectURL` 或 `dataURL` 二选一,交给编辑器装进画布。 */
export interface BackgroundAsset {
kind: BackgroundSourceKind;
/** 可直接喂给 fabric 的图片地址(object URL / data URL / 跨域已处理的 http URL)。 */
src: string;
width: number;
height: number;
attribution?: Attribution;
}
/* ---------------------------------------------------------------- 导出与模板(T5) */
export type ExportFormat = "webp" | "png";
export interface ExportOptions {
format: ExportFormat;
/** 0–1,只对 webp 生效。 */
quality: number;
/** 不含扩展名。 */
filename: string;
}
/**
* 一份模板 = 一份 Fabric 画布 JSON + 它的画布尺寸。
* 用户自己保存,存在 localStorage;没有内置预设包。
*/
export interface Template {
id: string;
name: string;
size: CanvasSize;
/** `canvas.toJSON()` 的产物,原样存原样喂给 `loadFromJSON`。 */
canvasJSON: unknown;
/** ISO 8601。 */
createdAt: string;
/**
* 这份模板引用的本机字体 family 列表。
* 用了本机字体的模板换台机器复现不出来,界面据此给出警告。
*/
usedFonts: string[];
}
/* ---------------------------------------------------------------- 侧栏面板 */
/** 右侧栏面板。选中对象的类型决定显示哪几个。 */
export type PanelId = "canvas" | "source" | "filter" | "text" | "export";
/** 画布当前选中了什么,用来决定右栏显示哪些面板。 */
export type SelectionKind = "none" | "image" | "text" | "other";