Files
cover-studio/src/editor/useEditor.ts
T
im0o a72014e419 perf: 抬高对象缓存上限,预览放大时文字和形状锐利到约 6 倍
fabric 给每个对象缓存一张离屏画布,面积超过 perfLimitSizeTotal 时不是裁剪而是
降采样,再放大贴屏,于是边缘发虚。默认 2 Mpx 下一个 960x75 的文本框在 12 倍
缩放时 zoomX 从 12 掉到 3.5。

面积和边长是两道独立的闸:只把面积抬到 8 Mpx,zoomX 只从 3.5 涨到 3.9,紧接着
撞上 maxCacheSideLimit 的 4096。两道一起抬(边长 6144 / 面积 8 Mpx)之后实测
4 倍锐利、6 倍以上封顶在 5.92,覆盖了实际会用到的缩放区间。

只影响预览。导出按画板逻辑尺寸重新渲染,与视口缩放无关。
2026-09-05 18:19:32 +08:00

393 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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 的等效)。
* 背景性质的图沉到最底层,否则会盖住先加进来的文字。
*
* 进来先过一道 `capImageSize`:超过 fabric 纹理上限的图加了滤镜会缺右边和下边,
* 原因和取舍见 `imageLimits.ts`。
*/
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 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 };