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