360 lines
12 KiB
TypeScript
360 lines
12 KiB
TypeScript
import {
|
||
ActiveSelection,
|
||
Canvas,
|
||
Ellipse,
|
||
FabricImage,
|
||
FabricObject,
|
||
Line,
|
||
Polygon,
|
||
Rect,
|
||
Textbox,
|
||
type TPointerEvent,
|
||
} from "fabric";
|
||
import { ref, shallowRef } from "vue";
|
||
import type { CanvasSize, LayerKind, SelectionKind, ShapeKind, ToolId } from "@/types";
|
||
import { DEFAULT_SIZE } from "./canvasSize";
|
||
|
||
/**
|
||
* 编辑器核心:一个模块级单例。
|
||
*
|
||
* 画布是**逻辑尺寸**(导出像素,如 1200×630)。屏幕上的大小由 `useViewport` 的
|
||
* 缩放平移决定,两者分开——视口怎么变都不影响导出分辨率。
|
||
*
|
||
* 各条开发线通过 `getCanvas()` 拿到 Fabric 画布实例。**不要另外 new 一个 Canvas。**
|
||
*/
|
||
|
||
/** 自定义属性要登记在这里,`canvas.toJSON()` 才会带上(fabric v7 的 customProperties 机制)。 */
|
||
FabricObject.customProperties = ["layerId", "layerName", "layerLocked", "isBackground", "shapeKind"];
|
||
|
||
declare module "fabric" {
|
||
interface FabricObject {
|
||
/** 稳定图层 id,跨 toJSON / loadFromJSON 保留。 */
|
||
layerId?: string;
|
||
/** 图层名。没设过就由 `defaultLayerName()` 按类型给一个。 */
|
||
layerName?: string;
|
||
/** 锁定:不可选中、不可移动。和 fabric 自带的 selectable/evented 联动。 */
|
||
layerLocked?: boolean;
|
||
/**
|
||
* 这是**那一个**专用背景图层。全画布最多一个,永远在最底层,
|
||
* 换背景是替换它而不是再加一层。归 `useBackground` 管,别处不要自己置位。
|
||
*/
|
||
isBackground?: boolean;
|
||
/**
|
||
* 形状工具画出来的形状是哪一种。fabric 的类推不出「箭头」——它就是一个
|
||
* Polygon/Path,和多边形同类;这个字段把用户的意图记下来,图层图标和
|
||
* 右栏属性都读它。
|
||
*/
|
||
shapeKind?: ShapeKind;
|
||
}
|
||
}
|
||
|
||
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 });
|
||
/** 当前工具。`select` 是默认态。 */
|
||
const tool = ref<ToolId>("select");
|
||
/** 形状工具当前要画哪一种形状。工具栏形状按钮右边的下拉改它。 */
|
||
const shapeKind = ref<ShapeKind>("rect");
|
||
/**
|
||
* 画布内容的版本号。对象增删改、层序变化都会 +1。
|
||
* 图层面板、属性检查器 watch 它来重新推导自己的视图——
|
||
* fabric 不是响应式的,这是唯一的变更信号。
|
||
*/
|
||
const revision = ref(0);
|
||
|
||
let idSeq = 0;
|
||
|
||
/** 给还没有 id 的对象补一个。 */
|
||
export function ensureLayerId(obj: FabricObject): string {
|
||
if (!obj.layerId) obj.layerId = `L${Date.now().toString(36)}${(idSeq++).toString(36)}`;
|
||
return obj.layerId;
|
||
}
|
||
|
||
export function layerKindOf(obj: FabricObject | null | undefined): LayerKind {
|
||
if (!obj) return "other";
|
||
// 背景要先判:它本身是一张 FabricImage 或一个 Rect,光看类型分不出来。
|
||
if (obj.isBackground) return "background";
|
||
if (obj.shapeKind) return obj.shapeKind;
|
||
if (obj instanceof FabricImage) return "image";
|
||
if (obj instanceof Textbox) return "text";
|
||
if (obj instanceof Ellipse) return "ellipse";
|
||
if (obj instanceof Line) return "line";
|
||
if (obj instanceof Polygon) return "polygon";
|
||
if (obj instanceof Rect) return "rect";
|
||
return "other";
|
||
}
|
||
|
||
/** 这个种类算不算「形状」——形状共用同一组外观属性(填充、描边、圆角)。 */
|
||
export function isShapeKind(kind: LayerKind): boolean {
|
||
return kind === "rect" || kind === "line" || kind === "arrow" || kind === "ellipse" || kind === "polygon";
|
||
}
|
||
|
||
const KIND_NAME: Record<LayerKind, string> = {
|
||
background: "背景",
|
||
image: "图片",
|
||
text: "文字",
|
||
rect: "矩形",
|
||
line: "直线",
|
||
arrow: "箭头",
|
||
ellipse: "圆形",
|
||
polygon: "多边形",
|
||
other: "对象",
|
||
};
|
||
|
||
export function defaultLayerName(obj: FabricObject): string {
|
||
const kind = layerKindOf(obj);
|
||
if (kind === "text") return (obj as Textbox).text?.slice(0, 20) || "文字";
|
||
return KIND_NAME[kind];
|
||
}
|
||
|
||
/**
|
||
* 选中态:右栏据此决定显示哪几组属性。
|
||
*
|
||
* 五种形状塌成一个 `shape`——它们共用同一组外观属性,右栏没必要为每种各写一份。
|
||
* 背景图层归到 `image`:它就是一张图,滤镜之类的都适用。
|
||
*/
|
||
function kindOf(obj: FabricObject | null | undefined): SelectionKind {
|
||
if (!obj) return "none";
|
||
if (obj instanceof ActiveSelection) return "multi";
|
||
const k = layerKindOf(obj);
|
||
if (k === "background" || k === "image") return "image";
|
||
if (k === "text") return "text";
|
||
return isShapeKind(k) ? "shape" : "other";
|
||
}
|
||
|
||
function syncSelection() {
|
||
const c = canvas.value;
|
||
const obj = c?.getActiveObject() ?? null;
|
||
activeObject.value = obj;
|
||
selectionKind.value = obj ? kindOf(obj) : "none";
|
||
// 选中变化也要发变更信号。少了这句,「在画布上点选另一个对象」不会 bump revision,
|
||
// 所有从 revision 推导选中态的视图(图层面板的高亮首当其冲)都会停在旧值上。
|
||
bumpRevision();
|
||
}
|
||
|
||
/** 内容变了:让所有 watch revision 的视图重新推导。 */
|
||
export function bumpRevision() {
|
||
revision.value++;
|
||
}
|
||
|
||
/**
|
||
* 画板(artboard)的底与边界。
|
||
*
|
||
* 关键区别:`backgroundColor` 填的是**整个画布元素**,而画布元素现在铺满整个工作区,
|
||
* 所以它是「画布外的空白」的颜色。真正的画板——那个 1200×630 的矩形——
|
||
* 用 `backgroundImage` 画(它是 FabricObject,跟随 viewportTransform,
|
||
* 且**不进 `getObjects()`**,所以不会跑到图层面板里去)。
|
||
*
|
||
* **不要用 `canvas.clipPath` 把对象裁在画板内。** 它不吃 viewportTransform——
|
||
* fabric 的文档原话是渲染完之后「the context is placed in the top left corner of
|
||
* the canvas」,即在画布元素坐标里裁剪。缩放平移一开,裁剪区和画板就错位,
|
||
* 屏幕上会出现一块与画板无关的方形亮区(2026-09-05 踩过)。
|
||
* 超出画板的部分照常显示(Figma 不开 clip 的 frame 也是这样),
|
||
* 导出时按画板裁剪,见 useViewport 的 artboardExportOptions()。
|
||
*/
|
||
function makeArtboard(w: number, h: number) {
|
||
return new Rect({
|
||
left: 0,
|
||
top: 0,
|
||
// **必须显式写 left/top 原点。** fabric v7 的默认原点是**中心**,
|
||
// 不写的话 left:0,top:0 会被当成「以场景原点为中心」,画板落在 (-w/2,-h/2),
|
||
// 屏幕上看到的是被画布裁掉负半边之后的一块怪方形(2026-09-05 排查了很久)。
|
||
originX: "left",
|
||
originY: "top",
|
||
width: w,
|
||
height: h,
|
||
fill: "#1b1b1f",
|
||
selectable: false,
|
||
evented: false,
|
||
});
|
||
}
|
||
|
||
function syncArtboard(c: Canvas) {
|
||
c.backgroundImage = makeArtboard(size.value.width, size.value.height);
|
||
}
|
||
|
||
/** 挂载画布。只在 Workspace.vue 里调一次。 */
|
||
function mount(el: HTMLCanvasElement): Canvas {
|
||
const c = new Canvas(el, {
|
||
width: size.value.width,
|
||
height: size.value.height,
|
||
// 这是画板**外面**的颜色,不是画板本身。画板见 syncArtboard。
|
||
backgroundColor: "#0f0f11",
|
||
preserveObjectStacking: true,
|
||
// clipPath 会把选中控件一起裁掉,抬到裁剪之上才能在画板边缘正常操作。
|
||
controlsAboveOverlay: true,
|
||
// 图层面板要能选中被上层盖住的对象,所以点选走「最上层命中」而不是「整体包围盒」。
|
||
perPixelTargetFind: false,
|
||
});
|
||
syncArtboard(c);
|
||
|
||
c.on("selection:created", syncSelection);
|
||
c.on("selection:updated", syncSelection);
|
||
c.on("selection:cleared", syncSelection);
|
||
|
||
// 新对象一律补上 id,哪怕是别处直接 canvas.add() 加进来的。
|
||
c.on("object:added", (e) => {
|
||
if (e.target) registerLayer(e.target);
|
||
bumpRevision();
|
||
});
|
||
// 增删改都要通知视图层;moving/scaling/rotating 让右栏的 x/y/角度跟着拖拽实时走。
|
||
for (const ev of ["object:removed", "object:modified", "object:moving", "object:scaling", "object:rotating"] as const) {
|
||
c.on(ev, bumpRevision);
|
||
}
|
||
|
||
canvas.value = c;
|
||
bumpRevision();
|
||
return c;
|
||
}
|
||
|
||
function unmount() {
|
||
canvas.value?.dispose();
|
||
canvas.value = null;
|
||
activeObject.value = null;
|
||
selectionKind.value = "none";
|
||
}
|
||
|
||
/** 改逻辑尺寸(导出像素)。视口缩放由 useViewport 单独管。 */
|
||
function setSize(next: CanvasSize) {
|
||
size.value = next;
|
||
const c = canvas.value;
|
||
if (!c) return;
|
||
// 画布元素的尺寸是「视口」,由 Workspace 跟着容器设;这里只重画画板。
|
||
syncArtboard(c);
|
||
c.requestRenderAll();
|
||
bumpRevision();
|
||
}
|
||
|
||
/** 把对象登记成图层:补 id、补名字、按锁定状态设好可交互性。 */
|
||
export function registerLayer(obj: FabricObject, name?: string): FabricObject {
|
||
ensureLayerId(obj);
|
||
if (!obj.layerName) obj.layerName = name ?? defaultLayerName(obj);
|
||
applyLocked(obj, obj.layerLocked === true);
|
||
return obj;
|
||
}
|
||
|
||
/** 锁定/解锁:fabric 没有单一开关,要同时关掉选中、事件和变换。 */
|
||
export function applyLocked(obj: FabricObject, locked: boolean) {
|
||
obj.layerLocked = locked;
|
||
obj.selectable = !locked;
|
||
obj.evented = !locked;
|
||
obj.lockMovementX = locked;
|
||
obj.lockMovementY = locked;
|
||
obj.lockScalingX = locked;
|
||
obj.lockScalingY = locked;
|
||
obj.lockRotation = locked;
|
||
obj.hasControls = !locked;
|
||
}
|
||
|
||
/**
|
||
* 装一张图进画布并铺满(object-fit: cover 的等效)。
|
||
* 背景性质的图沉到最底层,否则会盖住先加进来的文字。
|
||
*/
|
||
async function addImage(src: string, opts: { toBack?: boolean } = {}): 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",
|
||
});
|
||
registerLayer(img);
|
||
c.add(img);
|
||
if (opts.toBack !== false) c.sendObjectToBack(img);
|
||
c.setActiveObject(img);
|
||
c.requestRenderAll();
|
||
bumpRevision();
|
||
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",
|
||
});
|
||
registerLayer(box);
|
||
c.add(box);
|
||
c.setActiveObject(box);
|
||
c.requestRenderAll();
|
||
bumpRevision();
|
||
return box;
|
||
}
|
||
|
||
/** 加一个矩形。圆角走 rx/ry,右栏的「圆角」改的就是它。 */
|
||
function addRect(at?: { x: number; y: number }): Rect {
|
||
const c = canvas.value;
|
||
if (!c) throw new Error("画布还没挂载");
|
||
const w = Math.round(size.value.width * 0.3);
|
||
const h = Math.round(size.value.height * 0.25);
|
||
const rect = new Rect({
|
||
left: at?.x ?? size.value.width / 2,
|
||
top: at?.y ?? size.value.height / 2,
|
||
originX: "center",
|
||
originY: "center",
|
||
width: w,
|
||
height: h,
|
||
fill: "#6ea8fe",
|
||
rx: 0,
|
||
ry: 0,
|
||
});
|
||
registerLayer(rect);
|
||
c.add(rect);
|
||
c.setActiveObject(rect);
|
||
c.requestRenderAll();
|
||
bumpRevision();
|
||
return rect;
|
||
}
|
||
|
||
function getCanvas(): Canvas {
|
||
const c = canvas.value;
|
||
if (!c) throw new Error("画布还没挂载");
|
||
return c;
|
||
}
|
||
|
||
/** 当前选中的对象们。单选返回一个,框选返回多个,没选返回空数组。 */
|
||
function selectedObjects(): FabricObject[] {
|
||
const c = canvas.value;
|
||
if (!c) return [];
|
||
return c.getActiveObjects();
|
||
}
|
||
|
||
export function useEditor() {
|
||
return {
|
||
canvas,
|
||
activeObject,
|
||
selectionKind,
|
||
size,
|
||
tool,
|
||
shapeKind,
|
||
revision,
|
||
mount,
|
||
unmount,
|
||
setSize,
|
||
addImage,
|
||
addText,
|
||
addRect,
|
||
getCanvas,
|
||
selectedObjects,
|
||
bumpRevision,
|
||
registerLayer,
|
||
applyLocked,
|
||
};
|
||
}
|
||
|
||
export type { TPointerEvent };
|