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
+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,
};
}