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:
+21
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 {};
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { FilterDef } from "@/types";
|
||||
|
||||
/**
|
||||
* 【T3 待实现 — 滤镜注册表】
|
||||
*
|
||||
* 往这个数组里注册滤镜,FilterPanel 会按 `params` 自动渲染滑杆,
|
||||
* 不需要为每个滤镜单独写界面。
|
||||
*
|
||||
* 第一版要有:高斯模糊、亮度、对比度、饱和度、像素化(都是 fabric 内建的
|
||||
* `filters.Blur / Brightness / Contrast / Saturation / Pixelate`),
|
||||
* 外加自定义的晶格化(见 ./crystallize)。
|
||||
*/
|
||||
export const FILTERS: FilterDef[] = [];
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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(...)
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./style.css";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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";
|
||||
Reference in New Issue
Block a user