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:
+186
-38
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user