Эх сурвалжийг харах

添加 fabric 和 pdfmake 依赖

cc12458 1 сар өмнө
parent
commit
f283a150a8
46 өөрчлөгдсөн 3720 нэмэгдсэн , 161 устгасан
  1. 34 0
      @types/fabric.d.ts
  2. 157 0
      @types/pdfmake.d.ts
  3. 8 3
      package.json
  4. 394 158
      pnpm-lock.yaml
  5. BIN
      public/fonts/NotoSansSC-Bold.ttf
  6. BIN
      public/fonts/NotoSansSC-Regular.ttf
  7. 12 0
      src/lib/fabric/actions/flip.ts
  8. 2 0
      src/lib/fabric/actions/index.ts
  9. 52 0
      src/lib/fabric/actions/scale.ts
  10. 197 0
      src/lib/fabric/brush/class/DragShapeBrush.ts
  11. 320 0
      src/lib/fabric/brush/class/PolygonBrush.ts
  12. 68 0
      src/lib/fabric/brush/class/ShapeBrush.ts
  13. 29 0
      src/lib/fabric/brush/index.ts
  14. 4 0
      src/lib/fabric/brush/types.ts
  15. 47 0
      src/lib/fabric/brush/utils.ts
  16. 33 0
      src/lib/fabric/common/blob.ts
  17. 4 0
      src/lib/fabric/common/index.ts
  18. 28 0
      src/lib/fabric/common/object.ts
  19. 166 0
      src/lib/fabric/common/transform.ts
  20. 65 0
      src/lib/fabric/components/Fabric.vue
  21. 155 0
      src/lib/fabric/components/toolbar/ToolButtonLike.vue
  22. 64 0
      src/lib/fabric/components/toolbar/ToolIconLabel.vue
  23. 235 0
      src/lib/fabric/components/toolbar/ToolItem.vue
  24. 37 0
      src/lib/fabric/components/toolbar/ToolRender.ts
  25. 343 0
      src/lib/fabric/components/toolbar/ToolSelectableControl.vue
  26. 302 0
      src/lib/fabric/components/toolbar/Toolbar.vue
  27. 5 0
      src/lib/fabric/components/toolbar/index.ts
  28. 81 0
      src/lib/fabric/components/toolbar/toolbar-variables.css
  29. 4 0
      src/lib/fabric/components/toolbar/toolbarContext.ts
  30. 145 0
      src/lib/fabric/components/toolbar/types.ts
  31. 124 0
      src/lib/fabric/components/toolbar/useToolbarCore.ts
  32. 35 0
      src/lib/fabric/composables/useFabric.ts
  33. 110 0
      src/lib/fabric/composables/useFabricEventListener.ts
  34. 60 0
      src/lib/fabric/composables/useImage.ts
  35. 52 0
      src/lib/fabric/composables/useSnapshot.ts
  36. 27 0
      src/lib/fabric/composables/useToolbar.ts
  37. 5 0
      src/lib/fabric/core/Canvas.ts
  38. 47 0
      src/lib/fabric/core/constant.ts
  39. 54 0
      src/lib/fabric/core/context.ts
  40. 11 0
      src/lib/fabric/index.ts
  41. 14 0
      src/lib/fabric/types.ts
  42. 26 0
      src/lib/fabric/utils/props.ts
  43. 35 0
      src/lib/fabric/utils/wrapper.ts
  44. 47 0
      src/lib/pdfmake/db.ts
  45. 37 0
      src/lib/pdfmake/font.ts
  46. 45 0
      src/lib/pdfmake/index.ts

+ 34 - 0
@types/fabric.d.ts

@@ -0,0 +1,34 @@
+declare module 'fabric' {
+  interface FabricObject {
+    id: `${string}-${string}-${string}-${string}-${string}`;
+    lockScaleUniform: boolean;
+    maxScaleLimit: number;
+  }
+  interface FabricObjectProps {
+    lockScaleUniform?: boolean;
+    maxScaleLimit?: number;
+  }
+
+  interface CanvasEvents {
+    'before:object:created': { object: import('fabric').FabricObject };
+    'object:created': { object: import('fabric').FabricObject };
+    'brush:invalid': {
+      reason: 'outOfRegion' | 'dragTooShort' | 'selfIntersection' | 'degenerateTriangle' | 'closeWouldIntersect' | 'polygonTooFewPoints' | 'dragBoundsInvalid';
+      point: { x: number; y: number };
+      vertexCount?: number;
+      detail?: Record<string, unknown>;
+    };
+    'brush:start': {
+      shape?: 'rect' | 'circle' | 'ellipse' | 'triangle' | 'polyline' | 'polygon';
+    };
+    'brush:end': {
+      shape?: 'rect' | 'circle' | 'ellipse' | 'triangle' | 'polyline' | 'polygon';
+    };
+  }
+
+  interface ObjectEvents {
+    'snapshot:start': { target: import('fabric').FabricObject };
+    'snapshot:end': { target: import('fabric').FabricObject };
+  }
+}
+export {};

+ 157 - 0
@types/pdfmake.d.ts

@@ -0,0 +1,157 @@
+declare module 'pdfmake/build/pdfmake' {
+  interface PdfMakeStatic {
+    fonts: Record<string, FontDefinition>;
+    virtualfs: VirtualFileSystem;
+
+    createPdf(docDefinition: TDocumentDefinitions, options?: Record<string, unknown>): OutputDocumentBrowser;
+    setFonts(fonts: Record<string, FontDefinition>): void;
+    addFonts(fonts: Record<string, FontDefinition>): void;
+    addVirtualFileSystem(vfs: Record<string, string | { data: string; encoding?: string }>): void;
+    setUrlAccessPolicy(callback: ((url: string) => boolean) | undefined): void;
+  }
+
+  interface FontDefinition {
+    normal: string;
+    bold: string;
+    italics: string;
+    bolditalics: string;
+  }
+
+  interface VirtualFileSystem {
+    storage: Record<string, unknown>;
+    existsSync(filename: string): boolean;
+    readFileSync(filename: string, options?: string | { encoding?: string }): unknown;
+    writeFileSync(filename: string, content: unknown, options?: string | { encoding?: string }): void;
+  }
+
+  interface OutputDocumentBrowser {
+    getBlob(): Promise<Blob>;
+    getBuffer(): Promise<ArrayBuffer>;
+    download(filename?: string): Promise<void>;
+    open(win?: Window | null): Promise<void>;
+    print(win?: Window | null): Promise<void>;
+  }
+
+  type Margins = number | [number, number] | [number, number, number, number];
+  type Alignment = 'left' | 'center' | 'right' | 'justify';
+
+  interface Style {
+    font?: string;
+    fontSize?: number;
+    bold?: boolean;
+    italics?: boolean;
+    alignment?: Alignment;
+    color?: string;
+    fillColor?: string;
+    fillOpacity?: number;
+    margin?: Margins;
+    lineHeight?: number;
+    decoration?: 'underline' | 'lineThrough' | 'overline';
+    columnGap?: number;
+    outline?: boolean;
+  }
+
+  type Content = string | ContentText | ContentColumns | ContentStack | ContentTable | ContentImage | ContentSvg | ContentCanvas | Content[];
+
+  interface ContentText extends Style {
+    text: string | Content[];
+    style?: string | string[];
+    pageBreak?: 'before' | 'after';
+  }
+
+  interface ContentColumns extends Style {
+    columns: Content[];
+  }
+
+  interface ContentStack extends Style {
+    stack: Content[];
+  }
+
+  interface ContentImage extends Style {
+    image: string;
+    width?: number;
+    height?: number;
+    fit?: [number, number];
+  }
+
+  interface ContentSvg extends Style {
+    svg: string;
+    width?: number;
+    height?: number;
+    fit?: [number, number];
+  }
+
+  interface ContentCanvas extends Style {
+    canvas: {
+      type: string;
+      x: number;
+      y: number;
+      w: number;
+      h: number;
+      r: number;
+      color: string;
+    }[];
+  }
+
+  interface TableCell extends Style {
+    text?: string | Content[];
+    rowSpan?: number;
+    colSpan?: number;
+    /** 单元格垂直对齐(pdfmake 0.3+,合并 rowSpan 时常用 middle;非 CSS 的 valign) */
+    verticalAlignment?: 'top' | 'middle' | 'bottom';
+    border?: [boolean, boolean, boolean, boolean];
+    noWrap?: boolean;
+  }
+
+  interface ContentTable extends Style {
+    table: {
+      headerRows?: number;
+      widths?: ('auto' | '*' | number | string)[];
+      body: (string | TableCell | Content)[][];
+    };
+    layout?: string | TableLayout;
+  }
+
+  interface TableLayout {
+    hLineWidth?: (i: number, node: unknown) => number;
+    vLineWidth?: (i: number, node: unknown) => number;
+    hLineColor?: (i: number, node: unknown) => string;
+    vLineColor?: (i: number, node: unknown) => string;
+    paddingLeft?: (i: number, node: unknown) => number;
+    paddingRight?: (i: number, node: unknown) => number;
+    paddingTop?: (i: number, node: unknown) => number;
+    paddingBottom?: (i: number, node: unknown) => number;
+    fillColor?: (rowIndex: number, node: unknown, columnIndex: number) => string | null;
+  }
+
+  interface TDocumentDefinitions {
+    content: Content;
+    defaultStyle?: Style;
+    styles?: Record<string, Style>;
+    pageSize?: string | { width: number; height: number };
+    pageOrientation?: 'portrait' | 'landscape';
+    pageMargins?: Margins;
+    header?: Content | ((currentPage: number, pageCount: number) => Content);
+    footer?: Content | ((currentPage: number, pageCount: number) => Content);
+    info?: Record<string, string>;
+    images?: Record<string, string | { url: string; headers?: Record<string, any> }>;
+  }
+
+  const pdfMake: PdfMakeStatic;
+  export default pdfMake;
+  export type {
+    TDocumentDefinitions,
+    Content,
+    ContentText,
+    ContentColumns,
+    ContentStack,
+    ContentTable,
+    ContentImage,
+    TableCell,
+    TableLayout,
+    Style,
+    Margins,
+    Alignment,
+    OutputDocumentBrowser,
+  };
+}

+ 8 - 3
package.json

@@ -16,6 +16,8 @@
   },
   "dependencies": {
     "@alova/mock": "^2.0.7",
+    "@iconify/vue": "^5.0.0",
+    "@vueuse/components": "^13.9.0",
     "@vueuse/core": "^13.6.0",
     "@vueuse/router": "^13.6.0",
     "alova": "^3.0.20",
@@ -25,14 +27,17 @@
     "eruda-features": "^2.1.0",
     "eruda-monitor": "^1.0.2",
     "eruda-timing": "^2.0.1",
+    "es-toolkit": "^1.45.1",
+    "fabric": "^7.2.0",
     "p5": "^1.11.0",
+    "pdfmake": "^0.3.7",
     "pinia": "^2.2.4",
     "pinia-plugin-persistedstate": "^4.1.1",
     "qrcode.vue": "^3.6.0",
     "svg-pathdata": "^7.1.0",
     "vant": "4",
     "vconsole": "^3.15.1",
-    "vue": "^3.5.11",
+    "vue": "^3.5.30",
     "vue-echarts": "^7.0.3",
     "vue-router": "^4.4.5"
   },
@@ -59,9 +64,9 @@
     "typescript": "~5.6.2",
     "unplugin-auto-import": "^0.18.3",
     "unplugin-vue-components": "^0.27.4",
-    "vite": "^5.4.8",
+    "vite": "^7.3.1",
     "vite-plugin-mkcert": "^1.17.6",
-    "vite-plugin-vue-devtools": "^7.4.6",
+    "vite-plugin-vue-devtools": "^8.0.6",
     "vue-tsc": "^2.1.6"
   }
 }

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 394 - 158
pnpm-lock.yaml


BIN
public/fonts/NotoSansSC-Bold.ttf


BIN
public/fonts/NotoSansSC-Regular.ttf


+ 12 - 0
src/lib/fabric/actions/flip.ts

@@ -0,0 +1,12 @@
+import type { FabricObject } from 'fabric';
+
+export type FlipKey = 'flipX' | 'flipY' | 'flip';
+
+export function flipObject(object: FabricObject, key: FlipKey, flip?: boolean) {
+  const keys = key === 'flip' ? ['flipX', 'flipY'] : [key];
+  for (const key of keys) {
+    if (flip == null) object.toggle(key);
+    else object.set(key, flip);
+  }
+  object.dirty = true;
+}

+ 2 - 0
src/lib/fabric/actions/index.ts

@@ -0,0 +1,2 @@
+export * from './flip';
+export * from './scale';

+ 52 - 0
src/lib/fabric/actions/scale.ts

@@ -0,0 +1,52 @@
+import { type FabricObject, Point, type XY } from 'fabric';
+import { clamp } from 'es-toolkit';
+
+export type ScaleToContainerMode = 'width' | 'height' | 'cover' | 'contain';
+export function scaleObjectToContainer(object: FabricObject, container: { width: number; height: number; center?: XY }, mode: ScaleToContainerMode) {
+  const centerPoint = container?.center ? new Point(container?.center) : object.getCenterPoint();
+  if (mode === 'width') object.scaleToWidth(container.width);
+  else if (mode === 'height') object.scaleToHeight(container.height);
+  else {
+    const bounds = object.getBoundingRect();
+    const ratioByWidth = (container.width / object.width / bounds.width) * object.getScaledWidth();
+    const ratioByHeight = (container.height / object.height / bounds.height) * object.getScaledHeight();
+    if (mode === 'cover') object.scale(Math.max(ratioByWidth, ratioByHeight));
+    else if (mode === 'contain') object.scale(Math.min(ratioByWidth, ratioByHeight));
+  }
+  object.setXY(centerPoint);
+  object.setCoords();
+  object.dirty = true;
+}
+
+export function scaleObject(
+  object: FabricObject,
+  scale: number,
+  options?: { center?: XY; uniform?: boolean | 'min' | 'max' | 'avg'; deferSetCoords?: boolean },
+) {
+  const centerPoint = options?.center ? new Point(options.center) : object.getCenterPoint();
+  const uniform = options?.uniform ?? object.lockScaleUniform;
+
+  const rawX = object.scaleX ?? 1;
+  const rawY = object.scaleY ?? 1;
+  const flipX = object.flipX ?? false;
+  const flipY = object.flipY ?? false;
+
+  let scaleX = clamp(scale * Math.abs(rawX), object.minScaleLimit ?? 0, object.maxScaleLimit ?? Infinity);
+  let scaleY = clamp(scale * Math.abs(rawY), object.minScaleLimit ?? 0, object.maxScaleLimit ?? Infinity);
+
+  if (uniform === 'avg' || uniform === true) scaleX = scaleY = (scaleX + scaleY) / 2;
+  else if (uniform === 'min') scaleX = scaleY = Math.min(scaleX, scaleY);
+  else if (uniform === 'max') scaleX = scaleY = Math.max(scaleX, scaleY);
+
+  object.set({
+    scaleX,
+    scaleY,
+    flipX: rawX < 0 ? !flipX : flipX,
+    flipY: rawY < 0 ? !flipY : flipY,
+  });
+  object.setXY(centerPoint);
+  if (!options?.deferSetCoords) {
+    object.setCoords();
+  }
+  object.dirty = true;
+}

+ 197 - 0
src/lib/fabric/brush/class/DragShapeBrush.ts

@@ -0,0 +1,197 @@
+import type { ModifierKey, TBrushEventData, XY } from 'fabric';
+import type { DragShape } from '../types';
+import { Circle, Ellipse, Point, Rect, Shadow } from 'fabric';
+import { ShapeBrush } from './ShapeBrush';
+import { mergeObjectOptions } from '@/lib/fabric';
+
+export class DragShapeBrush extends ShapeBrush<DragShape> {
+  decimate = 24;
+
+  drawSpecialShape = false;
+
+  specialShapeKey: ModifierKey | undefined | null = 'shiftKey';
+
+  private _gestureActive = false;
+
+  private fireInvalid(reason: 'outOfRegion' | 'dragTooShort', point: XY, detail?: Record<string, unknown>) {
+    this.canvas.fire('brush:invalid', { reason, point, detail });
+  }
+
+  onMouseDoubleClick(pointer: Point, ev: TBrushEventData): void {}
+
+  onMouseDown(pointer: Point, { e }: TBrushEventData): void {
+    if (!this.canvas._isMainEvent(e)) return;
+    if (this._isOutSideContainer(pointer)) return;
+
+    this.drawSpecialShape = !!this.specialShapeKey && e[this.specialShapeKey];
+    this._reset();
+    this._points[0].setXY(pointer.x, pointer.y);
+    this._points[1].setXY(pointer.x, pointer.y);
+    this.canvas.contextTop.moveTo(pointer.x, pointer.y);
+    this._gestureActive = true;
+    this._render();
+  }
+
+  onMouseMove(pointer: Point, { e }: TBrushEventData): void {
+    if (!this.canvas._isMainEvent(e) || !this._gestureActive) return;
+    if (this._isOutSideContainer(pointer)) return;
+
+    this.drawSpecialShape = !!this.specialShapeKey && e[this.specialShapeKey];
+    this._points[1].setXY(pointer.x, pointer.y);
+    this._render();
+  }
+
+  onMouseUp({ e }: TBrushEventData): boolean | void {
+    if (!this.canvas._isMainEvent(e)) return true;
+    const active = this._gestureActive;
+    this._gestureActive = false;
+    this.drawSpecialShape = false;
+
+    if (!active) return false;
+
+    this.canvas.clearContext(this.canvas.contextTop);
+    this.canvas.requestRenderAll();
+
+    const s: XY = { x: this._points[0].x, y: this._points[0].y };
+    const end: XY = { x: this._points[1].x, y: this._points[1].y };
+    const shift = !!this.specialShapeKey && e[this.specialShapeKey];
+    const eConstr = constrainedEnd(this.shape, s, end, shift);
+    const dist = Math.hypot(eConstr.x - s.x, eConstr.y - s.y);
+
+    if (this.decimate > 0 && dist < this.decimate) {
+      const pointer = this.canvas.getScenePoint(e);
+      this.fireInvalid('dragTooShort', pointer, { distance: dist, minRequired: this.decimate });
+      this._reset();
+      return false;
+    }
+
+    this._finalizeAndAddShape(s, end, shift);
+    this._reset();
+    return false;
+  }
+
+  _render(ctx: CanvasRenderingContext2D = this.canvas.contextTop) {
+    const shift = this.drawSpecialShape;
+    const s = this._points[0];
+    const e0 = this._points[1];
+    const ec = constrainedEnd(this.shape, s, e0, shift);
+    this.canvas.clearContext(ctx);
+    const { left, top, w, h } = bboxFromCorners(s, ec);
+    this._saveAndTransform(ctx);
+    this._setBrushStyles(ctx);
+    this._setShadow();
+    ctx.beginPath();
+    if (this.shape === 'rect') {
+      ctx.rect(left, top, w, h);
+    } else if (this.shape === 'circle') {
+      const r = Math.min(w, h) / 2;
+      ctx.ellipse(left + w / 2, top + h / 2, r, r, 0, 0, Math.PI * 2);
+    } else {
+      ctx.ellipse(left + w / 2, top + h / 2, w / 2, h / 2, 0, 0, Math.PI * 2);
+    }
+    ctx.stroke();
+    ctx.restore();
+    this.canvas.requestRenderAll();
+  }
+
+  private _buildFabricObject(left: number, top: number, w: number, h: number) {
+    const cx = left + w / 2;
+    const cy = top + h / 2;
+    const stroke = this.color;
+    const sw = this.width;
+
+    const options = mergeObjectOptions({
+      fill: null,
+      stroke,
+      strokeWidth: sw,
+      strokeLineCap: this.strokeLineCap,
+      strokeMiterLimit: this.strokeMiterLimit,
+      strokeLineJoin: this.strokeLineJoin,
+      strokeDashArray: this.strokeDashArray,
+    });
+
+    if (this.shape === 'rect') {
+      return new Rect({
+        left: cx,
+        top: cy,
+        width: Math.max(w, 1),
+        height: Math.max(h, 1),
+        ...options,
+      });
+    }
+
+    if (this.shape === 'circle') {
+      const radius = Math.max(Math.min(w, h) / 2, 0.5);
+      return new Circle({
+        left: cx,
+        top: cy,
+        radius,
+        ...options,
+      });
+    }
+
+    return new Ellipse({
+      left: cx,
+      top: cy,
+      originX: 'center',
+      originY: 'center',
+      rx: Math.max(w / 2, 0.5),
+      ry: Math.max(h / 2, 0.5),
+      ...options,
+    });
+  }
+
+  private _finalizeAndAddShape(s: XY, endRaw: XY, shift: boolean) {
+    const e = constrainedEnd(this.shape, s, endRaw, shift);
+    const { left, top, w, h } = bboxFromCorners(s, e);
+    const obj = this._buildFabricObject(left, top, w, h);
+
+    if (this.shadow) {
+      this.shadow.affectStroke = true;
+      obj.shadow = new Shadow(this.shadow);
+    }
+
+    this.canvas.fire('before:object:created', { object: obj });
+    this.canvas.add(obj);
+    this.canvas.requestRenderAll();
+    obj.setCoords();
+    this._resetShadow();
+    this.canvas.fire('object:created', { object: obj });
+  }
+
+  _reset() {
+    this._points[0].setXY(0, 0);
+    this._points[1].setXY(0, 0);
+    this._setBrushStyles(this.canvas.contextTop);
+    this._setShadow();
+  }
+
+  protected override cleanupBrushState(): void {
+    this._gestureActive = false;
+    this.drawSpecialShape = false;
+    this._points[0].setXY(0, 0);
+    this._points[1].setXY(0, 0);
+  }
+}
+
+function applyShiftSquare(sx: number, sy: number, ex: number, ey: number, shift: boolean): XY {
+  if (!shift) return { x: ex, y: ey };
+  const signX = ex >= sx ? 1 : -1;
+  const signY = ey >= sy ? 1 : -1;
+  const side = Math.max(Math.abs(ex - sx), Math.abs(ey - sy));
+  return { x: sx + signX * side, y: sy + signY * side };
+}
+
+function bboxFromCorners(s: XY, e: XY) {
+  const left = Math.min(s.x, e.x);
+  const top = Math.min(s.y, e.y);
+  const w = Math.abs(e.x - s.x);
+  const h = Math.abs(e.y - s.y);
+  return { left, top, w, h };
+}
+
+function constrainedEnd(shape: DragShape, s: XY, e: XY, shift: boolean): XY {
+  const useSquare = shape === 'rect' || (shift && (shape === 'circle' || shape === 'ellipse'));
+  if (!useSquare) return { x: e.x, y: e.y };
+  return applyShiftSquare(s.x, s.y, e.x, e.y, shift);
+}

+ 320 - 0
src/lib/fabric/brush/class/PolygonBrush.ts

@@ -0,0 +1,320 @@
+import type { Canvas, FabricObjectProps, TBrushEventData, XY } from 'fabric';
+import type { PolygonShape } from '../types';
+
+import { Point, Polygon, Shadow } from 'fabric';
+import { ShapeBrush } from './ShapeBrush';
+import { mergeObjectOptions } from '@/lib/fabric';
+
+const GEOM_EPS = 1e-9;
+
+function orient(a: XY, b: XY, c: XY): number {
+  return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
+}
+
+function onSegment(a: XY, b: XY, c: XY): boolean {
+  return Math.min(a.x, b.x) - GEOM_EPS <= c.x && c.x <= Math.max(a.x, b.x) + GEOM_EPS && Math.min(a.y, b.y) - GEOM_EPS <= c.y && c.y <= Math.max(a.y, b.y) + GEOM_EPS;
+}
+
+function segmentsIntersect(p1: XY, p2: XY, p3: XY, p4: XY): boolean {
+  const o1 = orient(p1, p2, p3);
+  const o2 = orient(p1, p2, p4);
+  const o3 = orient(p3, p4, p1);
+  const o4 = orient(p3, p4, p2);
+
+  if ((o1 > GEOM_EPS && o2 < -GEOM_EPS) || (o1 < -GEOM_EPS && o2 > GEOM_EPS)) {
+    if ((o3 > GEOM_EPS && o4 < -GEOM_EPS) || (o3 < -GEOM_EPS && o4 > GEOM_EPS)) return true;
+  }
+
+  if (Math.abs(o1) <= GEOM_EPS && onSegment(p1, p2, p3)) return true;
+  if (Math.abs(o2) <= GEOM_EPS && onSegment(p1, p2, p4)) return true;
+  if (Math.abs(o3) <= GEOM_EPS && onSegment(p3, p4, p1)) return true;
+  if (Math.abs(o4) <= GEOM_EPS && onSegment(p3, p4, p2)) return true;
+
+  return false;
+}
+
+function newEdgeCrossesExisting(points: XY[], candidate: XY): boolean {
+  const n = points.length;
+  if (n < 1) return false;
+  const last = points[n - 1]!;
+
+  for (let i = 0; i < n - 1; i++) {
+    if (i === n - 2) continue;
+    const a = points[i]!;
+    const b = points[i + 1]!;
+    if (segmentsIntersect(last, candidate, a, b)) return true;
+  }
+
+  return false;
+}
+
+function closingEdgeCrosses(points: XY[]): boolean {
+  const n = points.length;
+  if (n < 3) return false;
+  const first = points[0]!;
+  const last = points[n - 1]!;
+
+  for (let i = 0; i < n - 1; i++) {
+    if (i === 0 || i === n - 2) continue;
+    const a = points[i]!;
+    const b = points[i + 1]!;
+    if (segmentsIntersect(last, first, a, b)) return true;
+  }
+
+  return false;
+}
+
+function polygonSignedArea(pts: XY[]): number {
+  if (pts.length < 3) return 0;
+  let s = 0;
+  for (let i = 0; i < pts.length; i++) {
+    const j = (i + 1) % pts.length;
+    s += pts[i]!.x * pts[j]!.y - pts[j]!.x * pts[i]!.y;
+  }
+  return s / 2;
+}
+
+function distanceSq(a: XY, b: XY): number {
+  const dx = a.x - b.x;
+  const dy = a.y - b.y;
+  return dx * dx + dy * dy;
+}
+
+function axisAlignedBBoxCenter(pts: XY[]): { ox: number; oy: number } {
+  let minX = pts[0]!.x;
+  let minY = pts[0]!.y;
+  let maxX = minX;
+  let maxY = minY;
+  for (let i = 1; i < pts.length; i++) {
+    const { x, y } = pts[i]!;
+    minX = Math.min(minX, x);
+    minY = Math.min(minY, y);
+    maxX = Math.max(maxX, x);
+    maxY = Math.max(maxY, y);
+  }
+  return { ox: minX + (maxX - minX) / 2, oy: minY + (maxY - minY) / 2 };
+}
+
+function buildPolygonFromScenePoints(scenePts: XY[], style: Partial<FabricObjectProps>): Polygon {
+  const { ox, oy } = axisAlignedBBoxCenter(scenePts);
+  const rel = scenePts.map((p) => ({ x: p.x - ox, y: p.y - oy }));
+  return new Polygon(rel, {
+    left: ox,
+    top: oy,
+    fill: null,
+    ...style,
+  });
+}
+
+/** `decimate === 0` 时点击首点闭合的默认吸附半径(场景像素),与旧 POLYGON_CLOSE_DISTANCE_SCENE 一致。 */
+const CLOSE_SNAP_WHEN_DECIMATE_OFF = 14;
+
+type BrushInvalidReason = 'outOfRegion' | 'selfIntersection' | 'degenerateTriangle' | 'closeWouldIntersect' | 'polygonTooFewPoints' | 'dragTooShort';
+
+export class PolygonBrush extends ShapeBrush<PolygonShape> {
+  /** 有效顶点数达到该值即自动闭合落成;默认 100;三角形工具用 3。 */
+  vertexTargetCount = 100;
+
+  private get vc() {
+    return this.shape === 'triangle' ? 3 : this.vertexTargetCount;
+  }
+
+  /** 是否三角形工具:仅由 `vertexTargetCount === 3` 决定(与 Cropper `triangle` / `polygon` 形状一致)。 */
+  private isTriangleTool(): boolean {
+    return this.vc === 3;
+  }
+
+  /** 是否多边形工具(非三角形):`vertexTargetCount > 3`。 */
+  private isPolygonTool(): boolean {
+    return this.vc > 3;
+  }
+
+  decimate = 24;
+  areaEps = 13;
+
+  private vertices: XY[] = [];
+  private moveHint: XY | null = null;
+
+  constructor(canvas: Canvas) {
+    super(canvas);
+  }
+
+  onMouseDoubleClick(pointer: Point, { e }: TBrushEventData): void {
+    if (!this.canvas._isMainEvent(e)) return;
+    this.tryCloseFromKeyboardOrDblClick({ x: pointer.x, y: pointer.y });
+  }
+
+  private closeSnapSq(): number {
+    if (this.decimate > 0) return this.decimate * this.decimate;
+    return CLOSE_SNAP_WHEN_DECIMATE_OFF * CLOSE_SNAP_WHEN_DECIMATE_OFF;
+  }
+
+  private fireInvalid(reason: BrushInvalidReason, point: XY, extra?: { vertexCount?: number; detail?: Record<string, unknown> }) {
+    this.canvas.fire('brush:invalid', {
+      reason,
+      point,
+      vertexCount: extra?.vertexCount,
+      detail: extra?.detail,
+    });
+  }
+
+  private refreshPreview(): void {
+    this._render();
+    this.canvas.requestRenderAll();
+  }
+
+  private finalizePolygon(pointForInvalid: XY): boolean {
+    if (closingEdgeCrosses(this.vertices)) {
+      this.fireInvalid('closeWouldIntersect', pointForInvalid, { vertexCount: this.vertices.length });
+      return false;
+    }
+    const area = Math.abs(polygonSignedArea(this.vertices));
+    if (area < this.areaEps) {
+      this.fireInvalid('degenerateTriangle', pointForInvalid, { vertexCount: this.vertices.length });
+      return false;
+    }
+
+    const options = mergeObjectOptions({
+      fill: null,
+      stroke: this.color,
+      strokeWidth: this.width,
+      strokeLineCap: this.strokeLineCap,
+      strokeMiterLimit: this.strokeMiterLimit,
+      strokeLineJoin: this.strokeLineJoin,
+      strokeDashArray: this.strokeDashArray,
+    });
+
+    const poly = buildPolygonFromScenePoints(this.vertices, options);
+
+    if (this.shadow) {
+      this.shadow.affectStroke = true;
+      poly.shadow = new Shadow(this.shadow);
+    }
+
+    this.vertices = [];
+    this.moveHint = null;
+    this.canvas.clearContext(this.canvas.contextTop);
+    this.canvas.fire('before:object:created', { object: poly });
+    this.canvas.add(poly);
+    this.canvas.requestRenderAll();
+    poly.setCoords();
+    this._resetShadow();
+    this.canvas.fire('object:created', { object: poly });
+    return true;
+  }
+
+  /**
+   * Enter / 双击:在顶点未达上限且 ≥3 点时尝试闭合。
+   * 三角形工具仅靠点满自动闭合,此处恒 false。
+   * 多边形工具下闭合不合规(点数不足、自交闭合边、退化面积等)时清空顶点,恢复初始状态。
+   */
+  tryCloseFromKeyboardOrDblClick(scenePointForEvent: XY): boolean {
+    if (this.isTriangleTool()) return false;
+    if (this.vertices.length < 3) {
+      this.fireInvalid('polygonTooFewPoints', scenePointForEvent, { vertexCount: this.vertices.length });
+      this.reset();
+      return false;
+    }
+    const ok = this.finalizePolygon(scenePointForEvent);
+    if (!ok) this.reset();
+    return ok;
+  }
+
+  onMouseDown(pointer: Point, { e }: TBrushEventData): void {
+    if (!this.canvas._isMainEvent(e)) return;
+    if (this._isOutSideContainer(pointer)) return;
+
+    const r = pointer;
+    const canCloseByFirst =
+      this.isPolygonTool() && this.vertices.length >= 3 && this.vertices.length < this.vertexTargetCount && distanceSq(r, this.vertices[0]!) <= this.closeSnapSq();
+
+    if (canCloseByFirst) {
+      if (closingEdgeCrosses(this.vertices)) {
+        this.fireInvalid('closeWouldIntersect', r, { vertexCount: this.vertices.length });
+        return;
+      }
+      this.finalizePolygon(r);
+      return;
+    }
+
+    if (this.vertices.length > 0 && this.decimate > 0) {
+      const last = this.vertices[this.vertices.length - 1]!;
+      const d = Math.hypot(r.x - last.x, r.y - last.y);
+      if (d < this.decimate) {
+        this.fireInvalid('dragTooShort', r, {
+          detail: { distance: d, minRequired: this.decimate },
+        });
+        return;
+      }
+    }
+
+    if (this.vertices.length > 0 && newEdgeCrossesExisting(this.vertices, r)) {
+      this.fireInvalid('selfIntersection', r, { vertexCount: this.vertices.length });
+      return;
+    }
+
+    this.vertices.push(r);
+
+    if (this.vertices.length === this.vertexTargetCount) {
+      if (this.isTriangleTool()) {
+        const area = Math.abs(polygonSignedArea(this.vertices));
+        if (area < this.areaEps) {
+          this.fireInvalid('degenerateTriangle', r, { vertexCount: 3 });
+          this.vertices.pop();
+          this.refreshPreview();
+          return;
+        }
+      }
+      this.finalizePolygon(r);
+      return;
+    }
+
+    this.refreshPreview();
+  }
+
+  onMouseMove(pointer: Point, { e }: TBrushEventData): void {
+    if (!this.canvas._isMainEvent(e)) return;
+    if (this._isOutSideContainer(pointer)) return;
+    this.moveHint = pointer;
+    this.refreshPreview();
+  }
+
+  onMouseUp({ e }: TBrushEventData): boolean {
+    if (!this.canvas._isMainEvent(e)) return false;
+    if (this.isTriangleTool() && this.vertices.length >= 3) return false;
+    if (this.isPolygonTool()) return this.vertices.length > 0;
+    return this.vertices.length > 0 && this.vertices.length < 3;
+  }
+
+  _render(ctx: CanvasRenderingContext2D = this.canvas.contextTop): void {
+    this.canvas.clearContext(ctx);
+    if (this.vertices.length === 0) return;
+    this._saveAndTransform(ctx);
+    this._setBrushStyles(ctx);
+    ctx.beginPath();
+    ctx.moveTo(this.vertices[0]!.x, this.vertices[0]!.y);
+    for (let i = 1; i < this.vertices.length; i++) {
+      ctx.lineTo(this.vertices[i]!.x, this.vertices[i]!.y);
+    }
+    const tail = this.moveHint ?? this.vertices[this.vertices.length - 1]!;
+    ctx.lineTo(tail.x, tail.y);
+    // 仅三角形工具:预览闭合到首点,形成三角形;多边形(vertexTargetCount > 3)保持开放折线,不出现三角形预览
+    if (this.isTriangleTool() && this.vertices.length >= 2) {
+      ctx.lineTo(this.vertices[0]!.x, this.vertices[0]!.y);
+    }
+    ctx.stroke();
+    ctx.restore();
+  }
+
+  reset(): void {
+    this.vertices = [];
+    this.moveHint = null;
+    this.canvas.clearContext(this.canvas.contextTop);
+    this.canvas.requestRenderAll();
+  }
+
+  protected override cleanupBrushState(): void {
+    this.vertices = [];
+    this.moveHint = null;
+  }
+}

+ 68 - 0
src/lib/fabric/brush/class/ShapeBrush.ts

@@ -0,0 +1,68 @@
+import type { Canvas, FabricObject, TBrushEventData, TPointerEventInfo } from 'fabric';
+import type { Shape } from '../types';
+import { BaseBrush, Point, StaticCanvas } from 'fabric';
+
+export abstract class ShapeBrush<T extends Shape> extends BaseBrush {
+  declare shape: T;
+  /**
+   * 与 {@link BaseBrush.onMouseDown} 相同形参风格:首参为场景坐标点,
+   * 次参为 `TBrushEventData`(与 Canvas 在绘图模式下调用笔刷时传入的 `{ e, pointer }` 一致)。
+   */
+  abstract onMouseDoubleClick(pointer: Point, ev: TBrushEventData): void;
+
+  declare protected _points: Point[];
+
+  private _destroyed = false;
+
+  /**
+   * 对应 Fabric {@link Canvas._onMouseDownInDrawingMode} 的命名与职责:在绘图模式下把画布 `mouse:dblclick`
+   * 转成对当前笔刷的 `onMouseDoubleClick(pointer, { e, pointer })`;仅当 `freeDrawingBrush` 为 `this` 时处理。
+   */
+  protected readonly _onMouseDoubleClickInDrawingMode = (opt: TPointerEventInfo) => {
+    if (this._destroyed) return;
+    if (!this.canvas.freeDrawingBrush || this.canvas.freeDrawingBrush !== this) return;
+    const pointer = opt.scenePoint;
+    this.onMouseDoubleClick(pointer, { e: opt.e, pointer });
+  };
+
+  constructor(canvas: Canvas) {
+    super(canvas);
+    this._points = [new Point(0, 0), new Point(0, 0)];
+    this.canvas.on('mouse:dblclick', this._onMouseDoubleClickInDrawingMode);
+  }
+
+  /**
+   * 销毁前重置笔刷内部状态;子类覆盖以清空顶点、手势标记等。
+   * {@link destroy} 随后会清空 `contextTop` 并 `requestRenderAll`。
+   */
+  protected cleanupBrushState(): void {}
+
+  destroy() {
+    if (this._destroyed) return;
+    this._destroyed = true;
+    this.cleanupBrushState();
+    this.canvas.clearContext(this.canvas.contextTop);
+    this.canvas.requestRenderAll();
+    this.canvas.off('mouse:dblclick', this._onMouseDoubleClickInDrawingMode);
+  }
+
+  protected _limitedToObject: FabricObject | Canvas | null = null;
+
+  get limited() {
+    return this._limitedToObject;
+  }
+
+  set limited(object: FabricObject | Canvas | null) {
+    this.limitedToCanvasSize = isCanvas(object);
+    this._limitedToObject = object;
+  }
+
+  protected _isOutSideContainer(pointer: Point): boolean {
+    if (!this._limitedToObject) return false;
+    return isCanvas(this._limitedToObject) ? super._isOutSideCanvas(pointer) : !this._limitedToObject.containsPoint(pointer);
+  }
+}
+
+function isCanvas(object: unknown): object is StaticCanvas {
+  return object instanceof StaticCanvas;
+}

+ 29 - 0
src/lib/fabric/brush/index.ts

@@ -0,0 +1,29 @@
+import type { Shape } from './types';
+import type { Canvas } from 'fabric';
+import { ShapeBrush } from './class/ShapeBrush';
+import { DragShapeBrush } from './class/DragShapeBrush';
+import { PolygonBrush } from './class/PolygonBrush';
+import { isDragShape, isPolygonShape } from './utils';
+
+export { isDragShape, isPolygonShape, isSameShapeCategory, isShapeBrush, isDragShapeBrush, isPolygonBrush, isSomeShapeBrush, isFreeDrawingBrushForShape } from './utils';
+export { ShapeBrush } from './class/ShapeBrush';
+export { DragShapeBrush } from './class/DragShapeBrush';
+export { PolygonBrush } from './class/PolygonBrush';
+
+export * from './types';
+
+export function createShapeBrush(canvas: Canvas, shape: Shape) {
+  if (isDragShape(shape)) {
+    const brush = new DragShapeBrush(canvas);
+    brush.shape = shape;
+    return brush;
+  }
+  if (isPolygonShape(shape)) {
+    const brush = new PolygonBrush(canvas);
+    brush.shape = shape;
+    if (shape === 'triangle') brush.vertexTargetCount = 3;
+    return brush;
+  }
+
+  return void 0;
+}

+ 4 - 0
src/lib/fabric/brush/types.ts

@@ -0,0 +1,4 @@
+export type DragShape = 'rect' | 'circle' | 'ellipse';
+export type PolygonShape = 'triangle' | 'polyline' | 'polygon';
+
+export type Shape = DragShape | PolygonShape;

+ 47 - 0
src/lib/fabric/brush/utils.ts

@@ -0,0 +1,47 @@
+import type { BaseBrush } from 'fabric';
+import type { DragShape, PolygonShape, Shape } from './types';
+import { ShapeBrush } from './class/ShapeBrush';
+import { DragShapeBrush } from './class/DragShapeBrush';
+import { PolygonBrush } from './class/PolygonBrush';
+
+export function isDragShape(value?: Shape): value is DragShape {
+  return !!value && ['rect', 'circle', 'ellipse'].includes(value);
+}
+
+export function isPolygonShape(value?: Shape): value is PolygonShape {
+  return !!value && ['triangle', 'polyline', 'polygon'].includes(value);
+}
+
+export function isSameShapeCategory<T extends Shape>(value?: T, oldValue?: Shape): boolean {
+  return (isDragShape(value) && isDragShape(oldValue)) || (isPolygonShape(value) && isPolygonShape(oldValue));
+}
+
+export function isDragShapeBrush(brush: BaseBrush | null | undefined): brush is DragShapeBrush {
+  return brush instanceof DragShapeBrush;
+}
+
+export function isPolygonBrush(brush: BaseBrush | null | undefined): brush is PolygonBrush {
+  return brush instanceof PolygonBrush;
+}
+
+/** 当前画布笔刷是否与 {@link Shape} 工具一致(与 {@link createShapeBrush} 约定对齐)。 */
+export function isFreeDrawingBrushForShape(brush: BaseBrush | null | undefined, shape?: Shape | null): boolean {
+  if (!brush || shape == null) return false;
+  if (isDragShape(shape) && isDragShapeBrush(brush)) return brush.shape === shape;
+  if (isPolygonShape(shape) && isPolygonBrush(brush)) {
+    if (shape === 'triangle') return brush.vertexTargetCount === 3;
+    if (shape === 'polygon') return brush.vertexTargetCount > 3;
+    return false;
+  }
+  return false;
+}
+
+/** 笔刷与 `shape` 是否同属一类(拖拽类 / 多边形类),不要求具体形状一致。 */
+export function isSomeShapeBrush(brush: BaseBrush | null | undefined, shape: Shape | null | undefined): boolean {
+  if (!brush || shape == null) return false;
+  return (isDragShape(shape) && isDragShapeBrush(brush)) || (isPolygonShape(shape) && isPolygonBrush(brush));
+}
+
+export function isShapeBrush(brush: BaseBrush | null | undefined): brush is ShapeBrush<Shape> {
+  return !!brush && brush instanceof ShapeBrush;
+}

+ 33 - 0
src/lib/fabric/common/blob.ts

@@ -0,0 +1,33 @@
+import type { Canvas, FabricObject, TToCanvasElementOptions } from 'fabric';
+
+interface Options extends TToCanvasElementOptions, BlobOptions {
+  multiplier?: number;
+  container?: FabricObject;
+
+  before?: (canvas: HTMLCanvasElement) => void;
+  after?: (blob: Blob | null) => void;
+}
+
+export interface BlobOptions {
+  format?: 'jpeg' | 'png' | 'webp';
+  quality?: number;
+}
+
+export function getBlob(canvas: Canvas, options?: Options): Promise<Blob> {
+  const multiplier = options?.multiplier ?? 1;
+  const bounds = options?.container ? options.container.getBoundingRect() : options;
+  const filter = options?.filter ?? (() => true);
+  const el = canvas.toCanvasElement(multiplier, { filter, ...bounds });
+  options?.before?.(el);
+
+  return new Promise<Blob>((resolve, reject) => {
+    el.toBlob(
+      (blob) => {
+        blob ? resolve(blob) : reject(new Error(`无法创建`));
+        options?.after?.(blob);
+      },
+      `image/${options?.format ?? 'png'}`,
+      options?.quality ?? 1
+    );
+  });
+}

+ 4 - 0
src/lib/fabric/common/index.ts

@@ -0,0 +1,4 @@
+export { getObjectMultiplier } from './object';
+
+export { getBlob, type BlobOptions } from './blob';
+export * from './transform';

+ 28 - 0
src/lib/fabric/common/object.ts

@@ -0,0 +1,28 @@
+import type { FabricObject } from 'fabric';
+import { FabricImage } from 'fabric';
+
+export function getObjectMultiplier(object: FabricObject, esp = 1e-9) {
+  object.setCoords();
+  if (object instanceof FabricImage) {
+    const sourceEl = object.getElement() as HTMLImageElement;
+    const sourceWidth = sourceEl.naturalWidth ?? sourceEl.width ?? 0;
+    const sourceHeight = sourceEl.naturalHeight ?? sourceEl.height ?? 0;
+
+    const scaleX = Math.abs(object.scaleX ?? 1);
+    const scaleY = Math.abs(object.scaleY ?? 1);
+    const displayWidth = Math.max(esp, (object.width ?? 1) * scaleX);
+    const displayHeight = Math.max(esp, (object.height ?? 1) * scaleY);
+
+    if (sourceWidth > 0 && sourceHeight > 0) {
+      const sx = sourceWidth / displayWidth;
+      const sy = sourceHeight / displayHeight;
+      return (sx + sy) / 2;
+    }
+  }
+  const { x, y } = object.getObjectScaling();
+  const ox = Math.abs(x);
+  const oy = Math.abs(y);
+  const sx = ox <= esp ? 1 : 1 / ox;
+  const sy = oy <= esp ? 1 : 1 / oy;
+  return (sx + sy) / 2;
+}

+ 166 - 0
src/lib/fabric/common/transform.ts

@@ -0,0 +1,166 @@
+import type { FabricObject, TMat2D, XY } from 'fabric';
+import { util } from 'fabric';
+
+const { createScaleMatrix, invertTransform, multiplyTransformMatrices, qrDecompose } = util;
+
+export interface TransformObjectOptions {
+  properties?: string[];
+  multiplier?: number;
+  absorbScale?: boolean;
+}
+
+export function getReferMatrix(object: FabricObject, multiplier = 1) {
+  // 获取参考对象的变换矩阵
+  const referMatrix = object.calcTransformMatrix();
+  const scaleMatrix = createScaleMatrix(multiplier, multiplier);
+  return multiplyTransformMatrices(referMatrix, scaleMatrix);
+}
+
+export function transformObjectData(object: FabricObject, refer: FabricObject, options?: TransformObjectOptions): Record<string, any> {
+  const scaledReferMatrix = getReferMatrix(refer, options?.multiplier);
+  return transformObjectMatrix(object, scaledReferMatrix, options);
+}
+
+export function transformObjectMatrix(object: FabricObject, matrix: TMat2D, options?: Omit<TransformObjectOptions, 'multiplier'>): Record<string, any> {
+  // 获取 object 的世界变换矩阵
+  const objectMatrix = object.calcTransformMatrix();
+  // 求参考矩阵的逆矩阵
+  const inverted = invertTransform(matrix);
+  // object 相对于缩放后参考对象的变换矩阵
+  const relativeMatrix = multiplyTransformMatrices(inverted, objectMatrix);
+  // 从相对矩阵中解构出 transform 属性
+  return getDecompose(object, relativeMatrix, options);
+}
+
+export function getDecompose(object: FabricObject, matrix: TMat2D, options?: Omit<TransformObjectOptions, 'multiplier'>) {
+  const properties = options?.properties ?? [];
+  const absorbScale = options?.absorbScale ?? true;
+  const decomposed = qrDecompose(matrix);
+  const data = {
+    ...object.toObject(properties),
+    left: decomposed.translateX,
+    top: decomposed.translateY,
+    angle: decomposed.angle,
+    scaleX: decomposed.scaleX,
+    scaleY: decomposed.scaleY,
+    skewX: decomposed.skewX,
+    skewY: decomposed.skewY,
+  };
+  return absorbScale ? absorbScaleIntoGeometry(data, object.type) : data;
+}
+
+export function setDecompose(object: FabricObject, matrix: TMat2D, options?: Pick<TransformObjectOptions, 'absorbScale'>) {
+  const decomposed = qrDecompose(matrix);
+  const data = {
+    left: decomposed.translateX,
+    top: decomposed.translateY,
+    angle: decomposed.angle,
+    scaleX: decomposed.scaleX,
+    scaleY: decomposed.scaleY,
+    skewX: decomposed.skewX,
+    skewY: decomposed.skewY,
+  };
+  object.set(data);
+  object.setCoords();
+}
+
+export function calcCoords(decompose: Record<string, any>): [XY, XY, XY, XY] {
+  const { left = 0, top = 0, width = 0, height = 0, angle = 0, scaleX = 1, scaleY = 1, originX = 'center', originY = 'center' } = decompose;
+
+  const ORIGIN_MAP = { left: 0, center: 0.5, right: 1, top: 0, bottom: 1 };
+
+  const w = width * scaleX;
+  const h = height * scaleY;
+
+  // 1. 将 origin 转为 0~1 的偏移系数
+  const ox = ORIGIN_MAP[originX as keyof typeof ORIGIN_MAP] ?? 0.5;
+  const oy = ORIGIN_MAP[originY as keyof typeof ORIGIN_MAP] ?? 0.5;
+
+  // 2. 从锚点推算中心点(未旋转坐标系下的偏移)
+  //    锚点在对象内的位置:ox=0 → 左边, ox=1 → 右边
+  //    中心相对锚点的偏移 = (0.5 - ox) * w
+  const offsetX = (0.5 - ox) * w;
+  const offsetY = (0.5 - oy) * h;
+
+  // 3. 旋转矩阵(偏移也要跟着旋转)
+  const rad = (angle * Math.PI) / 180;
+  const cos = Math.cos(rad);
+  const sin = Math.sin(rad);
+
+  // 4. 中心点世界坐标
+  const cx = left + offsetX * cos - offsetY * sin;
+  const cy = top + offsetX * sin + offsetY * cos;
+
+  // 5. 四角相对中心的偏移(半宽高)
+  const hw = w / 2;
+  const hh = h / 2;
+
+  // 6. 旋转 + 平移到世界坐标
+  const rotate = (p: { x: number; y: number }) => ({
+    x: cx + p.x * cos - p.y * sin,
+    y: cy + p.x * sin + p.y * cos,
+  });
+
+  return [rotate({ x: -hw, y: -hh }), rotate({ x: hw, y: -hh }), rotate({ x: hw, y: hh }), rotate({ x: -hw, y: hh })] as const;
+}
+
+/**
+ * 将 scaleX/scaleY 吸收进几何特殊属性,使 scale 归一
+ * @param raw 对象原始数据
+ * @param type 对象类型
+ */
+export function absorbScaleIntoGeometry(raw: Record<string, any>, type: string): Record<string, any> {
+  const { scaleX = 1, scaleY = 1, width = 1, height = 1 } = raw;
+  const scaleWidth = width * scaleX;
+  const scaleHeight = height * scaleY;
+  switch (type.toLowerCase()) {
+    case 'circle':
+      return {
+        ...raw,
+        radius: raw.radius * ((scaleX + scaleY) / 2),
+        width: scaleWidth,
+        height: scaleHeight,
+        scaleX: 1,
+        scaleY: 1,
+      };
+
+    case 'ellipse':
+      return {
+        ...raw,
+        rx: raw.rx * scaleX,
+        ry: raw.ry * scaleY,
+        width: scaleWidth,
+        height: scaleHeight,
+        scaleX: 1,
+        scaleY: 1,
+      };
+
+    case 'rect':
+    case 'triangle':
+    case 'image':
+      return {
+        ...raw,
+        width: scaleWidth,
+        height: scaleHeight,
+        scaleX: 1,
+        scaleY: 1,
+      };
+
+    case 'polygon':
+    case 'polyline':
+      return {
+        ...raw,
+        points: raw.points.map((p: { x: number; y: number }) => ({
+          x: p.x * scaleX,
+          y: p.y * scaleY,
+        })),
+        width: scaleWidth,
+        height: scaleHeight,
+        scaleX: 1,
+        scaleY: 1,
+      };
+
+    default:
+      return raw;
+  }
+}

+ 65 - 0
src/lib/fabric/components/Fabric.vue

@@ -0,0 +1,65 @@
+<script setup lang="ts">
+import type { FabricInstance, FabricProps } from '@/lib/fabric';
+
+import { getCurrentInstance, onBeforeUnmount, onMounted, shallowRef, useAttrs, useTemplateRef } from 'vue';
+import { FabricCanvas, defaultCanvasOptions } from '@/lib/fabric';
+import { mergeWithExplicitProps } from '../utils/props';
+
+type FabricEmits = {
+  (event: string, payload?: unknown): void;
+};
+
+defineOptions({ name: 'FabricContainer', inheritAttrs: false });
+
+const props = defineProps<FabricProps>();
+const emits = defineEmits<FabricEmits>();
+const attrs = useAttrs();
+const vmInstance = getCurrentInstance();
+const canvasRef = useTemplateRef<HTMLCanvasElement>('canvas');
+const canvasInstance = shallowRef<FabricInstance | null>(null);
+
+let restoreFire: (() => void) | null = null;
+
+const patchFabricFire = (instance: FabricInstance) => {
+  const originalFire = instance.fire;
+  instance.fire = (...args) => {
+    const [eventName, payload] = args as unknown as [string, unknown?];
+    emits(eventName, payload);
+    return originalFire.apply(instance, args);
+  };
+  return () => {
+    instance.fire = originalFire;
+  };
+};
+
+onMounted(() => {
+  if (!canvasRef.value) return;
+
+  const canvasOptions = mergeWithExplicitProps<FabricProps>(defaultCanvasOptions, props as FabricProps, (vmInstance?.vnode.props ?? {}) as Record<string, unknown>);
+  const { width, height } = canvasRef.value.parentElement?.getBoundingClientRect() ?? canvasOptions;
+  const instance = new FabricCanvas(canvasRef.value, {
+    ...canvasOptions,
+    width: canvasOptions.width ?? width,
+    height: canvasOptions.height ?? height,
+  });
+
+  restoreFire = patchFabricFire(instance);
+  canvasInstance.value = instance;
+
+  emits('ready', instance);
+});
+
+onBeforeUnmount(() => {
+  restoreFire?.();
+  canvasInstance.value?.dispose();
+  canvasInstance.value = null;
+});
+
+defineExpose({
+  canvas: canvasInstance,
+});
+</script>
+
+<template>
+  <canvas ref="canvas" v-bind="attrs" />
+</template>

+ 155 - 0
src/lib/fabric/components/toolbar/ToolButtonLike.vue

@@ -0,0 +1,155 @@
+<script setup lang="ts">
+import { toValue } from "vue";
+
+import ToolIconLabel from "./ToolIconLabel.vue";
+
+import type { ToolOption, ToolbarButtonTool, ToolbarSelectTool, ToolbarToggleTool } from "./types.ts";
+
+defineOptions({ name: "FabricToolbarToolButtonLike" });
+
+interface Props {
+  tool: ToolbarButtonTool | ToolbarToggleTool | ToolbarSelectTool;
+  disabled: boolean;
+  loading: boolean;
+  active: boolean;
+  resolvedIcon?: string;
+  selectedKey?: string;
+}
+
+interface Emits {
+  (event: "click"): void;
+  (event: "toggle"): void;
+  (event: "select", option: ToolOption): void;
+}
+
+const props = defineProps<Props>();
+const emit = defineEmits<Emits>();
+
+const isRadioGroupTool = (tool: Props["tool"]): tool is ToolbarSelectTool => tool.type === "radio-group";
+const resolveIcon = (icon: ToolOption["icon"]) => toValue(icon);
+const resolveLabel = (label: Props["tool"]["label"]) => toValue(label);
+</script>
+
+<template>
+  <div v-if="isRadioGroupTool(props.tool)" class="fabric-toolbar-radio-group">
+    <button
+      v-for="option in props.tool.options"
+      :key="option.key"
+      type="button"
+      class="fabric-toolbar-segment"
+      :class="{ 'is-active': option.key === props.selectedKey }"
+      :disabled="props.disabled || !!option.disabled"
+      @click="emit('select', option)"
+    >
+      <ToolIconLabel :icon="resolveIcon(option.icon)" :label="option.label" />
+    </button>
+  </div>
+
+  <button
+    v-else
+    type="button"
+    class="fabric-toolbar-button"
+    :class="{ 'is-active': props.active }"
+    :title="props.tool.title ?? undefined"
+    :disabled="props.disabled"
+    :aria-pressed="props.tool.type === 'toggle' ? props.active : undefined"
+    @click="props.tool.type === 'toggle' ? emit('toggle') : emit('click')"
+  >
+    <span v-if="props.loading" class="fabric-toolbar-spinner" aria-hidden="true" />
+    <ToolIconLabel v-else :icon="props.resolvedIcon" :label="resolveLabel(props.tool.label)" />
+  </button>
+</template>
+
+<style scoped>
+.fabric-toolbar-button,
+.fabric-toolbar-segment {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: var(--toolbar-control-gap);
+  min-height: var(--toolbar-control-min-height);
+  border: var(--toolbar-control-border-width) solid var(--toolbar-color-control-border);
+  background: var(--toolbar-color-control-bg);
+  color: var(--toolbar-color-text);
+  font-size: var(--toolbar-font-size);
+  line-height: 1;
+  padding: var(--toolbar-padding-y) var(--toolbar-padding-x);
+  cursor: pointer;
+  transition: var(--toolbar-transition-control);
+}
+
+.fabric-toolbar-button {
+  border-radius: var(--toolbar-control-radius-pill);
+}
+
+/* 避免 :hover 特异性压过 .is-active,导致「选中态」悬停时看起来像未选中 */
+.fabric-toolbar-button:hover:not(:disabled):not(.is-active),
+.fabric-toolbar-segment:hover:not(:disabled):not(.is-active) {
+  border-color: var(--toolbar-color-hover-border);
+  background: var(--toolbar-color-hover-bg);
+}
+
+.fabric-toolbar-button.is-active,
+.fabric-toolbar-segment.is-active {
+  border-color: var(--toolbar-color-active-border);
+  background: var(--toolbar-color-active-bg);
+  color: var(--toolbar-color-active-text);
+}
+
+.fabric-toolbar-button.is-active:hover:not(:disabled),
+.fabric-toolbar-segment.is-active:hover:not(:disabled) {
+  border-color: var(--toolbar-color-active-border);
+  background: var(--toolbar-color-active-bg);
+  color: var(--toolbar-color-active-text);
+}
+
+.fabric-toolbar-button:focus-visible,
+.fabric-toolbar-segment:focus-visible {
+  outline: var(--toolbar-focus-ring-width) solid var(--toolbar-color-focus-ring);
+  outline-offset: var(--toolbar-focus-ring-offset);
+}
+
+.fabric-toolbar-button:disabled,
+.fabric-toolbar-segment:disabled {
+  cursor: not-allowed;
+  opacity: var(--toolbar-disabled-opacity);
+}
+
+.fabric-toolbar-radio-group {
+  display: inline-flex;
+  align-items: center;
+  overflow: hidden;
+  border: var(--toolbar-control-border-width) solid var(--toolbar-color-control-border);
+  border-radius: var(--toolbar-control-radius-pill);
+  background: var(--toolbar-color-control-bg);
+  --toolbar-color-active-border: var(--toolbar-color-control-border);
+}
+
+.fabric-toolbar-segment {
+  border: 0;
+  border-right: var(--toolbar-control-border-width) solid var(--toolbar-color-control-border);
+  border-radius: 0;
+}
+
+.fabric-toolbar-segment:last-child {
+  border-right: 0;
+}
+
+.fabric-toolbar-spinner {
+  width: var(--toolbar-spinner-size);
+  height: var(--toolbar-spinner-size);
+  border: var(--toolbar-spinner-border-width) solid var(--toolbar-color-spinner-track);
+  border-top-color: var(--toolbar-color-spinner-head);
+  border-radius: var(--toolbar-control-radius-pill);
+  animation: toolbar-spin var(--toolbar-spinner-duration) linear infinite;
+}
+
+@keyframes toolbar-spin {
+  from {
+    transform: rotate(0deg);
+  }
+  to {
+    transform: rotate(360deg);
+  }
+}
+</style>

+ 64 - 0
src/lib/fabric/components/toolbar/ToolIconLabel.vue

@@ -0,0 +1,64 @@
+<script setup lang="ts">
+import { Icon } from "@iconify/vue";
+import { computed } from "vue";
+
+defineOptions({ name: "FabricToolbarToolIconLabel" });
+
+interface Props {
+  icon?: string;
+  label?: string;
+}
+
+const props = defineProps<Props>();
+
+const ICONIFY_ID_RE = /^[a-z0-9]+:[a-z0-9-]+$/i;
+
+const iconifyIcon = computed(() => {
+  const icon = props.icon;
+  if (!icon || !ICONIFY_ID_RE.test(icon)) return undefined;
+  return icon;
+});
+</script>
+
+<template>
+  <span class="fabric-toolbar-icon-label">
+    <Icon v-if="iconifyIcon" :icon="iconifyIcon" class="fabric-toolbar-icon" aria-hidden="true" />
+    <span v-else-if="props.icon" class="fabric-toolbar-icon" aria-hidden="true">
+      {{ props.icon }}
+    </span>
+    <span v-if="props.label" class="fabric-toolbar-label">{{ props.label }}</span>
+  </span>
+</template>
+
+<style scoped>
+.fabric-toolbar-icon-label {
+  display: inline-flex;
+  align-items: center;
+  gap: var(--toolbar-icon-label-gap, 6px);
+  min-width: 0;
+  vertical-align: middle;
+}
+
+.fabric-toolbar-icon {
+  width: var(--toolbar-icon-size, 16px);
+  height: var(--toolbar-icon-size, 16px);
+  line-height: 1;
+  flex-shrink: 0;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  font-size: var(--toolbar-icon-emoji-font-size, 12px);
+  color: currentColor;
+}
+
+.fabric-toolbar-icon :deep(svg) {
+  display: block;
+  color: inherit;
+}
+
+.fabric-toolbar-label {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+</style>

+ 235 - 0
src/lib/fabric/components/toolbar/ToolItem.vue

@@ -0,0 +1,235 @@
+<script setup lang="ts">
+import { computed, shallowRef, toValue } from "vue";
+
+import ToolButtonLike from "./ToolButtonLike.vue";
+import ToolRender from "./ToolRender.ts";
+import ToolSelectableControl from "./ToolSelectableControl.vue";
+
+import type {
+  ToolOption,
+  ToolbarActionContext,
+  ToolbarButtonTool,
+  ToolbarClickPayload,
+  ToolbarMenuOpenChangePayload,
+  ToolbarSelectPayload,
+  ToolbarSelectTool,
+  ToolbarTogglePayload,
+  ToolbarToggleTool,
+  ToolbarTool,
+} from "./types.ts";
+
+defineOptions({ name: "FabricToolbarToolItem" });
+
+interface Props {
+  tool: ToolbarTool;
+  disabled?: boolean;
+  runtimeLoading?: boolean;
+  actionContext?: ToolbarActionContext;
+  menuOpen?: boolean;
+}
+
+interface Emits {
+  (event: "tool-click", payload: ToolbarClickPayload): void;
+  (event: "tool-toggle", payload: ToolbarTogglePayload): void;
+  (event: "tool-select", payload: ToolbarSelectPayload): void;
+  (event: "menu-open-change", payload: ToolbarMenuOpenChangePayload): void;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  disabled: false,
+  runtimeLoading: false,
+  actionContext: undefined,
+  menuOpen: false,
+});
+const emit = defineEmits<Emits>();
+
+const localToggleState = shallowRef<Record<string, boolean>>({});
+const localSelectedState = shallowRef<Record<string, string | undefined>>({});
+
+const resolveToolParams = (tool: ToolbarTool) =>
+  tool.params !== undefined ? (toValue(tool.params) as Record<string, unknown>) : undefined;
+
+const isSelectTool = (tool: ToolbarTool): tool is ToolbarSelectTool =>
+  tool.type === "menu-button" || tool.type === "dropdown-button" || tool.type === "radio-group";
+const isButtonTool = (tool: ToolbarTool): tool is ToolbarButtonTool => tool.type === "button";
+const isToggleTool = (tool: ToolbarTool): tool is ToolbarToggleTool => tool.type === "toggle";
+
+const getDefaultSelectedKey = (tool: ToolbarSelectTool): string | undefined => {
+  if (tool.defaultKey) return tool.defaultKey;
+  if (typeof tool.defaultIndex === "number") return tool.options[tool.defaultIndex]?.key;
+  return undefined;
+};
+
+const resolveSelectedKey = (tool: ToolbarSelectTool): string | undefined => {
+  if (tool.selectedKey !== undefined) return toValue(tool.selectedKey);
+  if (Object.prototype.hasOwnProperty.call(localSelectedState.value, tool.key)) return localSelectedState.value[tool.key];
+  const initial = getDefaultSelectedKey(tool);
+  localSelectedState.value = { ...localSelectedState.value, [tool.key]: initial };
+  return initial;
+};
+
+const setSelectedKey = (tool: ToolbarSelectTool, value: string | undefined) => {
+  if (tool.selectedKey !== undefined) return;
+  localSelectedState.value = { ...localSelectedState.value, [tool.key]: value };
+};
+
+const resolveToggleActive = (tool: ToolbarToggleTool): boolean => {
+  if (tool.active !== undefined) return Boolean(toValue(tool.active));
+  return Boolean(localToggleState.value[tool.key]);
+};
+
+const setToggleActive = (tool: ToolbarToggleTool, value: boolean) => {
+  if (tool.active !== undefined) return;
+  localToggleState.value = { ...localToggleState.value, [tool.key]: value };
+};
+
+const isToolDisabled = computed(() => props.disabled || Boolean(toValue(props.tool.disabled)));
+const isToolLoading = computed(() => props.runtimeLoading || Boolean(toValue(props.tool.loading)));
+const isToggleActive = computed(() => (isToggleTool(props.tool) ? resolveToggleActive(props.tool) : false));
+const selectedKey = computed(() => (isSelectTool(props.tool) ? resolveSelectedKey(props.tool) : undefined));
+const selectedOption = computed<ToolOption | undefined>(() => {
+  if (!isSelectTool(props.tool)) return undefined;
+  return props.tool.options.find((option) => option.key === selectedKey.value);
+});
+const displayLabel = computed(() => selectedOption.value?.label ?? toValue(props.tool.label));
+const displayIcon = computed(() => (selectedOption.value ? toValue(selectedOption.value.icon) : toValue(props.tool.icon)));
+const resolvedIcon = computed(() => {
+  if (!isToggleTool(props.tool)) return toValue(props.tool.icon);
+  if (isToggleActive.value && props.tool.activeIcon) return toValue(props.tool.activeIcon);
+  return toValue(props.tool.icon);
+});
+
+/** 单选条、普通按钮、开关共用一个呈现组件 */
+const showToolButtonLike = computed(
+  () =>
+    isButtonTool(props.tool) || isToggleTool(props.tool) || props.tool.type === "radio-group",
+);
+
+const emitMenuOpen = (open: boolean) => emit("menu-open-change", { key: props.tool.key, open });
+
+const emitSelectPayload = (tool: ToolbarSelectTool, optionKey: string) => {
+  const oldValue = resolveSelectedKey(tool);
+  const allowClear = Boolean(tool.allowClear);
+  const value = allowClear && oldValue === optionKey ? undefined : optionKey;
+  setSelectedKey(tool, value);
+  emit("tool-select", {
+    tool,
+    key: tool.key,
+    context: props.actionContext,
+    value,
+    oldValue,
+    params: resolveToolParams(tool),
+  });
+};
+
+const onButtonClick = () => {
+  if (!isButtonTool(props.tool)) return;
+  emit("tool-click", {
+    tool: props.tool,
+    key: props.tool.key,
+    context: props.actionContext,
+    params: resolveToolParams(props.tool),
+  });
+};
+
+const onToggleClick = () => {
+  if (!isToggleTool(props.tool)) return;
+  const oldValue = resolveToggleActive(props.tool);
+  const value = !oldValue;
+  setToggleActive(props.tool, value);
+  emit("tool-toggle", {
+    tool: props.tool,
+    key: props.tool.key,
+    context: props.actionContext,
+    value,
+    oldValue,
+    params: resolveToolParams(props.tool),
+  });
+};
+
+const onRadioOptionClick = (option: ToolOption) => {
+  if (!isSelectTool(props.tool) || props.tool.type !== "radio-group") return;
+  emitSelectPayload(props.tool, option.key);
+};
+
+const onPrimaryClick = () => {
+  if (!isSelectTool(props.tool)) return;
+  if (props.tool.type === "dropdown-button") {
+    const currentSelectedKey = resolveSelectedKey(props.tool);
+    if (currentSelectedKey) {
+      emitSelectPayload(props.tool, currentSelectedKey);
+      return;
+    }
+  }
+  emitMenuOpen(!props.menuOpen);
+};
+
+const onToggleMenu = () => emitMenuOpen(!props.menuOpen);
+
+const onMenuOptionSelect = (option: ToolOption) => {
+  if (!isSelectTool(props.tool) || props.tool.type === "radio-group") return;
+  emitSelectPayload(props.tool, option.key);
+  emitMenuOpen(false);
+};
+
+const onRenderTrigger = (key: string) => {
+  if (isSelectTool(props.tool)) {
+    const option = props.tool.options.find((item) => item.key === key);
+    if (!option) return;
+    emitSelectPayload(props.tool, key);
+    return;
+  }
+  if (isToggleTool(props.tool) && key === props.tool.key) {
+    onToggleClick();
+    return;
+  }
+  onButtonClick();
+};
+</script>
+
+<template>
+  <ToolRender
+    v-if="props.tool.render"
+    :tool="props.tool"
+    :render="props.tool.render"
+    :context="props.actionContext"
+    :trigger="onRenderTrigger"
+  />
+
+  <div v-else class="fabric-toolbar-item" :class="props.tool.class" :style="props.tool.style">
+    <ToolButtonLike
+      v-if="showToolButtonLike"
+      :tool="props.tool"
+      :disabled="isToolDisabled"
+      :loading="isToolLoading"
+      :active="isToggleActive"
+      :resolved-icon="resolvedIcon"
+      :selected-key="selectedKey"
+      @click="onButtonClick"
+      @toggle="onToggleClick"
+      @select="onRadioOptionClick"
+    />
+
+    <ToolSelectableControl
+      v-else-if="props.tool.type === 'menu-button' || props.tool.type === 'dropdown-button'"
+      :tool="props.tool"
+      :disabled="isToolDisabled"
+      :loading="isToolLoading"
+      :menu-open="props.menuOpen"
+      :selected-key="selectedKey"
+      :display-icon="displayIcon"
+      :display-label="displayLabel"
+      @primary-click="onPrimaryClick"
+      @toggle-menu="onToggleMenu"
+      @select-option="onMenuOptionSelect"
+    />
+  </div>
+</template>
+
+<style scoped>
+.fabric-toolbar-item {
+  display: inline-flex;
+  align-items: center;
+  overflow: visible;
+}
+</style>

+ 37 - 0
src/lib/fabric/components/toolbar/ToolRender.ts

@@ -0,0 +1,37 @@
+import { defineComponent, type PropType } from "vue";
+
+import type { ToolbarActionContext, ToolbarTool } from "./types.ts";
+
+export type ToolbarRenderFn = (
+  tool: ToolbarTool,
+  trigger: (key: string) => void | Promise<void>,
+  context?: ToolbarActionContext,
+) => unknown;
+
+/**
+ * 仅负责调用外部 `render` 函数;无模板,用 setup 返回渲染函数(Vue 3 中比 SFC 更贴切)。
+ */
+export default defineComponent({
+  name: "FabricToolbarToolRender",
+  props: {
+    tool: {
+      type: Object as PropType<ToolbarTool>,
+      required: true,
+    },
+    context: {
+      type: Object as PropType<ToolbarActionContext | undefined>,
+      default: undefined,
+    },
+    render: {
+      type: Function as PropType<ToolbarRenderFn>,
+      required: true,
+    },
+    trigger: {
+      type: Function as PropType<(key: string) => void | Promise<void>>,
+      required: true,
+    },
+  },
+  setup(props) {
+    return () => props.render(props.tool, props.trigger, props.context);
+  },
+});

+ 343 - 0
src/lib/fabric/components/toolbar/ToolSelectableControl.vue

@@ -0,0 +1,343 @@
+<script setup lang="ts">
+import { Icon } from "@iconify/vue";
+import {
+  computed,
+  inject,
+  nextTick,
+  onBeforeUnmount,
+  ref,
+  toValue,
+  useTemplateRef,
+  watch,
+} from "vue";
+
+import ToolIconLabel from "./ToolIconLabel.vue";
+import { toolbarScrollableKey } from "./toolbarContext.ts";
+
+import type { ToolOption, ToolbarSelectTool } from "./types.ts";
+
+defineOptions({ name: "FabricToolbarToolSelectableControl" });
+
+interface Props {
+  tool: ToolbarSelectTool;
+  disabled: boolean;
+  loading: boolean;
+  menuOpen: boolean;
+  selectedKey?: string;
+  displayIcon?: string;
+  displayLabel?: string;
+}
+
+interface Emits {
+  (event: "primary-click"): void;
+  (event: "toggle-menu"): void;
+  (event: "select-option", option: ToolOption): void;
+}
+
+const props = defineProps<Props>();
+const emit = defineEmits<Emits>();
+
+const toolbarScrollable = inject(toolbarScrollableKey, computed(() => false));
+const usePortalMenu = computed(() => toolbarScrollable.value);
+
+const anchorRef = useTemplateRef<HTMLElement>("anchorRef");
+/** 与 CSS --toolbar-dropdown-offset-y 默认 6px 对齐 */
+const DROPDOWN_OFFSET_PX = 6;
+
+const fixedMenuStyle = ref<Record<string, string>>({});
+
+const visibleOptions = computed(() => {
+  const hideSelected = props.tool.type === "dropdown-button" && props.tool.hideSelected;
+  if (!hideSelected || !props.selectedKey) return props.tool.options;
+  return props.tool.options.filter((option) => option.key !== props.selectedKey);
+});
+
+const resolveIcon = (icon: ToolOption["icon"]) => toValue(icon);
+const resolveLabel = (label: ToolbarSelectTool["label"]) => toValue(label);
+
+function updateFixedMenuPosition() {
+  const el = anchorRef.value;
+  if (!el || !props.menuOpen || !usePortalMenu.value) return;
+  const r = el.getBoundingClientRect();
+  const minW = Math.max(r.width, 180);
+  fixedMenuStyle.value = {
+    position: "fixed",
+    top: `${Math.round(r.bottom + DROPDOWN_OFFSET_PX)}px`,
+    left: `${Math.round(r.left)}px`,
+    minWidth: `${Math.round(minW)}px`,
+    zIndex: "5000",
+  };
+}
+
+let repositionHandlersAttached = false;
+function attachRepositionHandlers() {
+  if (repositionHandlersAttached) return;
+  repositionHandlersAttached = true;
+  window.addEventListener("scroll", updateFixedMenuPosition, true);
+  window.addEventListener("resize", updateFixedMenuPosition);
+}
+
+function detachRepositionHandlers() {
+  if (!repositionHandlersAttached) return;
+  repositionHandlersAttached = false;
+  window.removeEventListener("scroll", updateFixedMenuPosition, true);
+  window.removeEventListener("resize", updateFixedMenuPosition);
+}
+
+watch(
+  () => [props.menuOpen, usePortalMenu.value] as const,
+  async ([open, portal]) => {
+    if (open && portal) {
+      await nextTick();
+      updateFixedMenuPosition();
+      attachRepositionHandlers();
+    } else {
+      detachRepositionHandlers();
+    }
+  },
+);
+
+onBeforeUnmount(() => detachRepositionHandlers());
+</script>
+
+<template>
+  <div ref="anchorRef" class="fabric-toolbar-dropdown-split">
+    <button
+      type="button"
+      class="fabric-toolbar-button"
+      :class="{ 'fabric-toolbar-button--split-primary': props.tool.type === 'dropdown-button' }"
+      :disabled="props.disabled"
+      :title="props.tool.title ?? undefined"
+      :aria-expanded="props.menuOpen"
+      @click="emit('primary-click')"
+    >
+      <span
+        v-if="props.loading"
+        class="fabric-toolbar-spinner"
+        aria-hidden="true"
+      />
+      <ToolIconLabel v-else :icon="props.displayIcon" :label="props.displayLabel" />
+      <Icon
+        v-if="props.tool.type === 'menu-button'"
+        icon="mdi:chevron-down"
+        class="fabric-toolbar-caret-icon fabric-toolbar-menu-caret"
+        aria-hidden="true"
+      />
+    </button>
+
+    <button
+      v-if="props.tool.type === 'dropdown-button'"
+      type="button"
+      class="fabric-toolbar-button fabric-toolbar-split-arrow"
+      :disabled="props.disabled"
+      :aria-expanded="props.menuOpen"
+      :title="`展开 ${resolveLabel(props.tool.label) ?? props.tool.key} 菜单`"
+      @click="emit('toggle-menu')"
+    >
+      <Icon icon="mdi:chevron-down" class="fabric-toolbar-caret-icon fabric-toolbar-split-caret" aria-hidden="true" />
+    </button>
+
+    <Teleport to="body" :disabled="!usePortalMenu">
+      <Transition name="fabric-toolbar-menu">
+        <div
+          v-if="props.menuOpen"
+          data-fabric-toolbar-menu-portal
+          class="fabric-toolbar-dropdown-menu"
+          :class="{ 'fabric-toolbar-dropdown-menu--portal': usePortalMenu }"
+          :style="usePortalMenu ? fixedMenuStyle : undefined"
+        >
+          <ul class="fabric-toolbar-dropdown-list" role="menu" :aria-label="resolveLabel(props.tool.label) ?? props.tool.key">
+            <li v-for="option in visibleOptions" :key="option.key">
+              <button
+                type="button"
+                role="menuitem"
+                class="fabric-toolbar-dropdown-item"
+                :class="{ 'is-selected': option.key === props.selectedKey }"
+                :disabled="props.disabled || !!option.disabled"
+                @click="emit('select-option', option)"
+              >
+                <ToolIconLabel :icon="resolveIcon(option.icon)" :label="option.label" />
+              </button>
+            </li>
+          </ul>
+          <div v-if="visibleOptions.length === 0" class="fabric-toolbar-empty-option">无可选项</div>
+        </div>
+      </Transition>
+    </Teleport>
+  </div>
+</template>
+
+<style scoped>
+.fabric-toolbar-dropdown-split {
+  position: relative;
+  display: inline-flex;
+  overflow: visible;
+}
+
+.fabric-toolbar-button {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: var(--toolbar-control-gap);
+  min-height: var(--toolbar-control-min-height);
+  border: var(--toolbar-control-border-width) solid var(--toolbar-color-control-border);
+  border-radius: var(--toolbar-control-radius-pill);
+  background: var(--toolbar-color-control-bg);
+  color: var(--toolbar-color-text);
+  font-size: var(--toolbar-font-size);
+  line-height: 1;
+  padding: var(--toolbar-padding-y) var(--toolbar-padding-x);
+  cursor: pointer;
+  transition: var(--toolbar-transition-control);
+}
+
+.fabric-toolbar-button--split-primary {
+  border-top-right-radius: 0;
+  border-bottom-right-radius: 0;
+  border-right: 0;
+}
+
+.fabric-toolbar-split-arrow {
+  min-width: var(--toolbar-split-arrow-min-width);
+  padding-left: var(--toolbar-split-arrow-padding-x);
+  padding-right: var(--toolbar-split-arrow-padding-x);
+  border-left-color: var(--toolbar-color-split-divider);
+  border-top-left-radius: 0;
+  border-bottom-left-radius: 0;
+}
+
+.fabric-toolbar-button:hover:not(:disabled) {
+  border-color: var(--toolbar-color-hover-border);
+  background: var(--toolbar-color-hover-bg);
+}
+
+.fabric-toolbar-button:focus-visible {
+  outline: var(--toolbar-focus-ring-width) solid var(--toolbar-color-focus-ring);
+  outline-offset: var(--toolbar-focus-ring-offset);
+}
+
+.fabric-toolbar-button:disabled {
+  cursor: not-allowed;
+  opacity: var(--toolbar-disabled-opacity);
+}
+
+.fabric-toolbar-spinner {
+  width: var(--toolbar-spinner-size);
+  height: var(--toolbar-spinner-size);
+  border: var(--toolbar-spinner-border-width) solid var(--toolbar-color-spinner-track);
+  border-top-color: var(--toolbar-color-spinner-head);
+  border-radius: var(--toolbar-control-radius-pill);
+  animation: toolbar-spin var(--toolbar-spinner-duration) linear infinite;
+}
+
+.fabric-toolbar-caret-icon {
+  flex-shrink: 0;
+  width: var(--toolbar-caret-size);
+  height: var(--toolbar-caret-size);
+}
+
+.fabric-toolbar-menu-caret {
+  margin-left: var(--toolbar-menu-caret-margin-left);
+  opacity: var(--toolbar-menu-caret-opacity);
+}
+
+.fabric-toolbar-split-caret {
+  opacity: var(--toolbar-split-caret-opacity);
+}
+
+.fabric-toolbar-dropdown-menu {
+  position: absolute;
+  top: calc(100% + var(--toolbar-dropdown-offset-y));
+  left: 0;
+  z-index: var(--toolbar-dropdown-z-index);
+  min-width: var(--toolbar-dropdown-min-width);
+  border: var(--toolbar-control-border-width) solid var(--toolbar-color-control-border);
+  border-radius: var(--toolbar-dropdown-radius);
+  background: var(--toolbar-color-control-bg);
+  padding: var(--toolbar-dropdown-padding);
+  box-shadow: var(--toolbar-shadow-dropdown);
+}
+
+.fabric-toolbar-dropdown-menu--portal {
+  position: fixed;
+  top: auto;
+  left: auto;
+}
+
+.fabric-toolbar-dropdown-list {
+  max-height: var(--toolbar-dropdown-list-max-height);
+  overflow: auto;
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+
+.fabric-toolbar-dropdown-item {
+  width: 100%;
+  display: inline-flex;
+  align-items: center;
+  gap: var(--toolbar-dropdown-item-gap);
+  border: 0;
+  border-radius: var(--toolbar-dropdown-item-radius);
+  background: transparent;
+  color: var(--toolbar-color-text);
+  padding: var(--toolbar-dropdown-item-padding-y) var(--toolbar-dropdown-item-padding-x);
+  text-align: left;
+  font-size: var(--toolbar-font-size);
+  cursor: pointer;
+}
+
+/* :hover 特异性高于 .is-selected,需排除选中项或显式覆盖,否则当前项悬停时高亮会「消失」 */
+.fabric-toolbar-dropdown-item:hover:not(:disabled):not(.is-selected) {
+  background: var(--toolbar-color-menu-hover-bg);
+}
+
+.fabric-toolbar-dropdown-item.is-selected {
+  background: var(--toolbar-color-menu-selected-bg);
+  color: var(--toolbar-color-menu-selected-text);
+}
+
+.fabric-toolbar-dropdown-item.is-selected:hover:not(:disabled) {
+  background: var(--toolbar-color-menu-selected-bg);
+  color: var(--toolbar-color-menu-selected-text);
+}
+
+.fabric-toolbar-dropdown-item:disabled {
+  cursor: not-allowed;
+  opacity: var(--toolbar-disabled-opacity);
+}
+
+.fabric-toolbar-empty-option {
+  padding: var(--toolbar-dropdown-item-padding-y) var(--toolbar-dropdown-item-padding-x);
+  font-size: var(--toolbar-empty-font-size);
+  color: var(--toolbar-color-muted);
+}
+
+.fabric-toolbar-menu-enter-active,
+.fabric-toolbar-menu-leave-active {
+  transition:
+    transform var(--toolbar-menu-transition-duration) ease,
+    opacity var(--toolbar-menu-transition-duration) ease;
+}
+
+.fabric-toolbar-menu-enter-from,
+.fabric-toolbar-menu-leave-to {
+  opacity: 0;
+  transform: translateY(var(--toolbar-menu-enter-translate-y)) scale(var(--toolbar-menu-enter-scale));
+}
+
+.fabric-toolbar-menu-enter-to,
+.fabric-toolbar-menu-leave-from {
+  opacity: 1;
+  transform: translateY(0) scale(1);
+}
+
+@keyframes toolbar-spin {
+  from {
+    transform: rotate(0deg);
+  }
+  to {
+    transform: rotate(360deg);
+  }
+}
+</style>

+ 302 - 0
src/lib/fabric/components/toolbar/Toolbar.vue

@@ -0,0 +1,302 @@
+<script setup lang="ts">
+import type {
+  ToolbarClickPayload,
+  ToolbarEmits,
+  ToolbarErrorPayload,
+  ToolbarMenuOpenChangePayload,
+  ToolbarProps,
+  ToolbarSelectPayload,
+  ToolbarSelectTool,
+  ToolbarTogglePayload,
+  ToolbarTool,
+} from "./types.ts";
+import { computed, onBeforeUnmount, onMounted, provide, shallowRef, toValue, useTemplateRef } from "vue";
+import { toolbarScrollableKey } from "./toolbarContext";
+import { useToolbarCore } from "./useToolbarCore";
+import ToolItem from "./ToolItem.vue";
+
+const resolveToolParams = (tool: ToolbarTool) =>
+  tool.params !== undefined ? (toValue(tool.params) as Record<string, unknown>) : undefined;
+
+defineOptions({ name: "FabricToolbar" });
+const props = withDefaults(defineProps<ToolbarProps>(), {
+  direction: "row",
+  scrollable: false,
+  wrap: false,
+  actionContext: undefined,
+});
+
+const isScrollable = computed(() => props.scrollable);
+provide(toolbarScrollableKey, isScrollable);
+const emit = defineEmits<ToolbarEmits>();
+const toolbarRoot = useTemplateRef<HTMLElement>("toolbarRoot");
+const openMenuKey = shallowRef<string | undefined>(undefined);
+
+const {
+  actionContext,
+  leftTools,
+  rightTools,
+  runClickAction,
+  runToggleAction,
+  runSelectAction,
+  isToolPending,
+} = useToolbarCore({
+  tools: () => props.tools,
+  actionContext: () => props.actionContext,
+  beforeAction: props.beforeAction,
+  afterAction: props.afterAction,
+  onError: props.onError,
+});
+
+const rootClasses = computed(() => {
+  const list = [`fabric-toolbar--${props.direction}`];
+  if (props.scrollable) list.push("fabric-toolbar--scrollable");
+  if (props.wrap) list.push("fabric-toolbar--wrap");
+  return list;
+});
+const groupClasses = computed(() => [`fabric-toolbar-group--${props.direction}`]);
+const toolGroups = computed(() =>
+  [
+    { id: "left", tools: leftTools.value },
+    { id: "right", tools: rightTools.value },
+  ].filter((group) => group.tools.length > 0),
+);
+
+const onToolError = (payload: ToolbarErrorPayload) => emit("tool-error", payload);
+const onToolClick = (payload: ToolbarClickPayload) => emit("tool-click", runClickAction(payload, onToolError));
+const onToolToggle = (payload: ToolbarTogglePayload) => emit("tool-toggle", runToggleAction(payload, onToolError));
+const onToolSelect = (payload: ToolbarSelectPayload) => emit("tool-select", runSelectAction(payload, onToolError));
+
+const isSelectTool = (tool: ToolbarTool): tool is ToolbarSelectTool =>
+  tool.type === "menu-button" || tool.type === "dropdown-button" || tool.type === "radio-group";
+
+/** 与 ToolItem 中用于「当前选中」的解析对齐(不含本地 state,仅受控 / default) */
+const resolveSelectedForTrigger = (tool: ToolbarSelectTool): string | undefined => {
+  if (tool.selectedKey !== undefined) return toValue(tool.selectedKey);
+  if (tool.defaultKey) return tool.defaultKey;
+  if (typeof tool.defaultIndex === "number") return tool.options[tool.defaultIndex]?.key;
+  return undefined;
+};
+
+const emitSelectLikePayload = (tool: ToolbarSelectTool, optionKey: string) => {
+  const oldValue = resolveSelectedForTrigger(tool);
+  const allowClear = Boolean(tool.allowClear);
+  const value = allowClear && oldValue === optionKey ? undefined : optionKey;
+  onToolSelect({
+    tool,
+    key: tool.key,
+    context: actionContext.value,
+    value,
+    oldValue,
+    params: resolveToolParams(tool),
+  });
+};
+
+/**
+ * 按 `tool.key` 走与主按钮 / 主区点击相近的管道(emit + action 钩子)。
+ * 选择类未受控时子项本地选中态可能不同步,建议 `selectedKey` 受控后再用。
+ */
+const emitToolByKey = (toolKey: string): boolean => {
+  const tool = props.tools.find((t) => t.key === toolKey);
+  if (!tool) return false;
+  const ctx = actionContext.value;
+
+  if (tool.type === "button") {
+    onToolClick({ tool, key: tool.key, context: ctx, params: resolveToolParams(tool) });
+    return true;
+  }
+  if (tool.type === "toggle") {
+    const oldValue = tool.active !== undefined ? Boolean(toValue(tool.active)) : false;
+    onToolToggle({
+      tool,
+      key: tool.key,
+      context: ctx,
+      value: !oldValue,
+      oldValue,
+      params: resolveToolParams(tool),
+    });
+    return true;
+  }
+  if (!isSelectTool(tool)) return false;
+
+  if (tool.type === "radio-group") {
+    const optKey = tool.defaultKey ?? tool.options[0]?.key;
+    if (!optKey) return false;
+    emitSelectLikePayload(tool, optKey);
+    return true;
+  }
+
+  const current = resolveSelectedForTrigger(tool);
+  if (current !== undefined && current !== "") {
+    emitSelectLikePayload(tool, current);
+    return true;
+  }
+  return false;
+};
+
+const onMenuOpenChange = ({ key, open }: ToolbarMenuOpenChangePayload) => {
+  openMenuKey.value = open ? key : openMenuKey.value === key ? undefined : openMenuKey.value;
+};
+
+const onDocumentPointerDown = (event: MouseEvent | TouchEvent) => {
+  if (!openMenuKey.value) return;
+  const target = event.target as Node | null;
+  if (!target) return;
+  if (toolbarRoot.value?.contains(target)) return;
+  // scrollable 时下拉挂在 body,需排除传送后的菜单区域
+  if (target instanceof Element && target.closest("[data-fabric-toolbar-menu-portal]")) return;
+  openMenuKey.value = undefined;
+};
+
+onMounted(() => {
+  document.addEventListener("mousedown", onDocumentPointerDown);
+  document.addEventListener("touchstart", onDocumentPointerDown);
+});
+onBeforeUnmount(() => {
+  document.removeEventListener("mousedown", onDocumentPointerDown);
+  document.removeEventListener("touchstart", onDocumentPointerDown);
+});
+
+defineExpose({
+  openMenuKey,
+  emitToolByKey,
+});
+</script>
+
+<template>
+  <div
+    ref="toolbarRoot"
+    class="fabric-toolbar"
+    :class="rootClasses"
+  >
+    <div class="fabric-toolbar__track">
+      <div
+        v-for="group in toolGroups"
+        :key="group.id"
+        class="fabric-toolbar-group"
+        :class="[groupClasses, `fabric-toolbar-group--${group.id}`]"
+      >
+        <template v-for="tool in group.tools" :key="tool.key">
+          <slot
+            v-if="tool.slot"
+            :name="tool.slot"
+            :tool="tool"
+            :loading="isToolPending(tool.key)"
+            :context="actionContext"
+          >
+            <ToolItem
+              :tool="tool"
+              :runtime-loading="isToolPending(tool.key)"
+              :action-context="actionContext"
+              :menu-open="openMenuKey === tool.key"
+              @tool-click="onToolClick"
+              @tool-toggle="onToolToggle"
+              @tool-select="onToolSelect"
+              @menu-open-change="onMenuOpenChange"
+            />
+          </slot>
+
+          <ToolItem
+            v-else
+            :tool="tool"
+            :runtime-loading="isToolPending(tool.key)"
+            :action-context="actionContext"
+            :menu-open="openMenuKey === tool.key"
+            @tool-click="onToolClick"
+            @tool-toggle="onToolToggle"
+            @tool-select="onToolSelect"
+            @menu-open-change="onMenuOpenChange"
+          />
+        </template>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style src="./toolbar-variables.css"></style>
+
+<style scoped>
+.fabric-toolbar {
+  display: flex;
+  align-items: center;
+  gap: var(--toolbar-root-gap);
+  width: 100%;
+  max-width: var(--toolbar-max-width, 100%);
+  box-sizing: border-box;
+  overflow: visible;
+  padding: var(--toolbar-root-padding-y) var(--toolbar-root-padding-x);
+  border-radius: var(--toolbar-root-radius);
+  border-bottom: var(--toolbar-control-border-width) solid var(--toolbar-color-surface-border);
+  background: var(--toolbar-color-surface);
+  color: var(--toolbar-color-text);
+}
+
+/* 默认:轨道不参与盒模型,分组仍是 .fabric-toolbar 的直接 flex 子项 */
+.fabric-toolbar__track {
+  display: contents;
+}
+
+.fabric-toolbar--row {
+  flex-wrap: nowrap;
+  min-width: 0;
+}
+
+/* 横向滚动:单轨包住全部分组,下拉需配合子组件 Teleport */
+.fabric-toolbar--row.fabric-toolbar--scrollable {
+  flex-wrap: nowrap;
+}
+
+.fabric-toolbar--row.fabric-toolbar--scrollable .fabric-toolbar__track {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: var(--toolbar-root-gap);
+  flex: 1 1 auto;
+  min-width: 0;
+  max-width: 100%;
+  overflow-x: auto;
+  overflow-y: hidden;
+  -webkit-overflow-scrolling: touch;
+}
+
+.fabric-toolbar--row.fabric-toolbar--wrap .fabric-toolbar__track {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: var(--toolbar-root-gap);
+  min-width: 0;
+  max-width: 100%;
+}
+
+.fabric-toolbar--row.fabric-toolbar--scrollable.fabric-toolbar--wrap .fabric-toolbar__track {
+  flex-wrap: wrap;
+  overflow-x: auto;
+}
+
+.fabric-toolbar--column {
+  flex-direction: column;
+  align-items: flex-start;
+}
+
+.fabric-toolbar-group {
+  display: flex;
+  align-items: center;
+  gap: var(--toolbar-group-gap);
+  overflow: visible;
+  flex-shrink: 0;
+}
+
+.fabric-toolbar-group--row {
+  flex-direction: row;
+}
+
+.fabric-toolbar-group--column {
+  flex-direction: column;
+  align-items: flex-start;
+}
+
+.fabric-toolbar-group--right.fabric-toolbar-group--row {
+  margin-left: auto;
+}
+</style>

+ 5 - 0
src/lib/fabric/components/toolbar/index.ts

@@ -0,0 +1,5 @@
+export {default as Toolbar} from "./Toolbar.vue";
+export {default as ToolRender} from "./ToolRender.ts";
+
+export type * from "./types.ts";
+export type { ToolbarRenderFn } from "./ToolRender.ts";

+ 81 - 0
src/lib/fabric/components/toolbar/toolbar-variables.css

@@ -0,0 +1,81 @@
+/**
+ * fabric-toolbar:主题与尺寸变量,挂在 `.fabric-toolbar` 上供子树继承。
+ * 由 Toolbar.vue 引入;覆盖主题时可在外层写 `.fabric-toolbar { --toolbar-...: ... }`。
+ */
+.fabric-toolbar {
+  /* 限制最大宽度(例如视口):配合外层 min-width:0 可避免 flex 子项撑出父级 */
+  --toolbar-max-width: 100%;
+  /* 根布局 */
+  --toolbar-root-gap: 8px;
+  --toolbar-root-padding-y: 6px;
+  --toolbar-root-padding-x: 8px;
+  --toolbar-root-radius: 8px;
+  --toolbar-group-gap: 8px;
+  /* 文字与控件内边距 */
+  --toolbar-font-size: 14px;
+  --toolbar-padding-y: 2px;
+  --toolbar-padding-x: 8px;
+  /* 控件尺寸 / 圆角 / 过渡 */
+  --toolbar-control-gap: 6px;
+  --toolbar-control-min-height: 30px;
+  --toolbar-control-border-width: 1px;
+  --toolbar-control-radius-pill: 8px;
+  --toolbar-transition-control: background-color 0.12s ease, border-color 0.12s ease;
+  /* 焦点与禁用 */
+  --toolbar-focus-ring-width: 2px;
+  --toolbar-focus-ring-offset: 1px;
+  --toolbar-disabled-opacity: 0.5;
+  /* 加载指示器 */
+  --toolbar-spinner-size: 12px;
+  --toolbar-spinner-border-width: 2px;
+  --toolbar-spinner-duration: 0.8s;
+  /* 分割下拉箭头区 */
+  --toolbar-split-arrow-min-width: 34px;
+  --toolbar-split-arrow-padding-x: 8px;
+  /* 下拉旁 chevron */
+  --toolbar-caret-size: 18px;
+  --toolbar-menu-caret-margin-left: 2px;
+  --toolbar-menu-caret-opacity: 0.85;
+  --toolbar-split-caret-opacity: 0.9;
+  /* 下拉面板 */
+  --toolbar-dropdown-offset-y: 6px;
+  --toolbar-dropdown-z-index: 5000;
+  --toolbar-dropdown-min-width: 180px;
+  --toolbar-dropdown-radius: 8px;
+  --toolbar-dropdown-padding: 4px;
+  --toolbar-dropdown-list-max-height: 288px;
+  --toolbar-dropdown-item-gap: 6px;
+  --toolbar-dropdown-item-radius: 6px;
+  --toolbar-dropdown-item-padding-y: 6px;
+  --toolbar-dropdown-item-padding-x: 8px;
+  /* 辅助文案 */
+  --toolbar-empty-font-size: 12px;
+  /* 菜单显隐过渡 */
+  --toolbar-menu-transition-duration: 0.14s;
+  --toolbar-menu-enter-translate-y: 4px;
+  --toolbar-menu-enter-scale: 0.96;
+  /* 图标 + 标签行 */
+  --toolbar-icon-label-gap: 6px;
+  --toolbar-icon-size: 16px;
+  --toolbar-icon-emoji-font-size: 12px;
+  /* 颜色(深色主题) */
+  --toolbar-color-surface: #0f2925;
+  --toolbar-color-surface-border: #1f4d45;
+  --toolbar-color-text: #e8f4f1;
+  --toolbar-color-control-bg: #12342f;
+  --toolbar-color-control-border: #2b5b52;
+  --toolbar-color-split-divider: #2f665b;
+  --toolbar-color-hover-border: #3d7e71;
+  --toolbar-color-hover-bg: #19423a;
+  --toolbar-color-active-border: #5dd5bc;
+  --toolbar-color-active-bg: #205047;
+  --toolbar-color-active-text: #d6fff5;
+  --toolbar-color-focus-ring: #67d8c0;
+  --toolbar-color-spinner-track: #7aa49b;
+  --toolbar-color-spinner-head: #d6fff5;
+  --toolbar-color-menu-hover-bg: #1d4b42;
+  --toolbar-color-menu-selected-bg: #205047;
+  --toolbar-color-menu-selected-text: #d6fff5;
+  --toolbar-color-muted: #9ec5bc;
+  --toolbar-shadow-dropdown: 0 8px 20px rgba(0, 0, 0, 0.2);
+}

+ 4 - 0
src/lib/fabric/components/toolbar/toolbarContext.ts

@@ -0,0 +1,4 @@
+import type { ComputedRef, InjectionKey } from "vue";
+
+/** 为 true 时,下拉菜单 Teleport 到 body + fixed 定位,避免横向滚动容器裁切 */
+export const toolbarScrollableKey: InjectionKey<ComputedRef<boolean>> = Symbol("fabricToolbarScrollable");

+ 145 - 0
src/lib/fabric/components/toolbar/types.ts

@@ -0,0 +1,145 @@
+import type {FabricInstance} from "@/lib/fabric";
+import type { MaybeRef, MaybeRefOrGetter, StyleValue, VNodeChild } from "vue";
+
+export type MaybeReactiveValue<T> = MaybeRefOrGetter<T>;
+export type MaybeArray<T> = T | T[];
+export type MaybePromise<T> = T | Promise<T>;
+export type ToolSide = "left" | "right";
+
+export interface ToolbarActionContext {
+  canvas?: MaybeRefOrGetter<FabricInstance | null>;
+  [key: string]: unknown;
+}
+
+export interface ToolbarProps {
+  tools: ToolbarTool[];
+  direction?: "row" | "column";
+  /**
+   * 行向时允许横向滚动,总宽度可超过父级而不撑破页面布局(下拉菜单会 Teleport,避免被 overflow 裁切)。
+   */
+  scrollable?: boolean;
+  /**
+   * 行向时允许换行(多行工具条)。与 scrollable 可同时开启。
+   */
+  wrap?: boolean;
+  actionContext?: ToolbarActionContext;
+  beforeAction?: ToolbarAction<ToolbarActionPayload>;
+  afterAction?: ToolbarAction<ToolbarActionPayload>;
+  onError?: (payload: ToolbarErrorPayload) => MaybePromise<void>;
+}
+
+export interface ToolbarEmits {
+  (event: "tool-click", payload: ToolbarClickPayload): void;
+  (event: "tool-toggle", payload: ToolbarTogglePayload): void;
+  (event: "tool-select", payload: ToolbarSelectPayload): void;
+  (event: "tool-error", payload: ToolbarErrorPayload): void;
+}
+
+/** ToolItem → Toolbar:菜单展开状态变化 */
+export interface ToolbarMenuOpenChangePayload {
+  key: string;
+  open: boolean;
+}
+
+export type ToolbarActionPayload = ToolbarClickPayload | ToolbarTogglePayload | ToolbarSelectPayload;
+export type ToolbarAction<P extends ToolbarActionPayload> = (payload: P) => MaybePromise<void>;
+type ToolbarError = (payload: ToolbarErrorPayload) => MaybePromise<void>;
+
+interface Tool {
+  key: string;
+  label?: MaybeReactiveValue<string>;
+  icon?: MaybeReactiveValue<string>;
+  disabled?: MaybeReactiveValue<boolean>;
+  loading?: MaybeReactiveValue<boolean>;
+  title?: string;
+  class?: MaybeArray<string | Record<string, boolean>>;
+  style?: StyleValue;
+  /** 可选参数(支持 ref / getter),随 tool 相关事件的 payload 一并传出(已用 toValue 解包)。 */
+  params?: MaybeReactiveValue<Record<string, unknown>>;
+}
+
+export interface ToolOption extends Tool {
+  label?: string;
+}
+
+interface ToolbarItem<P extends ToolbarActionPayload> extends Tool {
+  side?: ToolSide;
+  action?: ToolbarAction<P>;
+  beforeAction?: ToolbarAction<P>;
+  afterAction?: ToolbarAction<P>;
+  onError?: ToolbarError;
+  slot?: string;
+  render?: (tool: ToolbarTool, trigger: (key: string) => MaybePromise<void>, context?: ToolbarActionContext) => VNodeChild;
+}
+
+export interface ToolbarButtonTool extends ToolbarItem<ToolbarClickPayload> {
+  type: "button";
+}
+
+export interface ToolbarToggleTool extends ToolbarItem<ToolbarTogglePayload> {
+  type: "toggle";
+  active?: MaybeReactiveValue<boolean>;
+  activeIcon?: MaybeReactiveValue<string>;
+}
+
+export interface ToolbarSelectTool extends ToolbarItem<ToolbarSelectPayload> {
+  type: "menu-button" | "dropdown-button" | "radio-group";
+  options: ToolOption[];
+  selectedKey?: MaybeRef<string | undefined>;
+  defaultKey?: string;
+  defaultIndex?: number;
+  /**
+   * 是否允许再次点击同一项时取消选择,默认 false。
+   * 对 menu-button / dropdown-button / radio-group 都生效。
+   */
+  allowClear?: boolean;
+  /** 仅 dropdown-button 生效:是否隐藏已选项。 */
+  hideSelected?: boolean;
+}
+
+export type ToolbarTool = ToolbarButtonTool | ToolbarToggleTool | ToolbarSelectTool;
+
+export interface ToolbarClickPayload {
+  tool: ToolbarButtonTool;
+  key: string;
+  context?: ToolbarActionContext;
+  params?: Record<string, unknown>;
+}
+
+export interface ToolbarTogglePayload {
+  tool: ToolbarToggleTool;
+  key: string;
+  context?: ToolbarActionContext;
+  value: boolean;
+  oldValue: boolean;
+  params?: Record<string, unknown>;
+}
+
+export interface ToolbarSelectPayload {
+  tool: ToolbarSelectTool;
+  key: string;
+  context?: ToolbarActionContext;
+  value: string | undefined;
+  oldValue: string | undefined;
+  params?: Record<string, unknown>;
+}
+
+export interface ToolbarErrorPayload {
+  tool: ToolbarTool;
+  key: string;
+  context?: ToolbarActionContext;
+  message?: string;
+  params?: Record<string, unknown>;
+}
+
+export type ToolbarEmitter = {
+    "tool-click"?: (payload: ToolbarClickPayload) => void;
+    "tool-toggle"?: (payload: ToolbarTogglePayload) => void;
+    "tool-select"?: (payload: ToolbarSelectPayload) => void;
+    "tool-error"?: (payload: ToolbarErrorPayload) => void;
+};
+
+/** 包装器 `expose` 与内层 `Toolbar` 对齐 */
+export type ToolbarExposed = {
+    emitToolByKey: (toolKey: string) => boolean;
+};

+ 124 - 0
src/lib/fabric/components/toolbar/useToolbarCore.ts

@@ -0,0 +1,124 @@
+import {mapValues} from "es-toolkit";
+import {computed, shallowRef, toValue} from "vue";
+import type {MaybeRefOrGetter} from "vue";
+import type {
+  ToolbarActionContext,
+  ToolbarActionPayload,
+  ToolbarClickPayload,
+  ToolbarErrorPayload,
+  ToolbarProps,
+  ToolbarSelectPayload,
+  ToolbarTogglePayload,
+  ToolbarTool,
+} from "./types.ts";
+
+export interface UseToolbarCoreOptions {
+  tools: MaybeRefOrGetter<ToolbarProps["tools"]>;
+  actionContext?: MaybeRefOrGetter<ToolbarProps["actionContext"]>;
+  beforeAction?: ToolbarProps["beforeAction"];
+  afterAction?: ToolbarProps["afterAction"];
+  onError?: ToolbarProps["onError"];
+}
+
+const isPromise = (value: unknown): value is Promise<void> =>
+  Boolean(value && typeof (value as Promise<void>).then === "function");
+
+export function useToolbarCore(options: UseToolbarCoreOptions) {
+  const tools = computed(() => toValue(options.tools) ?? []);
+  const actionContext = computed(() => toValue(options.actionContext));
+  const pendingActionCount = shallowRef<Record<string, number>>({});
+
+  const leftTools = computed(() => tools.value.filter((tool) => tool.side !== "right"));
+  const rightTools = computed(() => tools.value.filter((tool) => tool.side === "right"));
+
+  const increasePending = (toolKey: string) => {
+    const current = pendingActionCount.value[toolKey] ?? 0;
+    pendingActionCount.value = {...pendingActionCount.value, [toolKey]: current + 1};
+  };
+
+  const decreasePending = (toolKey: string) => {
+    const current = pendingActionCount.value[toolKey] ?? 0;
+    pendingActionCount.value = {...pendingActionCount.value, [toolKey]: Math.max(0, current - 1)};
+  };
+
+  const normalizeError = (tool: ToolbarTool, key: string, error: unknown): ToolbarErrorPayload => ({
+    tool,
+    key,
+    context: actionContext.value,
+    message: error instanceof Error ? error.message : String(error),
+    ...(tool.params !== undefined
+      ? {params: toValue(tool.params) as Record<string, unknown>}
+      : {}),
+  });
+
+  const runHook = async (
+    hook: ((payload: ToolbarActionPayload) => void | Promise<void>) | undefined,
+    payload: ToolbarActionPayload,
+  ) => {
+    if (!hook) return;
+    await hook(payload);
+  };
+
+  const runActionWithPipeline = <TPayload extends ToolbarActionPayload>(
+    payload: TPayload,
+    emitError: (errorPayload: ToolbarErrorPayload) => void,
+  ) => {
+    const withContext = {
+      ...payload,
+      context: mapValues(actionContext.value ?? {}, (value) => toValue(value))
+    } as TPayload;
+    const execute = async () => {
+      await runHook(options.beforeAction, withContext);
+      await (withContext.tool.beforeAction as ((args: TPayload) => void | Promise<void>) | undefined)?.(
+        withContext,
+      );
+
+      const result = (withContext.tool.action as ((args: TPayload) => void | Promise<void>) | undefined)?.(
+        withContext,
+      );
+      if (isPromise(result)) {
+        increasePending(withContext.key);
+        try {
+          await result;
+        } finally {
+          decreasePending(withContext.key);
+        }
+      }
+
+      await (withContext.tool.afterAction as ((args: TPayload) => void | Promise<void>) | undefined)?.(
+        withContext,
+      );
+      await runHook(options.afterAction, withContext);
+    };
+
+    void execute().catch(async (error) => {
+      const errorPayload = normalizeError(withContext.tool, withContext.key, error);
+      emitError(errorPayload);
+      await withContext.tool.onError?.(errorPayload);
+      await options.onError?.(errorPayload);
+    });
+
+    return withContext;
+  };
+
+  const runClickAction = (payload: ToolbarClickPayload, emitError: (errorPayload: ToolbarErrorPayload) => void) =>
+    runActionWithPipeline(payload, emitError);
+
+  const runToggleAction = (payload: ToolbarTogglePayload, emitError: (errorPayload: ToolbarErrorPayload) => void) =>
+    runActionWithPipeline(payload, emitError);
+
+  const runSelectAction = (payload: ToolbarSelectPayload, emitError: (errorPayload: ToolbarErrorPayload) => void) =>
+    runActionWithPipeline(payload, emitError);
+
+  const isToolPending = (toolKey: string) => (pendingActionCount.value[toolKey] ?? 0) > 0;
+
+  return {
+    actionContext,
+    isToolPending,
+    leftTools,
+    rightTools,
+    runClickAction,
+    runToggleAction,
+    runSelectAction,
+  };
+}

+ 35 - 0
src/lib/fabric/composables/useFabric.ts

@@ -0,0 +1,35 @@
+import type { FabricContext, FabricEmits, FabricInstance, FabricProps } from '@/lib/fabric';
+import type { WrapperOptions, WrapperReturn } from '../utils/wrapper';
+import { getExposed, wrapperProps } from '../utils/wrapper';
+import ___components_Fabric_vue from '../components/Fabric.vue';
+
+export type UseFabricOptions = WrapperOptions<FabricProps, FabricEmits>;
+export type UseFabricReturn = WrapperReturn<FabricContext>;
+
+type FabricExposed = {
+  /** 当前已就绪实例;未就绪或已销毁时为 null */
+  canvas: FabricInstance | null;
+};
+
+export function useFabric(options?: UseFabricOptions): UseFabricReturn {
+  const fabricRef = shallowRef<ComponentPublicInstance<FabricExposed> | null>(null);
+
+  const context: FabricContext = {
+    canvas: () => getExposed<FabricExposed>(fabricRef)?.canvas ?? null,
+  };
+
+  const Wrapper = markRaw(
+    defineComponent({
+      name: 'FabricWrapper',
+      inheritAttrs: false,
+      setup(_, { attrs, slots, expose }) {
+        onBeforeUnmount(() => (fabricRef.value = null));
+        expose(context);
+        const props = wrapperProps<FabricProps>(fabricRef, attrs, options);
+        return () => h(___components_Fabric_vue, props, slots);
+      },
+    })
+  );
+
+  return [Wrapper, context] as const;
+}

+ 110 - 0
src/lib/fabric/composables/useFabricEventListener.ts

@@ -0,0 +1,110 @@
+import type { MaybeRefOrGetter, MaybeRef } from 'vue';
+import type { CanvasEvents, FabricObject, ObjectEvents } from 'fabric';
+import type { FabricInstance } from '@/lib/fabric';
+
+import { toValue, unref, watch } from 'vue';
+
+type Arrayable<T> = T | readonly T[];
+type AddFabricEventListenerOptions = Pick<AddEventListenerOptions, 'once' | 'signal'>;
+
+type FabricObservable = FabricInstance | FabricObject;
+
+/** 规避 Canvas / FabricObject 上 `on`/`off`/`once` 联合类型不兼容 */
+type FabricEventEmitter = {
+  on(event: string, handler: (e: unknown) => void): void;
+  off(event: string, handler: (e: unknown) => void): void;
+  once(event: string, handler: (e: unknown) => void): () => void;
+};
+
+const toArray = <T>(v: Arrayable<T>): T[] => (Array.isArray(v) ? [...v] : [v as T]);
+
+function attachFabricListeners(
+  rawTargets: FabricObservable[],
+  rawEvents: string[],
+  rawListeners: Array<(e: unknown) => void>,
+  rawOptions: AddFabricEventListenerOptions | undefined,
+  onCleanup: (fn: () => void) => void
+): void {
+  const once = rawOptions?.once === true;
+  const signal = rawOptions?.signal;
+
+  if (signal?.aborted) return;
+
+  const fabricCleanups: Array<() => void> = [];
+
+  for (const el of rawTargets) {
+    const emitter = el as unknown as FabricEventEmitter;
+    for (const ev of rawEvents) {
+      for (const fn of rawListeners) {
+        if (once) {
+          const dispose = emitter.once(ev, fn);
+          if (typeof dispose === 'function') fabricCleanups.push(dispose);
+        } else {
+          emitter.on(ev, fn);
+          fabricCleanups.push(() => emitter.off(ev, fn));
+        }
+      }
+    }
+  }
+
+  const runFabricCleanups = () => {
+    for (const d of fabricCleanups) d();
+  };
+
+  if (signal) {
+    const onAbort = () => {
+      runFabricCleanups();
+      signal.removeEventListener('abort', onAbort);
+    };
+    signal.addEventListener('abort', onAbort);
+    onCleanup(() => {
+      runFabricCleanups();
+      signal.removeEventListener('abort', onAbort);
+    });
+  } else {
+    onCleanup(runFabricCleanups);
+  }
+}
+
+/**
+ * 在 Fabric Canvas 上注册 {@link CanvasEvents},随依赖与组件生命周期自动卸载。
+ */
+export function useFabricEventListener<E extends keyof CanvasEvents>(
+  target: MaybeRefOrGetter<Arrayable<FabricInstance | null>>,
+  event: MaybeRefOrGetter<Arrayable<E>>,
+  listener: MaybeRef<Arrayable<(ev: CanvasEvents[E]) => void>>,
+  options?: MaybeRefOrGetter<AddFabricEventListenerOptions | undefined>
+): () => void;
+
+/**
+ * 在 FabricObject 上注册 {@link ObjectEvents},随依赖与组件生命周期自动卸载。
+ */
+export function useFabricEventListener<E extends keyof ObjectEvents>(
+  target: MaybeRefOrGetter<Arrayable<FabricObject | null>>,
+  event: MaybeRefOrGetter<Arrayable<E>>,
+  listener: MaybeRef<Arrayable<(ev: ObjectEvents[E]) => void>>,
+  options?: MaybeRefOrGetter<AddFabricEventListenerOptions | undefined>
+): () => void;
+
+export function useFabricEventListener(
+  target: MaybeRefOrGetter<Arrayable<FabricInstance | FabricObject | null>>,
+  event: MaybeRefOrGetter<Arrayable<string>>,
+  listener: MaybeRef<Arrayable<(ev: unknown) => void>>,
+  options?: MaybeRefOrGetter<AddFabricEventListenerOptions | undefined>
+): () => void {
+  return watch(
+    () =>
+      [
+        toArray(toValue(target) as Arrayable<FabricInstance | FabricObject | null | undefined>).filter((e): e is FabricObservable => e != null),
+        toArray(toValue(event) as Arrayable<string>),
+        toArray(unref(listener) as Arrayable<(e: unknown) => void>),
+        toValue(options),
+      ] as const,
+    ([rawTargets, rawEvents, rawListeners, rawOptions], _prev, onCleanup) => {
+      if (!rawTargets.length || !rawEvents.length || !rawListeners.length) return;
+
+      attachFabricListeners(rawTargets, rawEvents, rawListeners, rawOptions, onCleanup);
+    },
+    { flush: 'post', immediate: true }
+  );
+}

+ 60 - 0
src/lib/fabric/composables/useImage.ts

@@ -0,0 +1,60 @@
+import type { MaybeRefOrGetter } from 'vue';
+import type { FabricObject, FabricObjectProps, TOptions } from 'fabric';
+import type { FabricContext } from '@/lib/fabric';
+
+import { toValue } from 'vue';
+import { FabricImage } from 'fabric';
+import { mergeObjectOptions } from '@/lib/fabric';
+
+export interface ImageContext extends FabricContext {
+  url: MaybeRefOrGetter<string>;
+}
+
+export function useImage(context: ImageContext, options?: TOptions<FabricObjectProps>) {
+  const canvas = computed(() => toValue(context.canvas));
+  const url = computed(() => toValue(context.url));
+  const object = shallowRef<FabricImage | null>(null);
+
+  watch(
+    [canvas, url],
+    async ([canvas, url], oldValue, onCleanup) => {
+      if (!canvas || (canvas === oldValue[0] && url === oldValue[1])) return;
+      if (!url) return canvas.requestRenderAll();
+
+      const controller = new AbortController();
+      try {
+        const image = await loadImageObject(url, controller.signal, options);
+        if (controller.signal.aborted) return;
+
+        if (isAdded(object.value)) object.value.canvas?.remove(object.value);
+
+        object.value = image;
+        await nextTick();
+        canvas.add(object.value);
+      } finally {
+        canvas.requestRenderAll();
+      }
+
+      onCleanup(() => {
+        controller.abort();
+        object.value?.off();
+      });
+    },
+    { flush: 'post', immediate: true }
+  );
+
+  return object;
+}
+
+async function loadImageObject(url: string, signal: AbortSignal, options?: TOptions<FabricObjectProps>) {
+  const props = mergeObjectOptions({ lockScaleUniform: true, ...options });
+  try {
+    return await FabricImage.fromURL(url, { signal, crossOrigin: 'anonymous' }, props);
+  } catch {
+    return await FabricImage.fromURL(url, { signal }, props);
+  }
+}
+
+function isAdded<T extends FabricObject>(object: T | null): object is T {
+  return object != null && object.canvas != void 0;
+}

+ 52 - 0
src/lib/fabric/composables/useSnapshot.ts

@@ -0,0 +1,52 @@
+import type { MaybeRefOrGetter } from 'vue';
+import type { FabricObject } from 'fabric';
+import { type FabricContext, useFabricEventListener } from '@/lib/fabric';
+import { util } from 'fabric';
+
+const { saveObjectTransform } = util;
+export type ObjectTransformSnapshot = ReturnType<typeof saveObjectTransform>;
+
+export function useSnapshot(context: FabricContext, monitor?: MaybeRefOrGetter<FabricObject | null>[]) {
+  const active = shallowRef<FabricObject | null>(null);
+  const transform = shallowRef<ObjectTransformSnapshot>();
+
+  const start = (target: FabricObject) => {
+    active.value = target;
+    transform.value = saveObjectTransform(target);
+  };
+
+  const end = () => {
+    active.value = null;
+    transform.value = void 0;
+  };
+
+  /** `commit`:校验通过并已刷新快照;`rollback`:已恢复到上一快照;`skip`:无活动快照或未跟踪该 target */
+  const snapshot = (target: FabricObject, check: () => boolean): 'commit' | 'rollback' | 'skip' => {
+    if (target !== active.value || !transform.value) return 'skip';
+    if (check()) {
+      transform.value = saveObjectTransform(target);
+      return 'commit';
+    }
+    target.set(transform.value);
+    target.setCoords();
+    target.dirty = true;
+    toValue(context.canvas)?.requestRenderAll();
+    return 'rollback';
+  };
+
+  if (monitor?.length) {
+    const target = () => monitor.map((target) => toValue(target));
+    useFabricEventListener(target, 'mousedown', (event) => {
+      if (event.target) start(event.target);
+      else end();
+    });
+    useFabricEventListener(target, 'mouseup', end);
+    useFabricEventListener(context.canvas, 'before:transform', (event) => {
+      const transformTarget = event.transform?.target;
+      if (!transformTarget) return;
+      if (target().includes(transformTarget)) start(transformTarget);
+    });
+  }
+
+  return { start, end, snapshot };
+}

+ 27 - 0
src/lib/fabric/composables/useToolbar.ts

@@ -0,0 +1,27 @@
+import type { ToolbarProps } from '../components/toolbar';
+import type { WrapperOptions, WrapperReturn } from '../utils/wrapper';
+import { getExposed, wrapperProps } from '../utils/wrapper';
+import ___components_Toolbar_vue from '../components/toolbar/Toolbar.vue';
+
+export type UseToolbarOptions = WrapperOptions<ToolbarProps>;
+
+export function useToolbar(options?: UseToolbarOptions) {
+  const toolbarRef = shallowRef<ComponentPublicInstance | null>(null);
+
+  const context = {};
+
+  const Wrapper = markRaw(
+    defineComponent({
+      name: 'ToolbarWrapper',
+      inheritAttrs: false,
+      setup(_, { attrs, slots, expose }) {
+        onBeforeUnmount(() => (toolbarRef.value = null));
+        expose(context);
+        const props = wrapperProps<ToolbarProps>(toolbarRef, attrs, options);
+        return () => h(___components_Toolbar_vue, props, slots);
+      },
+    })
+  );
+
+  return [Wrapper, context];
+}

+ 5 - 0
src/lib/fabric/core/Canvas.ts

@@ -0,0 +1,5 @@
+import type { CanvasOptions, TOptions } from 'fabric';
+
+export {Canvas as FabricCanvas} from 'fabric';
+export type FabricCanvasOptions = Omit<CanvasOptions, 'stopContextMenu' | 'fireRightClick' | 'fireMiddleClick' | 'containerClass'>
+

+ 47 - 0
src/lib/fabric/core/constant.ts

@@ -0,0 +1,47 @@
+import type { FabricObjectProps, TOptions } from 'fabric';
+import type { FabricCanvasOptions } from '@/lib/fabric/core/Canvas';
+
+export const defaultCanvasOptions: TOptions<FabricCanvasOptions> = {
+  uniformScaling: true,
+  uniScaleKey: 'shiftKey',
+  centeredScaling: false,
+  centeredRotation: false,
+  centeredKey: 'altKey',
+  altActionKey: 'shiftKey',
+
+  selection: true,
+  selectionKey: 'shiftKey',
+  selectionColor: 'rgba(100, 100, 255, 0.3)',
+  selectionDashArray: [],
+  selectionBorderColor: 'rgba(255, 255, 255, 0.3)',
+  selectionLineWidth: 1,
+  selectionFullyContained: false,
+
+  hoverCursor: 'move',
+  moveCursor: 'move',
+  defaultCursor: 'default',
+  freeDrawingCursor: 'crosshair',
+  notAllowedCursor: 'not-allowed',
+
+  perPixelTargetFind: false,
+  targetFindTolerance: 0,
+  skipTargetFind: false,
+
+  enablePointerEvents: true,
+  preserveObjectStacking: true,
+
+  enableRetinaScaling: true,
+};
+
+export const defaultObjectOptions: TOptions<FabricObjectProps> = {
+  lockScaleUniform: false,
+  lockScalingFlip: true,
+  strokeUniform: true,
+
+  minScaleLimit: 0.1,
+  maxScaleLimit: 10,
+};
+
+export function mergeObjectOptions(options?: TOptions<FabricObjectProps>) {
+  return { ...defaultObjectOptions, ...options };
+}

+ 54 - 0
src/lib/fabric/core/context.ts

@@ -0,0 +1,54 @@
+import { type MaybeRefOrGetter, toValue } from 'vue';
+
+type MRO = MaybeRefOrGetter<unknown>;
+type MaybeRefOrGetterRecord<C> = Record<keyof C, MRO>;
+type MaybeRefOrGetterTuple = readonly MRO[];
+
+export type UnwrapMaybeRefOrGetterRecord<C extends MaybeRefOrGetterRecord<C>> = {
+  [K in keyof C]: C[K] extends MaybeRefOrGetter<infer U> ? U : C[K];
+};
+export type UnwrapMaybeRefOrGetterTuple<T extends MaybeRefOrGetterTuple> = {
+  [K in keyof T]: T[K] extends MaybeRefOrGetter<infer U> ? U : never;
+};
+
+export function unwrapContext<const C extends MaybeRefOrGetterRecord<C>>(context: C): UnwrapMaybeRefOrGetterRecord<C> {
+  const keys = Object.keys(context) as (keyof C)[];
+  return keys.reduce((acc, key) => {
+    acc[key] = toValue(context[key] as MRO) as UnwrapMaybeRefOrGetterRecord<C>[typeof key];
+    return acc;
+  }, {} as UnwrapMaybeRefOrGetterRecord<C>);
+}
+
+export function withContext<C extends MaybeRefOrGetterRecord<C>>(context: C): C;
+export function withContext<C extends MaybeRefOrGetterRecord<C>, T extends MaybeRefOrGetterRecord<T>>(context: C, expand: T): C & T;
+export function withContext<C extends MaybeRefOrGetterRecord<C>, T extends MaybeRefOrGetterRecord<T>>(context: C, expand?: T): C | (C & T) {
+  return expand === undefined ? context : { ...context, ...expand };
+}
+
+export function bindContext<const C extends MaybeRefOrGetterRecord<C>>(context: C): BindContextSources<C>;
+export function bindContext<const C extends MaybeRefOrGetterRecord<C>, const T extends MaybeRefOrGetterTuple>(context: C, ...sources: T): BindContextRun<C, T>;
+export function bindContext(
+  context: MaybeRefOrGetterRecord<Record<keyof any, MRO>>,
+  ...sources: MaybeRefOrGetterTuple
+): BindContextSources<MaybeRefOrGetterRecord<Record<keyof any, MRO>>> | BindContextRun<MaybeRefOrGetterRecord<Record<keyof any, MRO>>, MaybeRefOrGetterTuple> {
+  return arguments.length <= 1 ? bindSources(context) : boundRun(context, sources as readonly MRO[]);
+}
+
+type BindContextSources<C extends MaybeRefOrGetterRecord<C>> = <const T extends MaybeRefOrGetterTuple>(...sources: T) => BindContextRun<C, T>;
+type BindContextRun<C extends MaybeRefOrGetterRecord<C>, T extends MaybeRefOrGetterTuple> = <R>(
+  fn: (ctx: UnwrapMaybeRefOrGetterRecord<C>, ...values: UnwrapMaybeRefOrGetterTuple<T>) => R
+) => R;
+
+function boundRun<const C extends MaybeRefOrGetterRecord<C>, const T extends MaybeRefOrGetterTuple>(context: C, sources: T): BindContextRun<C, T> {
+  return function run<R>(fn: (ctx: UnwrapMaybeRefOrGetterRecord<C>, ...values: UnwrapMaybeRefOrGetterTuple<T>) => R): R {
+    const resolved = unwrapContext(context);
+    const values = sources.map((s) => toValue(s)) as unknown as [...UnwrapMaybeRefOrGetterTuple<T>];
+    return fn(resolved, ...values);
+  };
+}
+
+function bindSources<const C extends MaybeRefOrGetterRecord<C>>(context: C): BindContextSources<C> {
+  return function collect<const T extends MaybeRefOrGetterTuple>(...sources: T) {
+    return boundRun(context, sources);
+  };
+}

+ 11 - 0
src/lib/fabric/index.ts

@@ -0,0 +1,11 @@
+export { FabricCanvas } from './core/Canvas';
+export { defaultCanvasOptions, defaultObjectOptions, mergeObjectOptions } from './core/constant';
+
+export * from './core/context';
+export * from './composables/useFabric';
+export * from './composables/useFabricEventListener';
+export * from './composables/useToolbar';
+export * from './composables/useImage';
+export * from './composables/useSnapshot';
+
+export type * from './types';

+ 14 - 0
src/lib/fabric/types.ts

@@ -0,0 +1,14 @@
+import type { MaybeRefOrGetter } from 'vue';
+import type { CanvasEvents, FabricObjectProps } from 'fabric';
+import type { FabricCanvas, FabricCanvasOptions } from './core/Canvas';
+import type { UnwrapMaybeRefOrGetterRecord } from './core/context';
+
+export type FabricProps = Partial<FabricCanvasOptions>;
+export type FabricEmits = { [K in keyof CanvasEvents]?: (payload: CanvasEvents[K]) => void };
+export type FabricInstance = InstanceType<typeof FabricCanvas>;
+
+export type FabricContext = {
+  canvas: MaybeRefOrGetter<FabricInstance | null>;
+};
+
+export type UnwrapperContext<C extends FabricContext = FabricContext> = UnwrapMaybeRefOrGetterRecord<C>;

+ 26 - 0
src/lib/fabric/utils/props.ts

@@ -0,0 +1,26 @@
+type PlainObject = Record<string, unknown>;
+
+const toKebabCase = (value: string) => value.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
+
+/**
+ * Merge defaults with runtime props, but only override keys that were explicitly passed by parent.
+ * This avoids Vue boolean prop coercion (`undefined` -> `false`) from accidentally overriding defaults.
+ */
+export const mergeWithExplicitProps = <T extends PlainObject>(
+  defaults: Partial<T>,
+  props: T,
+  providedProps?: PlainObject,
+): T => {
+  const merged = {...defaults} as T;
+  const source = providedProps ?? {};
+
+  for (const key of Object.keys(props) as Array<keyof T>) {
+    const camelKey = String(key);
+    const kebabKey = toKebabCase(camelKey);
+    if (camelKey in source || kebabKey in source) {
+      merged[key] = props[key];
+    }
+  }
+
+  return merged;
+};

+ 35 - 0
src/lib/fabric/utils/wrapper.ts

@@ -0,0 +1,35 @@
+import type { Attrs, VNodeRef, MaybeRefOrGetter, DefineComponent } from 'vue';
+import { mergeProps, toValue } from 'vue';
+import { camelCase, mapKeys, mapValues } from 'es-toolkit';
+
+export interface WrapperOptions<P = unknown, E = unknown> {
+  params?: { [K in keyof P]?: MaybeRefOrGetter<Exclude<P[K], undefined>> };
+  events?: E;
+}
+
+export type WrapperReturn<API> = readonly [DefineComponent<{}, {}, {}, {}, {}, {}, {}, {}>, API];
+
+export function resolveParams(params: object | undefined): Record<string, unknown> {
+  if (!params || typeof params !== 'object') return {};
+  return mapValues(params, (value) => toValue(value));
+}
+
+export function resolveEvents(events: object | undefined): Record<string, unknown> {
+  if (!events || typeof events !== 'object') return {};
+  return mapKeys(events, (value, key: string) => {
+    if (typeof value === 'function' && !key.startsWith('on')) {
+      const camel = camelCase(`on-${key}`);
+      return key.includes(':') ? camel.slice(0, 3) + key.slice(1) : camel;
+    } else {
+      return key;
+    }
+  });
+}
+
+export function wrapperProps<P>(ref: VNodeRef | undefined, attrs: Attrs, options?: WrapperOptions, ...args: any[]) {
+  return mergeProps(resolveParams(options?.params ?? void 0), resolveEvents(options?.events ?? void 0), attrs, ...args, { ref }) as unknown as P;
+}
+
+export function getExposed<T>(ref: VNodeRef | undefined) {
+  return (ref as any).value as T;
+}

+ 47 - 0
src/lib/pdfmake/db.ts

@@ -0,0 +1,47 @@
+const DB_NAME = 'pdfmake-fonts';
+const STORE_NAME = 'fonts';
+const DB_VERSION = 1;
+
+export function openDB(): Promise<IDBDatabase> {
+  return new Promise((resolve, reject) => {
+    const request = indexedDB.open(DB_NAME, DB_VERSION);
+    request.onupgradeneeded = () => request.result.createObjectStore(STORE_NAME);
+    request.onsuccess = () => resolve(request.result);
+    request.onerror = () => reject(request.error);
+  });
+}
+
+export function dbGet(db: IDBDatabase, key: string): Promise<ArrayBuffer | undefined> {
+  return new Promise((resolve, reject) => {
+    const tx = db.transaction(STORE_NAME, 'readonly');
+    const req = tx.objectStore(STORE_NAME).get(key);
+    req.onsuccess = () => resolve(req.result);
+    req.onerror = () => reject(req.error);
+  });
+}
+
+export function dbPut(db: IDBDatabase, key: string, value: ArrayBuffer): Promise<void> {
+  return new Promise((resolve, reject) => {
+    const tx = db.transaction(STORE_NAME, 'readwrite');
+    tx.objectStore(STORE_NAME).put(value, key);
+    tx.oncomplete = () => resolve();
+    tx.onerror = () => reject(tx.error);
+  });
+}
+
+/**
+ * 清理不属于当前版本的缓存条目。
+ * fire-and-forget,不影响主流程。
+ */
+export function evictStale(db: IDBDatabase) {
+  const suffix = `?v=${__APP_VERSION__}`;
+  const tx = db.transaction(STORE_NAME, 'readwrite');
+  const store = tx.objectStore(STORE_NAME);
+  const req = store.openCursor();
+  req.onsuccess = () => {
+    const cursor = req.result;
+    if (!cursor) return;
+    if (typeof cursor.key === 'string' && !cursor.key.endsWith(suffix)) cursor.delete();
+    cursor.continue();
+  };
+}

+ 37 - 0
src/lib/pdfmake/font.ts

@@ -0,0 +1,37 @@
+import { openDB, dbGet, dbPut, evictStale } from './db';
+
+async function loadFont(db: IDBDatabase, url: string): Promise<ArrayBuffer> {
+  const cacheKey = `${url}?v=${__APP_VERSION__}`;
+  try {
+    const cached = await dbGet(db, cacheKey);
+    if (cached) return cached;
+  } catch { /* cache miss, fall through */ }
+
+  const buffer = await fetch(url).then((r) => {
+    if (!r.ok) throw new Error(`Font fetch failed: ${r.status} ${url}`);
+    return r.arrayBuffer();
+  });
+  dbPut(db, cacheKey, buffer).catch(() => {});
+  return buffer;
+}
+
+/**
+ * 预加载字体文件到 pdfmake 的 VFS 中。
+ *
+ * - 首次调用从网络下载并写入 IndexedDB 缓存
+ * - 后续页面访问从 IndexedDB 读取(毫秒级)
+ * - 版本变化时自动清理旧缓存
+ *
+ * @param fonts 字体名到 URL 的映射,如 `{ 'Regular': 'https://…/Regular.otf' }`
+ */
+export async function preloadFonts(fonts: Record<string, string>): Promise<Record<string, ArrayBuffer>> {
+  const db = await openDB();
+  try {
+    const entries = Object.entries(fonts);
+    const buffers = await Promise.all(entries.map(([, url]) => loadFont(db, url)));
+    evictStale(db);
+    return Object.fromEntries(entries.map(([name], i) => [name, buffers[i]]));
+  } finally {
+    db.close();
+  }
+}

+ 45 - 0
src/lib/pdfmake/index.ts

@@ -0,0 +1,45 @@
+import type { PdfMakeStatic } from 'pdfmake/build/pdfmake';
+
+import { getClientURL } from '@/tools';
+import { preloadFonts } from './font';
+
+const FONT_FILES = {
+  'NotoSansSC-Regular.ttf': getClientURL('/fonts/NotoSansSC-Regular.ttf'),
+  'NotoSansSC-Bold.ttf': getClientURL('/fonts/NotoSansSC-Bold.ttf'),
+};
+
+let pending: Promise<PdfMakeStatic> | null = null;
+
+async function init(): Promise<PdfMakeStatic> {
+  const [pdfMakeModule, buffers] = await Promise.all([
+    import('pdfmake/build/pdfmake'),
+    preloadFonts(FONT_FILES),
+  ]);
+
+  const pdfMake = pdfMakeModule.default;
+
+  for (const [filename, buffer] of Object.entries(buffers)) {
+    pdfMake.virtualfs.writeFileSync(filename, buffer);
+  }
+
+  pdfMake.setFonts({
+    NotoSansSC: {
+      normal: 'NotoSansSC-Regular.ttf',
+      bold: 'NotoSansSC-Bold.ttf',
+      italics: 'NotoSansSC-Regular.ttf',
+      bolditalics: 'NotoSansSC-Bold.ttf',
+    },
+  });
+
+  return pdfMake;
+}
+
+/**
+ * 获取已初始化(含中文字体)的 pdfMake 实例。
+ * 多次调用只执行一次初始化。
+ */
+export function loadPdfMake(): Promise<PdfMakeStatic> {
+  return (pending ??= init());
+}
+
+export type { PdfMakeStatic };

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно