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
+140 -30
View File
@@ -1,62 +1,172 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { onBeforeUnmount, onMounted, ref } from "vue";
import { useEditor } from "@/editor/useEditor";
import { useViewport } from "@/editor/useViewport";
import { initHistory, commit } from "@/editor/useHistory";
import { openContextMenu } from "@/editor/useContextMenu";
const { mount, unmount, fitTo, size } = useEditor();
/**
* 画布区。**画布元素的像素尺寸跟着容器走**(它是「视口」),
* 画布内容的逻辑尺寸由 useEditor 的 size 决定,两者靠 viewportTransform 换算。
*
* 这和旧版不同:旧版把画布元素本身缩放到容器里,所以看到多大就是多大;
* 现在画布元素铺满容器,内容在里面缩放平移,才能做出 Figma 那种无限画布的感觉。
*
* 交互:滚轮缩放(以光标为锚)、空格+拖 或 中键拖 平移、右键出上下文菜单。
*
* **这个文件由主控独占。**
*/
const { mount, unmount, getCanvas, tool, addText, addRect } = useEditor();
const { zoomAt, panBy, zoomToFit } = useViewport();
const canvasEl = ref<HTMLCanvasElement | null>(null);
const stage = ref<HTMLDivElement | null>(null);
let observer: ResizeObserver | null = null;
let spaceDown = false;
let panning = false;
let lastX = 0;
let lastY = 0;
function refit() {
/**
* 视口尺寸跟着容器走。
*
* **第一次拿到真实尺寸时必须重新适应画布。** `onMounted` 里 stage 的
* clientWidth/Height 可能还是 0 或过渡值,那时算出来的 zoomToFit 是按错误视口算的,
* 之后 setDimensions 换了视口却不重算,画板就会跑偏(2026-09-05 踩过)。
*/
let fitted = false;
function resize() {
const box = stage.value;
if (!box) return;
fitTo(box.clientWidth, box.clientHeight);
const c = canvasEl.value ? getCanvas() : null;
if (!box || !c) return;
const w = box.clientWidth;
const h = box.clientHeight;
if (w === 0 || h === 0) return;
c.setDimensions({ width: w, height: h });
if (!fitted) {
fitted = true;
zoomToFit();
}
c.requestRenderAll();
}
function onWheel(e: WheelEvent) {
// Ctrl+滚轮 = 缩放(和 Penpot / Figma 一致);不带修饰键 = 平移。
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
zoomAt(e.clientX - rect.left, e.clientY - rect.top, e.deltaY < 0 ? 1.1 : 1 / 1.1);
} else {
e.preventDefault();
panBy(-e.deltaX, -e.deltaY);
}
}
function onKeyDown(e: KeyboardEvent) {
if (e.code === "Space" && !spaceDown) {
spaceDown = true;
if (stage.value) stage.value.style.cursor = "grab";
}
}
function onKeyUp(e: KeyboardEvent) {
if (e.code === "Space") {
spaceDown = false;
if (stage.value) stage.value.style.cursor = "";
}
}
function onPointerDown(e: PointerEvent) {
// 中键或空格+左键 = 平移
if (e.button === 1 || (spaceDown && e.button === 0)) {
e.preventDefault();
panning = true;
lastX = e.clientX;
lastY = e.clientY;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
return;
}
// 非选择工具:在点击处放一个新对象,然后回到选择工具(和 Figma 一致)
if (e.button === 0 && tool.value !== "select") {
const c = getCanvas();
const p = c.getViewportPoint(e);
const scene = c.getScenePoint(e);
void p;
if (tool.value === "text") addText();
else if (tool.value === "rect") addRect({ x: scene.x, y: scene.y });
if (tool.value !== "image") {
tool.value = "select";
commit();
}
}
}
function onPointerMove(e: PointerEvent) {
if (!panning) return;
panBy(e.clientX - lastX, e.clientY - lastY);
lastX = e.clientX;
lastY = e.clientY;
}
function onPointerUp(e: PointerEvent) {
if (!panning) return;
panning = false;
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}
function onContextMenu(e: MouseEvent) {
e.preventDefault();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
openContextMenu(e.clientX - rect.left, e.clientY - rect.top);
}
onMounted(() => {
if (!canvasEl.value) return;
mount(canvasEl.value);
refit();
observer = new ResizeObserver(refit);
if (stage.value) observer.observe(stage.value);
if (!canvasEl.value || !stage.value) return;
const c = mount(canvasEl.value);
// 尺寸与首次适应交给 resize(),它会等到 stage 真的有尺寸了再做。
resize();
initHistory();
// 拖拽 / 缩放 / 旋转结束都算一步操作。
c.on("object:modified", () => commit());
observer = new ResizeObserver(resize);
observer.observe(stage.value);
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
});
onBeforeUnmount(() => {
observer?.disconnect();
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
unmount();
});
// 逻辑尺寸变了要重新算显示缩放。
watch(size, refit, { deep: true });
</script>
<template>
<div class="workspace">
<div class="stage" ref="stage">
<canvas ref="canvasEl"></canvas>
</div>
<div
class="stage"
ref="stage"
@wheel="onWheel"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@contextmenu="onContextMenu"
>
<canvas ref="canvasEl"></canvas>
</div>
</template>
<style scoped>
.workspace {
display: flex;
flex-direction: column;
min-width: 0;
background: var(--bg);
}
.stage {
flex: 1;
display: grid;
place-items: center;
min-height: 0;
position: absolute;
inset: 0;
background: var(--stage-bg);
overflow: hidden;
}
/* Fabric 会在 canvas 外面包一层 .canvas-container,阴影打在那上面才跟着缩放走。 */
/* 画布铺满视口,内容靠 viewportTransform 缩放平移。 */
.stage :deep(.canvas-container) {
box-shadow: 0 0 0 1px var(--line), 0 8px 32px rgb(0 0 0 / 0.4);
position: absolute !important;
inset: 0;
}
</style>