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
+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>