diff --git a/src/components/Workspace.vue b/src/components/Workspace.vue index 8162f89..8f909a2 100644 --- a/src/components/Workspace.vue +++ b/src/components/Workspace.vue @@ -4,6 +4,7 @@ import { useEditor } from "@/editor/useEditor"; import { useViewport } from "@/editor/useViewport"; import { initHistory, commit } from "@/editor/useHistory"; 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 canvasEl = ref(null); @@ -26,6 +27,7 @@ let spaceDown = false; let panning = false; let lastX = 0; let lastY = 0; +let detachShapeTool: (() => void) | null = null; /** * 视口尺寸跟着容器走。 @@ -86,14 +88,12 @@ function onPointerDown(e: PointerEvent) { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); return; } + // 形状工具:拖拽创建由 useShapeTool 的 attachShapeTool 接管,这里完全让开。 + if (e.button === 0 && tool.value === "shape") 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 === "shape") addRect({ x: scene.x, y: scene.y }); if (tool.value !== "background") { tool.value = "select"; commit(); @@ -126,6 +126,8 @@ onMounted(() => { // 尺寸与首次适应交给 resize(),它会等到 stage 真的有尺寸了再做。 resize(); initHistory(); + // 形状工具的指针交互单独挂在画布上,这里只负责装/卸。 + detachShapeTool = attachShapeTool(c); // 拖拽 / 缩放 / 旋转结束都算一步操作。 c.on("object:modified", () => commit()); observer = new ResizeObserver(resize); @@ -136,6 +138,7 @@ onMounted(() => { onBeforeUnmount(() => { observer?.disconnect(); + detachShapeTool?.(); window.removeEventListener("keydown", onKeyDown); window.removeEventListener("keyup", onKeyUp); unmount(); diff --git a/src/components/topbar/ShapeMenu.vue b/src/components/topbar/ShapeMenu.vue index d6aa6b4..c1796fd 100644 --- a/src/components/topbar/ShapeMenu.vue +++ b/src/components/topbar/ShapeMenu.vue @@ -1,7 +1,42 @@ diff --git a/src/editor/useShapeTool.ts b/src/editor/useShapeTool.ts index 5f1644e..5269226 100644 --- a/src/editor/useShapeTool.ts +++ b/src/editor/useShapeTool.ts @@ -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()`。 * - * 契约: - * - `attachShapeTool(canvas)` 在画布挂载后调一次,返回解绑函数,卸载时调。 - * - 只在 `useEditor().tool === "shape"` 时接管指针事件;其余时间完全让开, - * 不要影响选择/平移。 - * - 画什么看 `useEditor().shapeKind`。 - * - 拖拽过程中要有实时预览(把对象加进画布边拖边改,松手才 `commit()` 记一步历史)。 - * - **按住 Shift 约束成 1:1**(正方形 / 正圆 / 45° 倍数的直线和箭头)。 - * 注意这只管**新建**;已有对象缩放时的 Shift 语义保持现状,不要动。 - * - 松手后回到 `select` 工具并选中新对象(和 Figma 一致)。 - * - 拖拽距离小于阈值(约 4px)当作误触:放一个默认尺寸的形状,不要留下一个 0×0 的东西。 - * - 新对象要 `registerLayer()`,并把 `obj.shapeKind` 置成当前形状—— - * 箭头和多边形在 fabric 里都是 Polygon,光看类型分不出来。 + * Shift 的语义是「新建时约束比例」:矩形/圆/多边形约束到 1:1,直线/箭头 + * 吸附到 45° 的倍数。这只管**新建**;已有对象缩放时的 Shift 行为由 fabric + * 自己决定,这里不动。 + * + * 画布接管:形状工具激活期间把 `skipTargetFind` / `selection` 关掉,让 fabric + * 的 pointerdown 不选对象、不起框选(否则在已有对象上起笔会先去拖动它)。 + * 切回其它工具时恢复原值。watch 用默认的 pre flush,工具一换下一帧就生效, + * 足够赶在用户下一次点画布之前。 */ -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"); + }; }