Merge branch 't12-shapes'

This commit is contained in:
2026-09-05 18:28:59 +08:00
3 changed files with 441 additions and 26 deletions
+9 -6
View File
@@ -4,6 +4,7 @@ import { useEditor } from "@/editor/useEditor";
import { useViewport } from "@/editor/useViewport"; import { useViewport } from "@/editor/useViewport";
import { initHistory, commit } from "@/editor/useHistory"; import { initHistory, commit } from "@/editor/useHistory";
import { openContextMenu } from "@/editor/useContextMenu"; import { openContextMenu } from "@/editor/useContextMenu";
import { attachShapeTool } from "@/editor/useShapeTool";
/** /**
* 画布区。**画布元素的像素尺寸跟着容器走**(它是「视口」), * 画布区。**画布元素的像素尺寸跟着容器走**(它是「视口」),
@@ -16,7 +17,7 @@ import { openContextMenu } from "@/editor/useContextMenu";
* *
* **这个文件由主控独占。** * **这个文件由主控独占。**
*/ */
const { mount, unmount, getCanvas, tool, addText, addRect } = useEditor(); const { mount, unmount, getCanvas, tool, addText } = useEditor();
const { zoomAt, panBy, zoomToFit } = useViewport(); const { zoomAt, panBy, zoomToFit } = useViewport();
const canvasEl = ref<HTMLCanvasElement | null>(null); const canvasEl = ref<HTMLCanvasElement | null>(null);
@@ -26,6 +27,7 @@ let spaceDown = false;
let panning = false; let panning = false;
let lastX = 0; let lastX = 0;
let lastY = 0; let lastY = 0;
let detachShapeTool: (() => void) | null = null;
/** /**
* 视口尺寸跟着容器走。 * 视口尺寸跟着容器走。
@@ -86,14 +88,12 @@ function onPointerDown(e: PointerEvent) {
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
return; return;
} }
// 形状工具:拖拽创建由 useShapeTool 的 attachShapeTool 接管,这里完全让开。
if (e.button === 0 && tool.value === "shape") return;
// 非选择工具:在点击处放一个新对象,然后回到选择工具(和 Figma 一致) // 非选择工具:在点击处放一个新对象,然后回到选择工具(和 Figma 一致)
if (e.button === 0 && tool.value !== "select") { 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(); if (tool.value === "text") addText();
else if (tool.value === "shape") addRect({ x: scene.x, y: scene.y });
if (tool.value !== "background") { if (tool.value !== "background") {
tool.value = "select"; tool.value = "select";
commit(); commit();
@@ -126,6 +126,8 @@ onMounted(() => {
// 尺寸与首次适应交给 resize(),它会等到 stage 真的有尺寸了再做。 // 尺寸与首次适应交给 resize(),它会等到 stage 真的有尺寸了再做。
resize(); resize();
initHistory(); initHistory();
// 形状工具的指针交互单独挂在画布上,这里只负责装/卸。
detachShapeTool = attachShapeTool(c);
// 拖拽 / 缩放 / 旋转结束都算一步操作。 // 拖拽 / 缩放 / 旋转结束都算一步操作。
c.on("object:modified", () => commit()); c.on("object:modified", () => commit());
observer = new ResizeObserver(resize); observer = new ResizeObserver(resize);
@@ -136,6 +138,7 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
observer?.disconnect(); observer?.disconnect();
detachShapeTool?.();
window.removeEventListener("keydown", onKeyDown); window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp); window.removeEventListener("keyup", onKeyUp);
unmount(); unmount();
+37 -2
View File
@@ -1,7 +1,42 @@
<script setup lang="ts"> <script setup lang="ts">
/** 【占位】ShapeMenu 的内容由对应开发线实现。见任务书。 */ import { useEditor } from "@/editor/useEditor";
import { runCommand } from "@/editor/commands";
import { SHAPES } from "@/editor/shapes";
import { IconCheck } from "@/ui/icons";
import type { ShapeKind } from "@/types";
/**
* 形状下拉的面板内容。五项 `.menu-row`,图标 + 名字来自 `SHAPES`。
* 当前形状高亮(`.on`)并在右侧显示对勾。
*
* 点一项:把 `shapeKind` 切过去,再 `runCommand("toolShape")` 切到形状工具,
* 然后关掉下拉。组件本身**不**包 Dropdown——T11 在 TopBar 里包,这里只提供
* 一列行,所以关闭通过 `close`(prop 或 `close` 事件,两种接法都兼容)交还给它。
*/
const { shapeKind } = useEditor();
const props = withDefaults(defineProps<{ close?: () => void }>(), { close: () => {} });
const emit = defineEmits<{ close: [] }>();
function pick(kind: ShapeKind) {
shapeKind.value = kind;
runCommand("toolShape");
props.close?.();
emit("close");
}
</script> </script>
<template> <template>
<div class="menu-block">(ShapeMenu 待实现)</div> <button
v-for="s in SHAPES"
:key="s.kind"
type="button"
class="menu-row"
:class="{ on: shapeKind === s.kind }"
@click="pick(s.kind)"
>
<component :is="s.icon" />
<span>{{ s.label }}</span>
<IconCheck v-if="shapeKind === s.kind" class="menu-aside" />
</button>
</template> </template>
+395 -18
View File
@@ -1,24 +1,401 @@
import type { Canvas } from "fabric"; import { Ellipse, Line, Path, Polygon, Rect, type Canvas, type FabricObject, type TSimplePathData } from "fabric";
import { watch } from "vue";
import { useEditor } from "./useEditor";
import { commit } from "./useHistory";
import type { ShapeKind, ToolId } from "@/types";
/** /**
* 【T12 待实现】形状工具的拖拽创建。 * 形状工具的拖拽创建。按住左键从起点拖到终点,五种形状共用一条路径。
* *
* 现状(要被替换掉的):Workspace 里点一下画布就在点击处放一个固定大小的矩形。 * 交互模型(和 Figma 一致):
* 目标:按住左键从起点拖到终点松开,拖出多大就是多大,五种形状都走这一条路径。 * - pointerdown:在起点放一个对象(此时尺寸为 0),登记成图层。
* - pointermove:实时改对象的几何,`requestRenderAll()` + `bumpRevision()`,
* **不**在这期间 `commit()`——一次拖拽只记一步历史。
* - pointerup:距离 < 阈值当误触,改放一个默认尺寸的形状;否则选中新对象、
* 回到 select 工具、`commit()`。
* *
* 契约: * Shift 的语义是「新建时约束比例」:矩形/圆/多边形约束到 1:1,直线/箭头
* - `attachShapeTool(canvas)` 在画布挂载后调一次,返回解绑函数,卸载时调。 * 吸附到 45° 的倍数。这只管**新建**;已有对象缩放时的 Shift 行为由 fabric
* - 只在 `useEditor().tool === "shape"` 时接管指针事件;其余时间完全让开, * 自己决定,这里不动。
* 不要影响选择/平移。 *
* - 画什么看 `useEditor().shapeKind`。 * 画布接管:形状工具激活期间把 `skipTargetFind` / `selection` 关掉,让 fabric
* - 拖拽过程中要有实时预览(把对象加进画布边拖边改,松手才 `commit()` 记一步历史)。 * 的 pointerdown 不选对象、不起框选(否则在已有对象上起笔会先去拖动它)。
* - **按住 Shift 约束成 1:1**(正方形 / 正圆 / 45° 倍数的直线和箭头)。 * 切回其它工具时恢复原值。watch 用默认的 pre flush,工具一换下一帧就生效,
* 注意这只管**新建**;已有对象缩放时的 Shift 语义保持现状,不要动。 * 足够赶在用户下一次点画布之前。
* - 松手后回到 `select` 工具并选中新对象(和 Figma 一致)。
* - 拖拽距离小于阈值(约 4px)当作误触:放一个默认尺寸的形状,不要留下一个 0×0 的东西。
* - 新对象要 `registerLayer()`,并把 `obj.shapeKind` 置成当前形状——
* 箭头和多边形在 fabric 里都是 Polygon,光看类型分不出来。
*/ */
export function attachShapeTool(_canvas: Canvas): () => void {
return () => {}; const FILL = "#6ea8fe";
const STROKE = "#6ea8fe";
const STROKE_WIDTH = 4;
/** 小于这个位移(屏幕像素)当误触,放默认尺寸而不是留下 0×0 的对象。 */
const MIN_DRAG = 4;
/** 箭头三角头的轴向长度 / 半宽。头大小固定,跟杆长无关;整体缩放时才一起变。 */
const ARROW_HEAD_LEN = 12;
const ARROW_HEAD_HALF = 6;
const ed = useEditor();
interface Pt {
x: number;
y: number;
}
interface Box {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
interface DragState {
obj: FabricObject;
kind: ShapeKind;
startX: number;
startY: number;
/** 误触判定的起点用屏幕坐标:4px 指的是指针在屏幕上的位移,不是场景位移(受缩放影响)。 */
startClientX: number;
startClientY: number;
}
/* ------------------------------------------------ 几何:从两点推形状 */
/**
* 矩形/圆/多边形的包围盒。Shift 约束成正方形:以起点为锚,边长取较长的那个分量,
* 方向跟着拖拽走(`sign(0)` 归到正方向,纯竖直拖也会得到一个正方形)。
*/
function boxFrom(a: Pt, b: Pt, constrain: boolean): Box {
let dx = b.x - a.x;
let dy = b.y - a.y;
if (constrain) {
const side = Math.max(Math.abs(dx), Math.abs(dy));
dx = (dx >= 0 ? 1 : -1) * side;
dy = (dy >= 0 ? 1 : -1) * side;
}
return {
minX: Math.min(a.x, a.x + dx),
minY: Math.min(a.y, a.y + dy),
maxX: Math.max(a.x, a.x + dx),
maxY: Math.max(a.y, a.y + dy),
};
}
/**
* 直线/箭头的终点。Shift 时把方向角吸附到最近的 45° 倍数,长度保持拖拽距离。
*/
function snappedEnd(a: Pt, b: Pt, constrain: boolean): Pt {
if (!constrain) return { x: b.x, y: b.y };
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.hypot(dx, dy);
if (len < 1e-6) return { x: a.x, y: a.y };
const deg = (Math.atan2(dy, dx) * 180) / Math.PI;
const snapped = Math.round(deg / 45) * 45;
const rad = (snapped * Math.PI) / 180;
return { x: a.x + len * Math.cos(rad), y: a.y + len * Math.sin(rad) };
}
/* ------------------------------------------------ 五种形状的构建 / 更新 */
function makeRect(box: Box): Rect {
// fabric 的默认原点是中心,显式写 left/top 原点,让 left/top 就是包围盒左上角。
return new Rect({
left: box.minX,
top: box.minY,
originX: "left",
originY: "top",
width: box.maxX - box.minX,
height: box.maxY - box.minY,
fill: FILL,
rx: 0,
ry: 0,
});
}
function updateRect(obj: Rect, box: Box) {
obj.set({
left: box.minX,
top: box.minY,
width: box.maxX - box.minX,
height: box.maxY - box.minY,
});
obj.setCoords();
}
function makeEllipse(box: Box): Ellipse {
// rx/ry 是半轴,宽高各取一半。
return new Ellipse({
left: box.minX,
top: box.minY,
originX: "left",
originY: "top",
rx: (box.maxX - box.minX) / 2,
ry: (box.maxY - box.minY) / 2,
fill: FILL,
});
}
function updateEllipse(obj: Ellipse, box: Box) {
obj.set({
left: box.minX,
top: box.minY,
rx: (box.maxX - box.minX) / 2,
ry: (box.maxY - box.minY) / 2,
});
obj.setCoords();
}
/**
* 三角形(polygon,边数固定 3):外接于包围盒的等腰三角形,顶点在上、底边在下。
* Polygon 的 points 是相对对象中心的局部坐标,left/top 是包围盒中心。
*/
function makeTriangle(box: Box): Polygon {
const w = box.maxX - box.minX;
const h = box.maxY - box.minY;
return new Polygon(
[
{ x: 0, y: -h / 2 },
{ x: -w / 2, y: h / 2 },
{ x: w / 2, y: h / 2 },
],
{
left: (box.minX + box.maxX) / 2,
top: (box.minY + box.maxY) / 2,
fill: FILL,
},
);
}
function updateTriangle(obj: Polygon, box: Box) {
const w = box.maxX - box.minX;
const h = box.maxY - box.minY;
obj.set({
left: (box.minX + box.maxX) / 2,
top: (box.minY + box.maxY) / 2,
points: [
{ x: 0, y: -h / 2 },
{ x: -w / 2, y: h / 2 },
{ x: w / 2, y: h / 2 },
],
});
// points 改动后 width/height/pathOffset 不会自动重算(除非 exactBoundingBox),手动补一次。
obj.setDimensions();
obj.setCoords();
}
function makeLine(a: Pt, b: Pt): Line {
// Line 的 left/top 由 x1/y1/x2/y2 自动居中,不用(也不该)手填。
return new Line([a.x, a.y, b.x, b.y], {
stroke: STROKE,
strokeWidth: STROKE_WIDTH,
strokeLineCap: "round",
});
}
function updateLine(obj: Line, a: Pt, b: Pt) {
// Line 的 _set 会在坐标变化时重算宽高并重新居中,所以直接塞坐标即可。
obj.set({ x1: a.x, y1: a.y, x2: b.x, y2: b.y });
obj.setCoords();
}
/**
* 箭头用单个 Path 画:杆是一条开子路径(零面积,只有描边),头是一个闭合三角
* (有面积,靠 fill 填色)。
*
* 选 Path 而不是 Group 的理由:
* - 单对象 → 单包围盒 → 选中/旋转/缩放的把手都作用在这一个对象上,不用处理
* Group 的子对象在局部坐标里的重新定位。
* - `toJSON()` / `loadFromJSON()` 原样往返:它就是一个带 `shapeKind` 自定义属性的
* Path,加载时不需要任何重建逻辑。
* - 头的尺寸在**创建时**固定(和杆长无关,跟真实标注箭头一致);用户之后缩放
* 整体时,头和杆按同一比例一起变,不会出现「头被单独拉伸」的走样。
*/
function makeArrow(a: Pt, b: Pt): Path {
return new Path(arrowCommands(a, b), {
fill: STROKE,
stroke: STROKE,
strokeWidth: STROKE_WIDTH,
strokeLineCap: "round",
strokeLineJoin: "round",
});
}
function updateArrow(obj: Path, a: Pt, b: Pt) {
obj.set({ path: arrowCommands(a, b) });
// Path 改 path 后要手动重算包围盒并重新定位到新中心。
obj.setBoundingBox(true);
obj.setCoords();
}
/** 生成箭头的 SVG path 命令。坐标是场景坐标(Path 会按包围盒中心重新定位)。 */
function arrowCommands(a: Pt, end: Pt): TSimplePathData {
const dx = end.x - a.x;
const dy = end.y - a.y;
const len = Math.hypot(dx, dy);
if (len < 1e-6) {
// 零长度:先占住一个退化对象,移动时再补真路径。
return [
["M", a.x, a.y],
["L", a.x, a.y],
];
}
// 短到放不下头时把头长压进全长,别让头比杆还长。
const hl = Math.min(ARROW_HEAD_LEN, len);
const cos = dx / len;
const sin = dy / len;
const shaftX = a.x + (len - hl) * cos;
const shaftY = a.y + (len - hl) * sin;
// 三角底边两个顶点:底边中点在杆末端,沿杆的垂直方向各偏半个头宽。
const base1X = shaftX - ARROW_HEAD_HALF * sin;
const base1Y = shaftY + ARROW_HEAD_HALF * cos;
const base2X = shaftX + ARROW_HEAD_HALF * sin;
const base2Y = shaftY - ARROW_HEAD_HALF * cos;
return [
["M", a.x, a.y],
["L", shaftX, shaftY],
["M", base1X, base1Y],
["L", end.x, end.y],
["L", base2X, base2Y],
["Z"],
];
}
/* ------------------------------------------------ 组装 */
function createShape(kind: ShapeKind, a: Pt, b: Pt, constrain: boolean): FabricObject {
if (kind === "line") return makeLine(a, snappedEnd(a, b, constrain));
if (kind === "arrow") return makeArrow(a, snappedEnd(a, b, constrain));
const box = boxFrom(a, b, constrain);
if (kind === "rect") return makeRect(box);
if (kind === "ellipse") return makeEllipse(box);
return makeTriangle(box);
}
function updateShape(obj: FabricObject, kind: ShapeKind, a: Pt, b: Pt, constrain: boolean) {
if (kind === "line") return updateLine(obj as Line, a, snappedEnd(a, b, constrain));
if (kind === "arrow") return updateArrow(obj as Path, a, snappedEnd(a, b, constrain));
const box = boxFrom(a, b, constrain);
if (kind === "rect") return updateRect(obj as Rect, box);
if (kind === "ellipse") return updateEllipse(obj as Ellipse, box);
return updateTriangle(obj as Polygon, box);
}
/**
* 误触时放一个默认尺寸的形状(宽约画板的 30%),以点击处为中心。
* 直线/箭头默认是一条水平线,其余是居中的矩形框。
*/
function placeDefault(canvas: Canvas, kind: ShapeKind, center: Pt) {
const w = Math.round(ed.size.value.width * 0.3);
const h = Math.round(ed.size.value.height * 0.25);
let obj: FabricObject;
if (kind === "line" || kind === "arrow") {
obj = createShape(kind, { x: center.x - w / 2, y: center.y }, { x: center.x + w / 2, y: center.y }, false);
} else {
obj = createShape(
kind,
{ x: center.x - w / 2, y: center.y - h / 2 },
{ x: center.x + w / 2, y: center.y + h / 2 },
false,
);
}
obj.shapeKind = kind;
ed.registerLayer(obj);
canvas.add(obj);
canvas.setActiveObject(obj);
canvas.requestRenderAll();
ed.bumpRevision();
commit();
ed.tool.value = "select";
}
/* ------------------------------------------------ 事件接管 */
export function attachShapeTool(canvas: Canvas): () => void {
const el = canvas.upperCanvasEl;
const doc = el.ownerDocument ?? document;
let drag: DragState | null = null;
// 记下挂载时的交互开关,卸载 / 切回其它工具时原样恢复,别写死默认值。
const origSkipTargetFind = canvas.skipTargetFind;
const origSelection = canvas.selection;
const origDefaultCursor = canvas.defaultCursor;
function scenePoint(e: PointerEvent): Pt {
const p = canvas.getScenePoint(e);
return { x: p.x, y: p.y };
}
function onDown(e: PointerEvent) {
if (ed.tool.value !== "shape" || e.button !== 0) return;
const p = scenePoint(e);
const kind = ed.shapeKind.value;
const obj = createShape(kind, p, p, e.shiftKey);
// 自定义属性要在加进画布前置好:registerLayer 用 defaultLayerName 起名,靠它分五种。
obj.shapeKind = kind;
ed.registerLayer(obj);
canvas.add(obj);
drag = { obj, kind, startX: p.x, startY: p.y, startClientX: e.clientX, startClientY: e.clientY };
canvas.requestRenderAll();
ed.bumpRevision();
}
function onMove(e: PointerEvent) {
if (!drag) return;
const cur = scenePoint(e);
updateShape(drag.obj, drag.kind, { x: drag.startX, y: drag.startY }, cur, e.shiftKey);
canvas.requestRenderAll();
ed.bumpRevision();
}
function onUp(e: PointerEvent) {
if (!drag) return;
const d = drag;
drag = null;
const cur = scenePoint(e);
const dist = Math.hypot(e.clientX - d.startClientX, e.clientY - d.startClientY);
if (dist < MIN_DRAG) {
canvas.remove(d.obj);
placeDefault(canvas, d.kind, { x: d.startX, y: d.startY });
return;
}
// 用松手点做最后一次定位,避免最后一个 move 事件比 up 慢半拍。
updateShape(d.obj, d.kind, { x: d.startX, y: d.startY }, cur, e.shiftKey);
canvas.setActiveObject(d.obj);
canvas.requestRenderAll();
ed.bumpRevision();
commit();
ed.tool.value = "select";
}
function applyMode(tool: ToolId) {
// 形状工具期间让 fabric 完全让开:不找目标、不框选;否则在已有对象上起笔会去拖动它。
if (tool === "shape") {
canvas.skipTargetFind = true;
canvas.selection = false;
canvas.defaultCursor = "crosshair";
canvas.setCursor("crosshair");
} else {
canvas.skipTargetFind = origSkipTargetFind;
canvas.selection = origSelection;
canvas.defaultCursor = origDefaultCursor;
canvas.setCursor("default");
}
}
el.addEventListener("pointerdown", onDown);
// move/up 挂在 document 上:指针拖出画布元素也能收到,up 不丢。
doc.addEventListener("pointermove", onMove);
doc.addEventListener("pointerup", onUp);
doc.addEventListener("pointercancel", onUp);
const stopWatch = watch(() => ed.tool.value, applyMode, { immediate: true });
return () => {
el.removeEventListener("pointerdown", onDown);
doc.removeEventListener("pointermove", onMove);
doc.removeEventListener("pointerup", onUp);
doc.removeEventListener("pointercancel", onUp);
stopWatch();
// 卸载时兜底恢复 fabric 默认交互,别把画布留在「不能选」的状态。
applyMode("select");
};
} }