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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
+140
-30
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user