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
+52 -4
View File
@@ -1,21 +1,69 @@
<script setup lang="ts">
import TopBar from "./components/TopBar.vue";
import LayersPanel from "./components/LayersPanel.vue";
import Workspace from "./components/Workspace.vue";
import Sidebar from "./components/Sidebar.vue";
import Inspector from "./components/Inspector.vue";
import BottomDock from "./components/BottomDock.vue";
import SelectionMenu from "./components/SelectionMenu.vue";
import ContextMenu from "./components/ContextMenu.vue";
import { useShortcuts } from "./editor/useShortcuts";
/**
* Figma 式布局:顶部工具栏 / 左侧图层 / 中间画布 / 右侧属性 / 底部悬浮胶囊。
*
* `SelectionMenu`(选中对象上方的圆角编辑条)、`BottomDock`(底部胶囊)、
* `ContextMenu`(右键菜单)都是**浮在画布上的绝对定位层**,不占布局空间——
* 它们放在 .stage 里而不是 grid 的格子里,就是这个原因。
*
* **这个文件由主控独占**,并行开发期间各线只实现自己那个组件。
*/
useShortcuts();
</script>
<template>
<div class="app">
<TopBar class="area-top" />
<LayersPanel class="area-left" />
<div class="area-stage">
<Workspace />
<Sidebar />
<SelectionMenu />
<BottomDock />
<ContextMenu />
</div>
<Inspector class="area-right" />
</div>
</template>
<style scoped>
/* 左侧大部分版面是预览 / 工作区,右侧是工具与配置侧边栏。 */
.app {
display: grid;
grid-template-columns: 1fr var(--sidebar-w);
grid-template-columns: var(--layers-w) 1fr var(--inspector-w);
grid-template-rows: var(--topbar-h) 1fr;
grid-template-areas:
"top top top"
"left stage right";
height: 100vh;
overflow: hidden;
}
.area-top {
grid-area: top;
}
.area-left {
grid-area: left;
}
.area-right {
grid-area: right;
}
/* 画布区是浮层的定位上下文:选中菜单、底部胶囊、右键菜单都绝对定位在它里面。 */
.area-stage {
grid-area: stage;
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
}
</style>
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
/**
* 【T7 待实现 — 底部悬浮胶囊】
*
* 浮在画布下缘居中的圆角胶囊,装高频快捷操作:
* 缩放百分比(可点开选 50/100/200%、适应画布、缩放到选中)、减号/加号、
* 适应画布按钮。全部走 `runCommand("zoomFit")` 等命令,不要自己调 viewport。
*
* 缩放数值读 `useViewport().zoom`。
* 它是浮层,绝对定位在 .area-stage 里,不占布局空间——不要给它加 margin 去挤画布。
*/
</script>
<template>
<div class="dock">
<span class="todo">T7 待实现:缩放胶囊</span>
</div>
</template>
<style scoped>
.dock {
position: absolute;
left: 50%;
bottom: 16px;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--bg3);
box-shadow: 0 6px 24px rgb(0 0 0 / 0.45);
}
.todo {
color: var(--fg-dim);
}
</style>
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
/**
* 【T9 待实现 — 右键上下文菜单】
*
* 开合与位置在 `@/editor/useContextMenu`(坐标相对画布区左上角)。
* 菜单内容读 `CANVAS_CONTEXT_MENU`(`@/editor/commands`),每项指向一条命令:
* 标题用 `command.label`,右侧灰字用 `keyHintOf(id)`(`@/editor/useShortcuts`),
* `command.enabled()` 为 false 时置灰不可点,点击走 `runCommand(id)`。
*
* **不要在这里自己写删除/层序的逻辑**——那些都在命令表里,
* 快捷键和工具栏走的是同一条,抄一份必然走偏。
*
* 要处理:点菜单外或按 Esc 关闭;贴近视口边缘时翻转,不要被裁掉。
*/
</script>
<template>
<div v-if="false" class="context-menu">T9 待实现</div>
</template>
<style scoped>
.context-menu {
position: absolute;
min-width: 180px;
padding: 4px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--bg3);
box-shadow: 0 8px 28px rgb(0 0 0 / 0.5);
z-index: 10;
}
</style>
+46
View File
@@ -0,0 +1,46 @@
<script setup lang="ts">
/**
* 【T8 待实现 — 右侧属性检查器】
*
* 只放**选中对象的属性**,不再是原来那个什么都装的侧栏。分组:
*
* - 位置与尺寸:X / Y / W / H(W/H 是缩放后的实际占位尺寸,见 types.ts 的 TransformValues)
* - 变换:缩放比例 X/Y、旋转角度
* - 外观:不透明度、圆角(矩形走 rx/ry;图片靠 clipPath 挂圆角矩形;文字没有)
*
* 全部双向绑定:画布上拖动要让数字跟着变(watch `useEditor().revision`),
* 改数字要立刻反映到画布(`obj.set` + `setCoords` + `requestRenderAll`)。
* 每次编辑结束调 `commit()` 记一步历史;拖拽过程中不要每帧 commit。
*
* 没选中对象时显示画布本身的属性(尺寸、背景色)。
*/
</script>
<template>
<aside class="inspector">
<h2>属性</h2>
<p class="todo">T8 待实现:X / Y / W / H、缩放、旋转、不透明度、圆角</p>
</aside>
</template>
<style scoped>
.inspector {
border-left: 1px solid var(--line);
background: var(--bg2);
overflow-y: auto;
padding: 10px 0;
}
.inspector h2 {
margin: 0 12px 8px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
color: var(--fg-dim);
text-transform: uppercase;
}
.todo {
color: var(--fg-dim);
margin: 0 12px;
line-height: 1.6;
}
</style>
+40
View File
@@ -0,0 +1,40 @@
<script setup lang="ts">
/**
* 【T6 待实现 — 左侧图层面板】
*
* 数据来自 `@/editor/useLayers` 的 `layers`(从上到下 = 从前到后,和 Figma 一致)。
* 那个模块已经把所有操作都写好了:selectLayer / renameLayer / toggleVisible /
* toggleLocked / deleteLayer / moveLayerToRow。**面板只管界面,不要另写一套状态。**
*
* 要有:每行图标(按 kind)+ 名字 + 显隐眼睛 + 锁;点击选中,Ctrl/Shift 点多选;
* 双击重命名;拖拽排序(放到第 N 行调 moveLayerToRow);行内或右键删除。
*/
</script>
<template>
<aside class="layers">
<h2>图层</h2>
<p class="todo">T6 待实现</p>
</aside>
</template>
<style scoped>
.layers {
border-right: 1px solid var(--line);
background: var(--bg2);
overflow-y: auto;
padding: 10px 0;
}
.layers h2 {
margin: 0 12px 8px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
color: var(--fg-dim);
text-transform: uppercase;
}
.todo {
color: var(--fg-dim);
margin: 0 12px;
}
</style>
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
/**
* 【T9 待实现 — 选中对象的浮动编辑条】
*
* 一条圆角矩形的浮动菜单,跟着选中对象走(贴在它包围盒的上方,
* 顶到画布上缘时翻到下方)。**没有选中就不渲染。**
*
* 内容随选中对象的类型变:
* - 图片:滤镜入口、替换图片、圆角、置顶/置底、删除
* - 文字:字体、字号、颜色、对齐、粗细
* - 矩形:填色、圆角、描边
* - 多选:对齐与分布、成组、删除
*
* 位置换算:对象的 `getBoundingRect()` 给的是**场景坐标**,要经 viewportTransform
* 换算成屏幕坐标才能定位这个 DOM 浮层。参考
* `canvas.getViewportPoint` / `viewportTransform`,别用场景坐标直接当 CSS 值。
* 跟随要 watch `useEditor().revision`(拖拽过程中它每帧都会变)。
*/
</script>
<template>
<div v-if="false" class="selection-menu">T9 待实现</div>
</template>
<style scoped>
.selection-menu {
position: absolute;
display: flex;
align-items: center;
gap: 4px;
padding: 5px 8px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--bg3);
box-shadow: 0 6px 24px rgb(0 0 0 / 0.45);
pointer-events: auto;
}
</style>
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
/**
* 【T7 待实现 — 顶部工具栏】
*
* 左:工具组(选择 V / 文字 T / 矩形 R / 图片 Shift+K),当前工具高亮。
* 工具状态是 `useEditor().tool`,切换走 `runCommand("toolSelect")` 等命令——
* **不要直接写 tool.value**,否则工具栏和快捷键会走成两条路。
*
* 中:文件名 / 画布尺寸(尺寸预设与自定义从旧的 CanvasPanel 搬过来,那是 T10 的活)。
* 右:撤销 / 重做(`useHistory` 的 canUndo / canRedo 控制禁用态)、导出入口。
*/
</script>
<template>
<header class="topbar">
<span class="todo">T7 待实现:工具组 / 画布尺寸 / 撤销重做 / 导出</span>
</header>
</template>
<style scoped>
.topbar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border-bottom: 1px solid var(--line);
background: var(--bg2);
}
.todo {
color: var(--fg-dim);
}
</style>
+139 -29
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">
<div
class="stage"
ref="stage"
@wheel="onWheel"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@contextmenu="onContextMenu"
>
<canvas ref="canvasEl"></canvas>
</div>
</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>
+5 -1
View File
@@ -1,9 +1,13 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useEditor } from "@/editor/useEditor";
import { useViewport } from "@/editor/useViewport";
import { RATIOS, SIZE_PRESETS, applyRatio } from "@/editor/canvasSize";
const { size, setSize, addText, displayScale } = useEditor();
// 【T10 迁移中】这个面板的内容要拆进顶部工具栏(尺寸预设)与右侧检查器(画布属性)。
// 预览缩放已经从 useEditor 移到 useViewport,这里临时接过去,等 T10 拆完这个文件就删。
const { size, setSize, addText } = useEditor();
const { zoom: displayScale } = useViewport();
const presetId = ref<string>(SIZE_PRESETS[0].id);
const ratioId = ref<string>("free");
+3 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useEditor } from "@/editor/useEditor";
import { artboardExportOptions } from "@/editor/useViewport";
import { fonts } from "@/fonts/useFonts";
import type { ExportFormat, Template } from "@/types";
import {
@@ -40,9 +41,10 @@ async function doExport() {
exportError.value = "";
exporting.value = true;
try {
// 画板是视口里的一块区域,导出要按视口裁剪——规则见 useViewport 的 artboardExportOptions()
await exportCanvas(
getCanvas(),
{ width: size.value.width, height: size.value.height },
artboardExportOptions(),
{
format: format.value,
quality: quality.value,
+217
View File
@@ -0,0 +1,217 @@
import { ActiveSelection } from "fabric";
import type { Command, ContextMenuItem } from "@/types";
import { useEditor } from "./useEditor";
import { commit, redo, undo } from "./useHistory";
import { deleteLayer } from "./useLayers";
import { zoomBy, zoomReset, zoomToFit, zoomToSelection } from "./useViewport";
/**
* 命令注册表。
*
* **快捷键、右键菜单、工具栏按钮三个入口共用这一张表。** 不这么做的话,
* 「右键菜单能删、快捷键删不掉」这类不一致会立刻出现,而且每加一个入口
* 就要再抄一遍逻辑。加功能就是往这里加一条,三个入口自动都有。
*
* `keys` 的写法:小写键名,修饰键按 `ctrl+shift+alt+键` 的固定顺序拼。
* 键名取 `KeyboardEvent.key` 的小写形式(`delete`、`arrowup`、`z`、`escape`…)。
*
* 键位取自 Penpot 的快捷键表(2026-09-05 从 penpot-docs 仓核对),
* 它在缩放(Shift+0/1/2)、移动(方向键 / Shift+方向键)上和 Figma 一致;
* 层序用 Penpot 的 `Ctrl+方向键` 而不是 Figma 的 `Ctrl+]`,和移动同键位更好记。
*/
const ed = useEditor();
function hasSelection(): boolean {
return ed.selectedObjects().length > 0;
}
/** 对每个选中对象做点什么,然后统一提交历史。 */
function forEachSelected(fn: (o: import("fabric").FabricObject) => void, record = true) {
const objs = ed.selectedObjects();
if (objs.length === 0) return;
objs.forEach(fn);
const c = ed.getCanvas();
c.requestRenderAll();
ed.bumpRevision();
if (record) commit();
}
/** 方向键微调。`step` 是画布逻辑像素。 */
function nudge(dx: number, dy: number) {
forEachSelected((o) => {
o.set({ left: (o.left ?? 0) + dx, top: (o.top ?? 0) + dy });
o.setCoords();
});
}
export const COMMANDS: Command[] = [
/* ---------------- 编辑 ---------------- */
{
id: "delete",
label: "删除",
keys: ["delete", "backspace"],
enabled: hasSelection,
run: () => {
const ids = ed.selectedObjects().map((o) => o.layerId).filter((v): v is string => !!v);
ids.forEach(deleteLayer);
},
},
{
id: "duplicate",
label: "创建副本",
keys: ["ctrl+d"],
enabled: hasSelection,
run: async () => {
const c = ed.getCanvas();
const objs = ed.selectedObjects();
const clones = await Promise.all(objs.map((o) => o.clone()));
c.discardActiveObject();
for (const clone of clones) {
clone.set({ left: (clone.left ?? 0) + 24, top: (clone.top ?? 0) + 24 });
clone.layerId = undefined;
clone.layerName = undefined;
c.add(clone);
}
if (clones.length === 1) c.setActiveObject(clones[0]);
else if (clones.length > 1) c.setActiveObject(new ActiveSelection(clones, { canvas: c }));
c.requestRenderAll();
ed.bumpRevision();
commit();
},
},
{
id: "selectAll",
label: "全选",
keys: ["ctrl+a"],
enabled: () => (ed.canvas.value?.getObjects().length ?? 0) > 0,
run: () => {
const c = ed.getCanvas();
const objs = c.getObjects().filter((o) => o.selectable !== false && o.visible !== false);
c.discardActiveObject();
if (objs.length === 1) c.setActiveObject(objs[0]);
else if (objs.length > 1) c.setActiveObject(new ActiveSelection(objs, { canvas: c }));
c.requestRenderAll();
ed.bumpRevision();
},
},
{
id: "deselect",
label: "取消选择",
keys: ["escape"],
enabled: hasSelection,
run: () => {
const c = ed.getCanvas();
c.discardActiveObject();
c.requestRenderAll();
ed.bumpRevision();
},
},
{ id: "undo", label: "撤销", keys: ["ctrl+z"], enabled: () => true, run: undo },
{ id: "redo", label: "重做", keys: ["ctrl+shift+z", "ctrl+y"], enabled: () => true, run: redo },
/* ---------------- 层序 ---------------- */
{
id: "bringForward",
label: "上移一层",
keys: ["ctrl+arrowup"],
enabled: hasSelection,
run: () => forEachSelected((o) => void ed.getCanvas().bringObjectForward(o)),
},
{
id: "sendBackward",
label: "下移一层",
keys: ["ctrl+arrowdown"],
enabled: hasSelection,
run: () => forEachSelected((o) => void ed.getCanvas().sendObjectBackwards(o)),
},
{
id: "bringToFront",
label: "置于顶层",
keys: ["ctrl+shift+arrowup"],
enabled: hasSelection,
run: () => forEachSelected((o) => void ed.getCanvas().bringObjectToFront(o)),
},
{
id: "sendToBack",
label: "置于底层",
keys: ["ctrl+shift+arrowdown"],
enabled: hasSelection,
run: () => forEachSelected((o) => void ed.getCanvas().sendObjectToBack(o)),
},
/* ---------------- 微调 ---------------- */
{ id: "nudgeLeft", label: "左移", keys: ["arrowleft"], enabled: hasSelection, run: () => nudge(-1, 0) },
{ id: "nudgeRight", label: "右移", keys: ["arrowright"], enabled: hasSelection, run: () => nudge(1, 0) },
{ id: "nudgeUp", label: "上移", keys: ["arrowup"], enabled: hasSelection, run: () => nudge(0, -1) },
{ id: "nudgeDown", label: "下移", keys: ["arrowdown"], enabled: hasSelection, run: () => nudge(0, 1) },
{ id: "nudgeLeftBig", label: "左移 10px", keys: ["shift+arrowleft"], enabled: hasSelection, run: () => nudge(-10, 0) },
{ id: "nudgeRightBig", label: "右移 10px", keys: ["shift+arrowright"], enabled: hasSelection, run: () => nudge(10, 0) },
{ id: "nudgeUpBig", label: "上移 10px", keys: ["shift+arrowup"], enabled: hasSelection, run: () => nudge(0, -10) },
{ id: "nudgeDownBig", label: "下移 10px", keys: ["shift+arrowdown"], enabled: hasSelection, run: () => nudge(0, 10) },
/* ---------------- 锁定与显隐 ---------------- */
{
id: "toggleLock",
label: "锁定 / 解锁",
keys: ["ctrl+shift+l"],
enabled: hasSelection,
run: () => {
const objs = ed.selectedObjects();
const lock = !objs.every((o) => o.layerLocked === true);
objs.forEach((o) => ed.applyLocked(o, lock));
const c = ed.getCanvas();
if (lock) c.discardActiveObject();
c.requestRenderAll();
ed.bumpRevision();
commit();
},
},
{
id: "toggleVisible",
label: "显示 / 隐藏",
keys: ["ctrl+shift+h"],
enabled: hasSelection,
run: () => forEachSelected((o) => (o.visible = o.visible === false)),
},
/* ---------------- 工具 ---------------- */
{ id: "toolSelect", label: "选择工具", keys: ["v"], enabled: () => true, run: () => (ed.tool.value = "select") },
{ id: "toolText", label: "文字工具", keys: ["t"], enabled: () => true, run: () => (ed.tool.value = "text") },
{ id: "toolRect", label: "矩形工具", keys: ["r"], enabled: () => true, run: () => (ed.tool.value = "rect") },
{ id: "toolImage", label: "图片工具", keys: ["shift+k"], enabled: () => true, run: () => (ed.tool.value = "image") },
/* ---------------- 视图 ---------------- */
{ id: "zoomIn", label: "放大", keys: ["+", "="], enabled: () => true, run: () => zoomBy(1.2) },
{ id: "zoomOut", label: "缩小", keys: ["-"], enabled: () => true, run: () => zoomBy(1 / 1.2) },
{ id: "zoomReset", label: "缩放到 100%", keys: ["shift+0"], enabled: () => true, run: zoomReset },
{ id: "zoomFit", label: "适应画布", keys: ["shift+1"], enabled: () => true, run: () => zoomToFit() },
{ id: "zoomSelection", label: "缩放到选中", keys: ["shift+2"], enabled: hasSelection, run: () => zoomToSelection() },
];
const byId = new Map(COMMANDS.map((c) => [c.id, c]));
export function getCommand(id: string): Command | undefined {
return byId.get(id);
}
export function runCommand(id: string) {
const cmd = byId.get(id);
if (cmd && cmd.enabled()) void cmd.run();
}
/** 画布右键菜单的内容。分隔线用来把「编辑 / 层序 / 状态」三组分开。 */
export const CANVAS_CONTEXT_MENU: ContextMenuItem[] = [
{ type: "command", commandId: "duplicate" },
{ type: "command", commandId: "delete" },
{ type: "separator" },
{ type: "command", commandId: "bringForward" },
{ type: "command", commandId: "sendBackward" },
{ type: "command", commandId: "bringToFront" },
{ type: "command", commandId: "sendToBack" },
{ type: "separator" },
{ type: "command", commandId: "toggleLock" },
{ type: "command", commandId: "toggleVisible" },
{ type: "separator" },
{ type: "command", commandId: "zoomSelection" },
];
+25
View File
@@ -0,0 +1,25 @@
import { ref } from "vue";
/**
* 右键菜单的开合与位置。状态放在这里,渲染在 `components/ContextMenu.vue`——
* 这样画布(触发方)和菜单(渲染方)不用互相 import。
*
* 坐标是**相对画布区左上角**的像素,不是页面坐标,因为菜单绝对定位在 .area-stage 里。
*/
const open = ref(false);
const x = ref(0);
const y = ref(0);
export function openContextMenu(px: number, py: number) {
x.value = px;
y.value = py;
open.value = true;
}
export function closeContextMenu() {
open.value = false;
}
export function useContextMenu() {
return { open, x, y, openContextMenu, closeContextMenu };
}
+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 };
+105
View File
@@ -0,0 +1,105 @@
import { ref } from "vue";
import { useEditor, bumpRevision } from "./useEditor";
/**
* 撤销 / 重做。
*
* 做法是**快照式**:每次改动之后把 `canvas.toJSON()` 推进栈里,撤销就是
* `loadFromJSON` 回上一张快照。相对增量式(记录每个操作的逆操作)粗暴得多,
* 但对这个体量的工具是正确的取舍——增量式要为每种操作各写一遍逆操作,
* 漏一个就是「撤销之后画布状态不对」,而那种 bug 极难排查。
*
* 代价是每张快照都是整个画布的 JSON。图片是以 dataURL/objectURL 存在
* `src` 里的,快照会很大——所以栈深限制在 HISTORY_LIMIT,超了丢最老的。
*
* **重入保护**:`loadFromJSON` 本身会触发 object:added 等事件,
* 如果那时又去推快照就会无限自增。`restoring` 标志把这条路堵死。
*/
const HISTORY_LIMIT = 30;
type Snapshot = string;
const past = ref<Snapshot[]>([]);
const future = ref<Snapshot[]>([]);
const canUndo = ref(false);
const canRedo = ref(false);
let present: Snapshot | null = null;
let restoring = false;
function sync() {
canUndo.value = past.value.length > 0;
canRedo.value = future.value.length > 0;
}
function snapshot(): Snapshot | null {
try {
return JSON.stringify(useEditor().getCanvas().toJSON());
} catch {
return null;
}
}
/** 记下当前状态作为历史起点。画布挂载后调一次。 */
export function initHistory() {
past.value = [];
future.value = [];
present = snapshot();
sync();
}
/**
* 提交一次改动。**在改动完成之后调**,不是之前——
* 栈里存的是「每一步做完的样子」,撤销就是回到上一步做完的样子。
*/
export function commit() {
if (restoring) return;
const next = snapshot();
if (next === null || next === present) return;
if (present !== null) {
past.value.push(present);
if (past.value.length > HISTORY_LIMIT) past.value.shift();
}
present = next;
future.value = [];
sync();
}
async function restore(json: Snapshot) {
const c = useEditor().getCanvas();
restoring = true;
try {
await c.loadFromJSON(json);
c.requestRenderAll();
} finally {
restoring = false;
}
present = json;
bumpRevision();
}
export async function undo() {
const prev = past.value.pop();
if (prev === undefined) return;
if (present !== null) future.value.push(present);
await restore(prev);
sync();
}
export async function redo() {
const next = future.value.pop();
if (next === undefined) return;
if (present !== null) past.value.push(present);
await restore(next);
sync();
}
/** 正在回放历史时为 true。别的模块据此跳过自己的副作用。 */
export function isRestoring(): boolean {
return restoring;
}
export function useHistory() {
return { canUndo, canRedo, undo, redo, commit, initHistory, isRestoring };
}
+125
View File
@@ -0,0 +1,125 @@
import { ActiveSelection, type FabricObject } from "fabric";
import { computed } from "vue";
import type { LayerInfo } from "@/types";
import { applyLocked, defaultLayerName, layerKindOf, useEditor } from "./useEditor";
import { commit } from "./useHistory";
/**
* 图层模型。**画布上的 fabric 对象是唯一真源**,这里只做只读投影 + 一组操作方法。
* 不要在别处另存一份图层数组,那必然和画布对不上。
*
* 列表顺序是**从上到下 = 从前到后**,和 Figma 一致。fabric 的 `getObjects()`
* 是绘制顺序(越靠后越在上层),所以这里要反过来。凡是涉及 index 的地方
* 都要小心是「图层面板的行号」还是「fabric 的绘制序号」。
*/
const { canvas, revision, getCanvas, bumpRevision } = useEditor();
/** 图层列表,从上到下。依赖 `revision`,画布一变就重新推导。 */
export const layers = computed<LayerInfo[]>(() => {
void revision.value; // 建立依赖:fabric 不是响应式的,靠这个信号重算
const c = canvas.value;
if (!c) return [];
const active = new Set(c.getActiveObjects());
return c
.getObjects()
.map((obj) => ({
id: obj.layerId ?? "",
name: obj.layerName ?? defaultLayerName(obj),
kind: layerKindOf(obj),
visible: obj.visible !== false,
locked: obj.layerLocked === true,
selected: active.has(obj),
}))
.reverse();
});
function find(id: string): FabricObject | undefined {
return canvas.value?.getObjects().find((o) => o.layerId === id);
}
export function selectLayer(id: string, additive = false) {
const c = getCanvas();
const obj = find(id);
if (!obj || obj.layerLocked) return;
if (additive) {
// fabric 没有「往当前选区里加一个」的 API,多选一律重建一个 ActiveSelection。
const cur = c.getActiveObjects();
const next = cur.includes(obj) ? cur.filter((o) => o !== obj) : [...cur, obj];
c.discardActiveObject();
if (next.length === 1) c.setActiveObject(next[0]);
else if (next.length > 1) c.setActiveObject(new ActiveSelection(next, { canvas: c }));
} else {
c.setActiveObject(obj);
}
c.requestRenderAll();
bumpRevision();
}
export function renameLayer(id: string, name: string) {
const obj = find(id);
if (!obj) return;
obj.layerName = name.trim() || defaultLayerName(obj);
bumpRevision();
commit();
}
export function toggleVisible(id: string) {
const obj = find(id);
if (!obj) return;
obj.visible = obj.visible === false;
getCanvas().requestRenderAll();
bumpRevision();
commit();
}
export function toggleLocked(id: string) {
const obj = find(id);
if (!obj) return;
const next = obj.layerLocked !== true;
applyLocked(obj, next);
const c = getCanvas();
if (next && c.getActiveObjects().includes(obj)) c.discardActiveObject();
c.requestRenderAll();
bumpRevision();
commit();
}
export function deleteLayer(id: string) {
const obj = find(id);
if (!obj) return;
const c = getCanvas();
c.remove(obj);
c.discardActiveObject();
c.requestRenderAll();
bumpRevision();
commit();
}
/**
* 把 `id` 挪到图层面板的第 `toRow` 行(0 = 最上层)。拖拽排序用。
* 内部换算成 fabric 的绘制序号:`绘制序号 = 对象总数 - 1 - 行号`。
*/
export function moveLayerToRow(id: string, toRow: number) {
const c = getCanvas();
const objs = c.getObjects();
const obj = find(id);
if (!obj) return;
const target = Math.max(0, Math.min(objs.length - 1, objs.length - 1 - toRow));
c.moveObjectTo(obj, target);
c.requestRenderAll();
bumpRevision();
commit();
}
export function useLayers() {
return {
layers,
selectLayer,
renameLayer,
toggleVisible,
toggleLocked,
deleteLayer,
moveLayerToRow,
};
}
+85
View File
@@ -0,0 +1,85 @@
import { onBeforeUnmount, onMounted } from "vue";
import { COMMANDS } from "./commands";
/**
* 快捷键分发。把 `COMMANDS` 里每条命令的 `keys` 编成一张查找表,
* 全局 keydown 命中就执行。
*
* **在输入框里一律不拦截**:文本框、数字框、下拉、contenteditable 里
* 按 Delete 是删字符,按方向键是移光标——拦下来就没法编辑图层名和坐标了。
* 唯一例外是 Escape,它要能让输入框失焦。
*
* 正在编辑画布上的文字(fabric 的 IText 编辑态)时同理,那时按键属于文本编辑。
*/
/** 把事件规格化成 `ctrl+shift+alt+键`,和 Command.keys 的写法对齐。 */
export function eventToKey(e: KeyboardEvent): string {
const parts: string[] = [];
if (e.ctrlKey || e.metaKey) parts.push("ctrl");
if (e.shiftKey) parts.push("shift");
if (e.altKey) parts.push("alt");
parts.push(e.key.toLowerCase());
return parts.join("+");
}
function inTextEntry(target: EventTarget | null): boolean {
const el = target as HTMLElement | null;
if (!el) return false;
const tag = el.tagName;
return (
tag === "INPUT" ||
tag === "TEXTAREA" ||
tag === "SELECT" ||
el.isContentEditable === true
);
}
/** 画布上是否有对象正处于文字编辑态。 */
function editingCanvasText(): boolean {
return document.querySelector("textarea.fabric-textarea-hiddentextarea") !== null
|| document.activeElement?.tagName === "TEXTAREA";
}
const table = new Map<string, string>();
for (const cmd of COMMANDS) {
for (const k of cmd.keys ?? []) table.set(k, cmd.id);
}
/** 一个快捷键对应哪条命令。给「快捷键一览」之类的界面用。 */
export function commandIdForKey(key: string): string | undefined {
return table.get(key);
}
/** 一条命令的第一个绑定,用于在菜单里显示。 */
export function keyHintOf(commandId: string): string | undefined {
const cmd = COMMANDS.find((c) => c.id === commandId);
const k = cmd?.keys?.[0];
if (!k) return undefined;
return k
.split("+")
.map((p) =>
p === "ctrl" ? "Ctrl" : p === "shift" ? "Shift" : p === "alt" ? "Alt"
: p.startsWith("arrow") ? { arrowup: "↑", arrowdown: "↓", arrowleft: "←", arrowright: "→" }[p] ?? p
: p.length === 1 ? p.toUpperCase() : p[0].toUpperCase() + p.slice(1),
)
.join("+");
}
function onKeyDown(e: KeyboardEvent) {
if (inTextEntry(e.target) || editingCanvasText()) {
if (e.key === "Escape") (e.target as HTMLElement)?.blur?.();
return;
}
const id = table.get(eventToKey(e));
if (!id) return;
const cmd = COMMANDS.find((c) => c.id === id);
if (!cmd || !cmd.enabled()) return;
e.preventDefault();
void cmd.run();
}
/** 在应用根组件里调一次。 */
export function useShortcuts() {
onMounted(() => window.addEventListener("keydown", onKeyDown));
onBeforeUnmount(() => window.removeEventListener("keydown", onKeyDown));
}
+138
View File
@@ -0,0 +1,138 @@
import { ref } from "vue";
import { useEditor } from "./useEditor";
/**
* 视口:缩放与平移。**只影响显示,不影响导出。**
*
* 走 fabric 的 `viewportTransform`(一个 2D 仿射矩阵 `[a,b,c,d,e,f]`,
* 这里只用到缩放 a/d 和平移 e/f)。画布的 backstore 尺寸始终是逻辑尺寸,
* 所以 `toBlob({multiplier:1})` 导出的永远是全分辨率,和视口无关。
*
* 这是和旧版 `fitTo()` 的关键区别:旧版改的是 CSS 尺寸,画布在页面里的
* 占位跟着变;现在画布元素固定,内容在里面缩放平移。
*/
const MIN_ZOOM = 0.02;
const MAX_ZOOM = 16;
const zoom = ref(1);
function clamp(z: number) {
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z));
}
function apply(z: number, panX: number, panY: number) {
const c = useEditor().canvas.value;
if (!c) return;
c.setViewportTransform([z, 0, 0, z, panX, panY]);
zoom.value = z;
c.requestRenderAll();
}
function current(): { z: number; panX: number; panY: number } {
const c = useEditor().canvas.value;
const vt = c?.viewportTransform ?? [1, 0, 0, 1, 0, 0];
return { z: vt[0], panX: vt[4], panY: vt[5] };
}
/** 以画布上某个屏幕点为锚缩放(滚轮缩放要的就是这个,光标底下的内容不动)。 */
export function zoomAt(screenX: number, screenY: number, factor: number) {
const { z, panX, panY } = current();
const next = clamp(z * factor);
if (next === z) return;
// 锚点在内容坐标系里的位置保持不变:p = (s - pan) / z 恒定
apply(next, screenX - ((screenX - panX) / z) * next, screenY - ((screenY - panY) / z) * next);
}
export function zoomBy(factor: number) {
const c = useEditor().canvas.value;
if (!c) return;
zoomAt(c.getWidth() / 2, c.getHeight() / 2, factor);
}
export function panBy(dx: number, dy: number) {
const { z, panX, panY } = current();
apply(z, panX + dx, panY + dy);
}
/** 100%,并把画布摆回视图中央。 */
export function zoomReset() {
fitInto(1);
}
/** 缩放到画布正好装进视图(留 `padding` 边距)。 */
export function zoomToFit(padding = 48) {
const { size } = useEditor();
const c = useEditor().canvas.value;
if (!c) return;
const z = clamp(
Math.min((c.getWidth() - padding * 2) / size.value.width, (c.getHeight() - padding * 2) / size.value.height),
);
fitInto(z);
}
/** 缩放到选中对象。没选中就退化成 zoomToFit。 */
export function zoomToSelection(padding = 80) {
const { getCanvas, selectedObjects } = useEditor();
const objs = selectedObjects();
if (objs.length === 0) return zoomToFit();
const c = getCanvas();
let l = Infinity, t = Infinity, r = -Infinity, b = -Infinity;
for (const o of objs) {
const box = o.getBoundingRect();
l = Math.min(l, box.left);
t = Math.min(t, box.top);
r = Math.max(r, box.left + box.width);
b = Math.max(b, box.top + box.height);
}
const w = r - l, h = b - t;
if (w <= 0 || h <= 0) return;
const z = clamp(Math.min((c.getWidth() - padding * 2) / w, (c.getHeight() - padding * 2) / h));
apply(z, c.getWidth() / 2 - (l + w / 2) * z, c.getHeight() / 2 - (t + h / 2) * z);
}
/** 按给定缩放把整张画布居中。 */
function fitInto(z: number) {
const { size } = useEditor();
const c = useEditor().canvas.value;
if (!c) return;
apply(z, (c.getWidth() - size.value.width * z) / 2, (c.getHeight() - size.value.height * z) / 2);
}
/**
* 导出画板时要传给 `canvas.toBlob()` / `toDataURL()` 的裁剪参数。
*
* **fabric 的裁剪是在当前缩放的屏幕坐标里做的**(`toCanvasElement` 里
* `newZoom = zoom * multiplier`,`left/top` 直接从 `viewportTransform` 的平移里减)。
* 所以想导出「画板那一块、按逻辑尺寸 1:1」,要:
*
* multiplier = 1 / zoom 抵消视口缩放,让输出回到逻辑像素
* left, top = 视口平移量 画板原点在屏幕上的位置(画板在场景 0,0)
* width/height = 逻辑尺寸 × zoom 画板在屏幕上占的大小
*
* 结果 `scaledWidth = width × multiplier = 逻辑宽`,与视口缩放无关。
*
* 这条取代了旧版「backstore 就是逻辑尺寸,所以 multiplier 恒为 1」那个假设——
* 那个假设在画布元素铺满工作区之后不再成立。
*/
export function artboardExportOptions(): {
multiplier: number;
left: number;
top: number;
width: number;
height: number;
} {
const { size } = useEditor();
const { z, panX, panY } = current();
return {
multiplier: 1 / z,
left: panX,
top: panY,
width: size.value.width * z,
height: size.value.height * z,
};
}
export function useViewport() {
return { zoom, zoomAt, zoomBy, panBy, zoomReset, zoomToFit, zoomToSelection, artboardExportOptions, MIN_ZOOM, MAX_ZOOM };
}
+45 -10
View File
@@ -8,16 +8,31 @@ import type { CanvasSize, ExportFormat, ExportOptions } from "@/types";
/**
* 传给 fabric `canvas.toBlob()` 的参数。
*
* `multiplier` 恒为 1:画布 backstore 始终是逻辑尺寸(导出像素,如 1200×630),
* `useEditor().displayScale` / `fitTo()` 只改 CSS 尺寸。所以 `toBlob` 默认就已经
* 是全分辨率,**不要再乘一次 1/displayScale 去“补偿”预览缩放**,那会导出成两倍大小。
* **2026-09-05 改**:画布元素现在铺满整个工作区(它是「视口」),画板是里面
* 一块 1200×630 的区域,位置和大小由 `viewportTransform` 决定。所以导出必须
* **按视口裁剪**,不能再假设 `multiplier` 恒为 1——那个假设建立在旧版
* 「backstore 就是逻辑尺寸」之上,现在不成立了。换算规则见
* `src/editor/useViewport.ts` 的 `artboardExportOptions()`,这里只是把它转成
* toBlob 的参数形状。
*/
export interface ExportRequest {
format: ExportFormat;
/** 抵消视口缩放,= 1 / zoom。 */
multiplier: number;
/** 0–1,只对 webp 生效。 */
quality?: number;
/** 导出像素尺寸 = 画布逻辑尺寸;只作展示/自检用,不进 toBlob。 */
/** 裁剪区(屏幕坐标,当前缩放下画板占的那一块)。 */
left: number;
top: number;
width: number;
height: number;
}
/** 视口状态,由 `artboardExportOptions()` 给。抽成参数是为了能在 Node 里断言。 */
export interface ExportViewport {
multiplier: number;
left: number;
top: number;
width: number;
height: number;
}
@@ -27,12 +42,15 @@ function clampQuality(q: number): number {
return Math.min(1, Math.max(0, q));
}
/** 纯函数:把画布逻辑尺寸 + 面板选项解析成该传给 `toDataURL`/`toBlob` 的参数。 */
/**
* 纯函数:把视口裁剪参数 + 面板选项解析成该传给 `toDataURL`/`toBlob` 的参数。
* 不管视口怎么缩放,`width × multiplier` 恒等于画板的逻辑宽度。
*/
export function resolveExportRequest(
size: CanvasSize,
viewport: ExportViewport,
options: Pick<ExportOptions, "format" | "quality">,
): ExportRequest {
const base = { multiplier: 1, width: size.width, height: size.height };
const base = { ...viewport };
if (options.format === "png") {
// PNG 无损,浏览器会忽略 quality,干脆不带。
return { ...base, format: "png" };
@@ -40,9 +58,22 @@ export function resolveExportRequest(
return { ...base, format: "webp", quality: clampQuality(options.quality) };
}
/** 导出结果的实际像素尺寸,用于自检与界面显示。 */
export function exportedPixelSize(req: ExportRequest): CanvasSize {
return { width: Math.round(req.width * req.multiplier), height: Math.round(req.height * req.multiplier) };
}
/** fabric Canvas 的最小结构面——避免这里直接依赖 fabric 的类型。 */
export interface ExportableCanvas {
toBlob(options: { format: ExportFormat; multiplier: number; quality?: number }): Promise<Blob | null>;
toBlob(options: {
format: ExportFormat;
multiplier: number;
quality?: number;
left: number;
top: number;
width: number;
height: number;
}): Promise<Blob | null>;
}
/** 文件名清洗:去掉各平台文件名非法字符与控制字符,空结果退回 fallback。 */
@@ -70,13 +101,17 @@ export function triggerDownload(blob: Blob, filename: string): void {
export async function exportCanvas(
canvas: ExportableCanvas,
size: CanvasSize,
viewport: ExportViewport,
options: ExportOptions,
): Promise<void> {
const req = resolveExportRequest(size, options);
const req = resolveExportRequest(viewport, options);
const blob = await canvas.toBlob({
format: req.format,
multiplier: req.multiplier,
left: req.left,
top: req.top,
width: req.width,
height: req.height,
...(req.quality !== undefined ? { quality: req.quality } : {}),
});
if (!blob) {
+34 -13
View File
@@ -13,7 +13,7 @@
import assert from "node:assert/strict";
import { collectUsedFonts, deleteTemplate, listTemplates, missingFonts, saveTemplate } from "./templates";
import type { TemplateStore } from "./templates";
import { normalizeFilename, resolveExportRequest } from "./export";
import { exportedPixelSize, normalizeFilename, resolveExportRequest } from "./export";
/** 假 localStorage 的底层存储:同一块 Map。 */
function makeBacking() {
@@ -30,28 +30,49 @@ function viewOf(backing: Map<string, string>): TemplateStore {
};
}
/* ---------------- 1. 导出:multiplier 必须恒为 1 ---------------- */
/* ---------------- 1. 导出:任何视口缩放下,导出像素都等于画板逻辑尺寸 ----------------
*
* 2026-09-05 改造后画布元素铺满工作区,画板只是里面一块区域,
* 所以「multiplier 恒为 1」不再成立。真正该守的不变量是:
* width × multiplier === 逻辑宽,与 zoom 无关。
*/
const logical = { width: 1200, height: 630 };
const LOGICAL = { width: 1200, height: 630 };
const webp = resolveExportRequest(logical, { format: "webp", quality: 0.8 });
assert.equal(webp.multiplier, 1, "WebP 导出的 multiplier 必须是 1(逻辑尺寸 1200×630)");
assert.equal(webp.width, 1200, "导出宽 = 逻辑宽");
assert.equal(webp.height, 630, "导出高 = 逻辑高");
/** 模拟 useViewport 的 artboardExportOptions():给定缩放与平移,算出裁剪参数。 */
function viewportAt(zoom: number, panX = 0, panY = 0) {
return {
multiplier: 1 / zoom,
left: panX,
top: panY,
width: LOGICAL.width * zoom,
height: LOGICAL.height * zoom,
};
}
for (const zoom of [0.25, 0.5, 1, 1.5, 3]) {
const req = resolveExportRequest(viewportAt(zoom, 137, -42), { format: "webp", quality: 0.8 });
const px = exportedPixelSize(req);
assert.equal(px.width, LOGICAL.width, `zoom=${zoom} 时导出宽必须是 ${LOGICAL.width}`);
assert.equal(px.height, LOGICAL.height, `zoom=${zoom} 时导出高必须是 ${LOGICAL.height}`);
assert.equal(req.left, 137, "裁剪原点跟随视口平移");
assert.equal(req.top, -42, "裁剪原点跟随视口平移");
}
const webp = resolveExportRequest(viewportAt(1), { format: "webp", quality: 0.8 });
assert.equal(webp.format, "webp");
assert.equal(webp.quality, 0.8);
const png = resolveExportRequest(logical, { format: "png", quality: 0.8 });
assert.equal(png.multiplier, 1, "PNG 导出的 multiplier 也必须是 1");
const png = resolveExportRequest(viewportAt(1), { format: "png", quality: 0.8 });
assert.equal(png.format, "png");
assert.equal(png.quality, undefined, "PNG 无损,不传 quality");
// quality 钳制在 [0, 1]
assert.equal(resolveExportRequest(logical, { format: "webp", quality: 1.4 }).quality, 1);
assert.equal(resolveExportRequest(logical, { format: "webp", quality: -0.3 }).quality, 0);
assert.equal(resolveExportRequest(logical, { format: "webp", quality: Number.NaN }).quality, 0.85);
assert.equal(resolveExportRequest(viewportAt(1), { format: "webp", quality: 1.4 }).quality, 1);
assert.equal(resolveExportRequest(viewportAt(1), { format: "webp", quality: -0.3 }).quality, 0);
assert.equal(resolveExportRequest(viewportAt(1), { format: "webp", quality: Number.NaN }).quality, 0.85);
console.log("✓ 导出:1200×630 逻辑尺寸下 multiplier 恒为 1,width/height 与逻辑尺寸一致");
console.log("✓ 导出:0.25×–3× 五档缩放下,导出像素恒为 1200×630,裁剪原点跟随平移");
/* ---------------- 2. 文件名清洗 ---------------- */
+10
View File
@@ -9,7 +9,17 @@
--fg-dim: #9a9aa2;
--line: #33333a;
--accent: #6ea8fe;
--danger: #f0736a;
--radius: 6px;
/* Figma 式布局的三条固定边(2026-09-05 改造)。画布区是剩下的那块。 */
--topbar-h: 48px;
--layers-w: 240px;
--inspector-w: 264px;
/* 画布区的底色,比面板更深一档,让画布本身浮出来。 */
--stage-bg: #0f0f11;
/* 旧的单侧栏宽度,等 T10 把旧面板迁完就删。 */
--sidebar-w: 320px;
color-scheme: dark;
+96
View File
@@ -159,3 +159,99 @@ export type PanelId = "canvas" | "source" | "filter" | "text" | "export";
/** 画布当前选中了什么,用来决定右栏显示哪些面板。 */
export type SelectionKind = "none" | "image" | "text" | "other";
/* ================================================================
* 以下是 Figma 式编辑器改造(2026-09-05)新增的契约。
* 布局:顶部工具栏 / 左侧图层 / 中间画布 / 右侧属性 / 底部悬浮胶囊。
* ================================================================ */
/* ---------------------------------------------------------------- 工具 */
/** 顶部工具栏的当前工具。`select` 是默认态,其余是「下一次点画布要放什么」。 */
export type ToolId = "select" | "text" | "rect" | "image";
/* ---------------------------------------------------------------- 图层 */
/** 图层的种类,决定列表里的图标和右栏显示哪些属性组。 */
export type LayerKind = "image" | "text" | "rect" | "other";
/**
* 图层列表里的一行。**这是 fabric 对象的只读投影**,不是另一份状态——
* 真源永远是画布上的 fabric 对象,这个结构每次由 `useLayers()` 从画布重新推导。
* 不要往它上面写东西,改图层要调 `useLayers()` 暴露的方法。
*
* 列表顺序是**从上到下 = 从前到后**(和 Figma 一致,也和 fabric 的
* `getObjects()` 顺序相反——后者是绘制顺序,越靠后越在上层)。
*/
export interface LayerInfo {
/** 稳定 id,挂在 fabric 对象的自定义属性上,跨 toJSON/loadFromJSON 保留。 */
id: string;
name: string;
kind: LayerKind;
visible: boolean;
locked: boolean;
selected: boolean;
}
/* ---------------------------------------------------------------- 视图 */
/** 画布视口。缩放和平移只影响显示,不改画布的逻辑尺寸,也不影响导出。 */
export interface ViewportState {
/** 1 = 100%。 */
zoom: number;
panX: number;
panY: number;
}
/* ---------------------------------------------------------------- 选中对象的几何与外观 */
/**
* 右侧属性检查器编辑的那组值。全部是**画布逻辑坐标**(导出像素),
* 不是屏幕坐标——视口缩放不参与。
*
* `width` / `height` 是**缩放后的实际占位尺寸**(fabric 的 `getScaledWidth()`),
* 因为用户在 Figma 里改 W/H 期望的是最终大小,不是原始位图尺寸。
*/
export interface TransformValues {
x: number;
y: number;
width: number;
height: number;
scaleX: number;
scaleY: number;
/** 角度,0–360。 */
angle: number;
/** 0–1。 */
opacity: number;
/**
* 圆角半径,像素。矩形走 fabric 的 `rx`/`ry`;
* 图片没有原生圆角,靠 `clipPath` 挂一个同尺寸圆角矩形实现。
* 文字不支持,读出来是 `undefined`。
*/
cornerRadius?: number;
}
/* ---------------------------------------------------------------- 命令与快捷键 */
/**
* 一条可被快捷键、右键菜单、工具栏按钮共同调用的命令。
*
* **三个入口必须走同一个注册表**,否则「右键菜单能删、快捷键删不掉」这类
* 不一致会立刻出现。注册在 `src/editor/commands.ts`。
*/
export interface Command {
id: string;
label: string;
/**
* 快捷键,`useShortcuts` 解析。写法:小写键名,修饰键用 `+` 连接,
* 顺序固定 `ctrl+shift+alt+键`。例:`"ctrl+z"`、`"ctrl+shift+arrowup"`、`"delete"`。
* 一条命令可以有多个绑定。
*/
keys?: string[];
/** 当前是否可执行(例如「删除」在没选中任何东西时不可用)。 */
enabled: () => boolean;
run: () => void | Promise<void>;
}
/** 右键菜单里的一项:指向一条命令,或一条分隔线。 */
export type ContextMenuItem = { type: "separator" } | { type: "command"; commandId: string };