feat: Figma 式外壳与编辑器内核

布局改成 顶部工具栏 / 左图层 / 中画布 / 右属性 / 底部悬浮胶囊。

新内核:
- 图层模型:fabric 对象是唯一真源,LayerInfo 是只读投影;
  id/名字/锁定挂在 customProperties 上,随 toJSON 持久化
- 视口:zoom/pan 走 viewportTransform,只影响显示;
  导出改为按视口裁剪(artboardExportOptions),旧的「multiplier 恒为 1」
  在画布铺满工作区之后不再成立
- 历史:快照式撤销重做,30 步上限,带重入保护
- 命令表:快捷键 / 右键菜单 / 工具栏三个入口共用一份注册表
  键位取自 Penpot 快捷键表
- 画板用 backgroundImage 画(跟随视口且不进 getObjects)

两个 fabric 陷阱已在注释里记下:
- Rect 默认原点是中心,left:0/top:0 不写 originX/originY 会画错位置
- canvas.clipPath 不吃 viewportTransform,不能用来裁画板
This commit is contained in:
Jz0ojiang
2026-09-05 02:18:20 +08:00
parent 4833de7b68
commit 8934f374bb
21 changed files with 1493 additions and 98 deletions
+186 -38
View File
@@ -1,36 +1,119 @@
import { Canvas, FabricImage, Textbox, type FabricObject } from "fabric";
import { Canvas, FabricImage, FabricObject, Rect, Textbox, type TPointerEvent } from "fabric";
import { ref, shallowRef } from "vue";
import type { CanvasSize, SelectionKind } from "@/types";
import type { CanvasSize, LayerKind, SelectionKind, ToolId } from "@/types";
import { DEFAULT_SIZE } from "./canvasSize";
/**
* 编辑器核心:一个模块级单例。
*
* 画布是**逻辑尺寸**(导出像素,如 1200×630),显示时按容器缩放——
* Fabric 的 backstore 尺寸和 CSS 尺寸分开设置,所以预览缩放不影响导出分辨率。
* 画布是**逻辑尺寸**(导出像素,如 1200×630)。屏幕上的大小由 `useViewport` 的
* 缩放平移决定,两者分开——视口怎么变都不影响导出分辨率。
*
* 各条开发线通过 `getCanvas()` 拿到 Fabric 画布实例。**不要另外 new 一个 Canvas。**
*/
/** 自定义属性要登记在这里,`canvas.toJSON()` 才会带上(fabric v7 的 customProperties 机制)。 */
FabricObject.customProperties = ["layerId", "layerName", "layerLocked"];
declare module "fabric" {
interface FabricObject {
/** 稳定图层 id,跨 toJSON / loadFromJSON 保留。 */
layerId?: string;
/** 图层名。没设过就由 `defaultLayerName()` 按类型给一个。 */
layerName?: string;
/** 锁定:不可选中、不可移动。和 fabric 自带的 selectable/evented 联动。 */
layerLocked?: boolean;
}
}
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);
/** 当前工具。`select` 是默认态。 */
const tool = ref<ToolId>("select");
/**
* 画布内容的版本号。对象增删改、层序变化都会 +1。
* 图层面板、属性检查器 watch 它来重新推导自己的视图——
* fabric 不是响应式的,这是唯一的变更信号。
*/
const revision = ref(0);
function kindOf(obj: FabricObject | null | undefined): SelectionKind {
if (!obj) return "none";
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";
if (obj instanceof FabricImage) return "image";
if (obj instanceof Textbox) return "text";
if (obj instanceof Rect) return "rect";
return "other";
}
export function defaultLayerName(obj: FabricObject): string {
const kind = layerKindOf(obj);
if (kind === "text") return (obj as Textbox).text?.slice(0, 20) || "文字";
if (kind === "image") return "图片";
if (kind === "rect") return "矩形";
return "对象";
}
function kindOf(obj: FabricObject | null | undefined): SelectionKind {
const k = layerKindOf(obj);
return k === "other" ? (obj ? "other" : "none") : k === "rect" ? "other" : k;
}
function syncSelection() {
const c = canvas.value;
const obj = c?.getActiveObject() ?? null;
activeObject.value = obj;
selectionKind.value = kindOf(obj);
selectionKind.value = obj ? kindOf(obj) : "none";
}
/** 内容变了:让所有 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 里调一次。 */
@@ -38,13 +121,32 @@ function mount(el: HTMLCanvasElement): Canvas {
const c = new Canvas(el, {
width: size.value.width,
height: size.value.height,
backgroundColor: "#1b1b1f",
// 这是画板**外面**的颜色,不是画板本身。画板见 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;
}
@@ -55,39 +157,43 @@ function unmount() {
selectionKind.value = "none";
}
/** 改逻辑尺寸(导出像素)。显示缩放由 `fitTo` 单独算。 */
/** 改逻辑尺寸(导出像素)。视口缩放由 useViewport 单独管。 */
function setSize(next: CanvasSize) {
size.value = next;
const c = canvas.value;
if (!c) return;
c.setDimensions({ width: next.width, height: next.height });
// 画布元素的尺寸是「视口」,由 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;
}
/**
* 把画布缩放到容器里显示。改的是 CSS 尺寸,backstore 不动,
* 所以导出的仍然是逻辑尺寸的全分辨率。
* 装一张图进画布并铺满(object-fit: cover 的等效)。
* 背景性质的图沉到最底层,否则会盖住先加进来的文字。
*/
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> {
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" });
@@ -100,11 +206,12 @@ async function addImage(src: string): Promise<FabricImage> {
originX: "center",
originY: "center",
});
registerLayer(img);
c.add(img);
// 背景图永远沉到最底层,否则会盖住先加进来的文字。
c.sendObjectToBack(img);
if (opts.toBack !== false) c.sendObjectToBack(img);
c.setActiveObject(img);
c.requestRenderAll();
bumpRevision();
return img;
}
@@ -122,31 +229,72 @@ function addText(text = "标题"): Textbox {
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,
displayScale,
tool,
revision,
mount,
unmount,
setSize,
fitTo,
addImage,
addText,
addRect,
getCanvas,
selectedObjects,
bumpRevision,
registerLayer,
applyLocked,
};
}
export type { TPointerEvent };