feat: 形状工具改为拖拽创建,五种形状 + Shift 约束比例(T12)
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user