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,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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user