import { ActiveSelection, Canvas, Ellipse, FabricImage, FabricObject, Line, Polygon, Rect, Textbox, type TPointerEvent, } from "fabric"; import { config } from "fabric"; import { ref, shallowRef } from "vue"; import type { CanvasSize, LayerKind, SelectionKind, ShapeKind, ToolId } from "@/types"; import { DEFAULT_SIZE } from "./canvasSize"; import { capImageSize } from "./imageLimits"; /** * 编辑器核心:一个模块级单例。 * * 画布是**逻辑尺寸**(导出像素,如 1200×630)。屏幕上的大小由 `useViewport` 的 * 缩放平移决定,两者分开——视口怎么变都不影响导出分辨率。 * * 各条开发线通过 `getCanvas()` 拿到 Fabric 画布实例。**不要另外 new 一个 Canvas。** */ /** * 预览放大后文字和形状要保持清晰。 * * fabric 给每个对象缓存一张离屏画布,尺寸随对象在屏幕上的大小走;一旦面积超过 * `config.perfLimitSizeTotal`(默认 2 097 152 px²),它不是裁剪而是**降采样**—— * 按缩小后的比例画进缓存,再放大贴到屏幕上,于是边缘发虚。 * * 实测(2026-09-05,一个约 960×75 的文本框):默认值下 4 倍缩放仍然锐利, * 12 倍时缓存被压到 3647×574、zoomX 从 12 掉到 3.5,肉眼可见的锯齿和发虚。 * * **面积和边长是两道独立的闸**,只抬面积没用:把 perfLimitSizeTotal 抬到 8 Mpx * 之后 zoomX 只从 3.5 涨到 3.9,因为紧接着撞上了 maxCacheSideLimit 的 4096。 * 两道一起抬才有效果。 * * 取值:边长 6144、面积 8 Mpx。对上面那个文本框大约能锐利到 6 倍,覆盖了实际 * 会用到的缩放区间(1200×630 的封面很少工作在 400% 以上)。代价是**单个**被压的 * 对象缓存最大 8 Mpx × 4 B = 32 MB,封面上对象个位数,这个代价可以接受。 * 再往上抬性价比很低:12 倍锐利需要 40 MB 以上的单对象缓存,而位图图层受自身 * 分辨率限制,抬多少都不会更清楚。 * * 注意这只影响**预览**。导出走的是另一条路(按画板逻辑尺寸重新渲染), * 和视口缩放无关,io 那边的自验已经断言过 0.25×–3× 导出恒为 1200×630。 */ config.perfLimitSizeTotal = 8 * 1024 * 1024; config.maxCacheSideLimit = 6144; /** 自定义属性要登记在这里,`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(null); const activeObject = shallowRef(null); const selectionKind = ref("none"); const size = ref({ width: DEFAULT_SIZE.width, height: DEFAULT_SIZE.height }); /** 当前工具。`select` 是默认态。 */ const tool = ref("select"); /** 形状工具当前要画哪一种形状。工具栏形状按钮右边的下拉改它。 */ const shapeKind = ref("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 = { 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 的等效)。 * 背景性质的图沉到最底层,否则会盖住先加进来的文字。 * * 进来先过一道 `capImageSize`:超过 fabric 纹理上限的图加了滤镜会缺右边和下边, * 原因和取舍见 `imageLimits.ts`。 */ async function addImage(src: string, opts: { toBack?: boolean } = {}): Promise { const c = canvas.value; if (!c) throw new Error("画布还没挂载"); const img = await FabricImage.fromURL(src, { crossOrigin: "anonymous" }); const capped = capImageSize(img.getElement() as HTMLImageElement); if (capped !== img.getElement()) img.setElement(capped); 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 };