瀏覽代碼

fix: add constants and side methods to packages (#10418)

* fix: add constants and side methods to packages

* add transform to the element package

* lint

* remove dead code

* put transform types back to transform.ts

* fix imports

* fix imports in test

---------

Co-authored-by: dwelle <[email protected]>
Ryan Di 1 周之前
父節點
當前提交
e95222ed32

+ 7 - 0
packages/common/src/constants.ts

@@ -108,6 +108,13 @@ export const CLASSES = {
   FRAME_NAME: "frame-name",
 };
 
+export const FONT_SIZES = {
+  sm: 16,
+  md: 20,
+  lg: 28,
+  xl: 36,
+} as const;
+
 export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
 export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
 

+ 0 - 0
packages/excalidraw/data/__snapshots__/transform.test.ts.snap → packages/element/src/__tests__/__snapshots__/transform.test.ts.snap


+ 5 - 4
packages/excalidraw/data/transform.test.ts → packages/element/src/__tests__/transform.test.ts

@@ -1,11 +1,12 @@
 import { pointFrom } from "@excalidraw/math";
 import { vi } from "vitest";
 
-import type { ExcalidrawArrowElement } from "@excalidraw/element/types";
+import {
+  convertToExcalidrawElements,
+  type ExcalidrawElementSkeleton,
+} from "../transform";
 
-import { convertToExcalidrawElements } from "./transform";
-
-import type { ExcalidrawElementSkeleton } from "./transform";
+import type { ExcalidrawArrowElement } from "../types";
 
 const opts = { regenerateIds: false };
 

+ 438 - 1
packages/element/src/binding.ts

@@ -53,13 +53,18 @@ import {
   isBindableElement,
   isBoundToContainer,
   isElbowArrow,
+  isRectangularElement,
   isRectanguloidElement,
   isTextElement,
 } from "./typeChecks";
 
 import { aabbForElement, elementCenterPoint } from "./bounds";
 import { updateElbowArrowPoints } from "./elbowArrow";
-import { projectFixedPointOntoDiagonal } from "./utils";
+import {
+  deconstructDiamondElement,
+  deconstructRectanguloidElement,
+  projectFixedPointOntoDiagonal,
+} from "./utils";
 
 import type { Scene } from "./Scene";
 
@@ -71,6 +76,7 @@ import type {
   ExcalidrawBindableElement,
   ExcalidrawElbowArrowElement,
   ExcalidrawElement,
+  ExcalidrawRectanguloidElement,
   ExcalidrawTextElement,
   FixedPoint,
   FixedPointBinding,
@@ -2334,3 +2340,434 @@ export const normalizeFixedPoint = <T extends FixedPoint | null>(
   }
   return fixedPoint as any as T extends null ? null : FixedPoint;
 };
+
+type Side =
+  | "top"
+  | "top-right"
+  | "right"
+  | "bottom-right"
+  | "bottom"
+  | "bottom-left"
+  | "left"
+  | "top-left";
+type ShapeType = "rectangle" | "ellipse" | "diamond";
+const getShapeType = (element: ExcalidrawBindableElement): ShapeType => {
+  if (element.type === "ellipse" || element.type === "diamond") {
+    return element.type;
+  }
+  return "rectangle";
+};
+
+interface SectorConfig {
+  // center angle of the sector in degrees
+  centerAngle: number;
+  // width of the sector in degrees
+  sectorWidth: number;
+  side: Side;
+}
+
+// Define sector configurations for different shape types
+const SHAPE_CONFIGS: Record<ShapeType, SectorConfig[]> = {
+  // rectangle: 15° corners, 75° edges
+  rectangle: [
+    { centerAngle: 0, sectorWidth: 75, side: "right" },
+    { centerAngle: 45, sectorWidth: 15, side: "bottom-right" },
+    { centerAngle: 90, sectorWidth: 75, side: "bottom" },
+    { centerAngle: 135, sectorWidth: 15, side: "bottom-left" },
+    { centerAngle: 180, sectorWidth: 75, side: "left" },
+    { centerAngle: 225, sectorWidth: 15, side: "top-left" },
+    { centerAngle: 270, sectorWidth: 75, side: "top" },
+    { centerAngle: 315, sectorWidth: 15, side: "top-right" },
+  ],
+
+  // diamond: 15° vertices, 75° edges
+  diamond: [
+    { centerAngle: 0, sectorWidth: 15, side: "right" },
+    { centerAngle: 45, sectorWidth: 75, side: "bottom-right" },
+    { centerAngle: 90, sectorWidth: 15, side: "bottom" },
+    { centerAngle: 135, sectorWidth: 75, side: "bottom-left" },
+    { centerAngle: 180, sectorWidth: 15, side: "left" },
+    { centerAngle: 225, sectorWidth: 75, side: "top-left" },
+    { centerAngle: 270, sectorWidth: 15, side: "top" },
+    { centerAngle: 315, sectorWidth: 75, side: "top-right" },
+  ],
+
+  // ellipse: 15° cardinal points, 75° diagonals
+  ellipse: [
+    { centerAngle: 0, sectorWidth: 15, side: "right" },
+    { centerAngle: 45, sectorWidth: 75, side: "bottom-right" },
+    { centerAngle: 90, sectorWidth: 15, side: "bottom" },
+    { centerAngle: 135, sectorWidth: 75, side: "bottom-left" },
+    { centerAngle: 180, sectorWidth: 15, side: "left" },
+    { centerAngle: 225, sectorWidth: 75, side: "top-left" },
+    { centerAngle: 270, sectorWidth: 15, side: "top" },
+    { centerAngle: 315, sectorWidth: 75, side: "top-right" },
+  ],
+};
+
+const getSectorBoundaries = (
+  config: SectorConfig[],
+): Array<{ start: number; end: number; side: Side }> => {
+  return config.map((sector, index) => {
+    const halfWidth = sector.sectorWidth / 2;
+    let start = sector.centerAngle - halfWidth;
+    let end = sector.centerAngle + halfWidth;
+
+    // normalize angles to [0, 360) range
+    start = ((start % 360) + 360) % 360;
+    end = ((end % 360) + 360) % 360;
+
+    return { start, end, side: sector.side };
+  });
+};
+
+// determine which side a point falls into using adaptive sectors
+const getShapeSideAdaptive = (
+  fixedPoint: FixedPoint,
+  shapeType: ShapeType,
+): Side => {
+  const [x, y] = fixedPoint;
+
+  // convert to centered coordinates
+  const centerX = x - 0.5;
+  const centerY = y - 0.5;
+
+  // calculate angle
+  let angle = Math.atan2(centerY, centerX);
+  if (angle < 0) {
+    angle += 2 * Math.PI;
+  }
+  const degrees = (angle * 180) / Math.PI;
+
+  // get sector configuration for this shape type
+  const config = SHAPE_CONFIGS[shapeType];
+  const boundaries = getSectorBoundaries(config);
+
+  // find which sector the angle falls into
+  for (const boundary of boundaries) {
+    if (boundary.start <= boundary.end) {
+      // Normal case: sector doesn't cross 0°
+      if (degrees >= boundary.start && degrees <= boundary.end) {
+        return boundary.side;
+      }
+    } else if (degrees >= boundary.start || degrees <= boundary.end) {
+      return boundary.side;
+    }
+  }
+
+  // fallback - find nearest sector center
+  let minDiff = Infinity;
+  let nearestSide = config[0].side;
+
+  for (const sector of config) {
+    let diff = Math.abs(degrees - sector.centerAngle);
+    // handle wraparound
+    if (diff > 180) {
+      diff = 360 - diff;
+    }
+
+    if (diff < minDiff) {
+      minDiff = diff;
+      nearestSide = sector.side;
+    }
+  }
+
+  return nearestSide;
+};
+
+export const getBindingSideMidPoint = (
+  binding: FixedPointBinding,
+  elementsMap: ElementsMap,
+) => {
+  const bindableElement = elementsMap.get(binding.elementId);
+  if (
+    !bindableElement ||
+    bindableElement.isDeleted ||
+    !isBindableElement(bindableElement)
+  ) {
+    return null;
+  }
+
+  const center = elementCenterPoint(bindableElement, elementsMap);
+  const shapeType = getShapeType(bindableElement);
+  const side = getShapeSideAdaptive(
+    normalizeFixedPoint(binding.fixedPoint),
+    shapeType,
+  );
+
+  // small offset to avoid precision issues in elbow
+  const OFFSET = 0.01;
+
+  if (bindableElement.type === "diamond") {
+    const [sides, corners] = deconstructDiamondElement(bindableElement);
+    const [bottomRight, bottomLeft, topLeft, topRight] = sides;
+
+    let x: number;
+    let y: number;
+    switch (side) {
+      case "left": {
+        // left vertex - use the center of the left corner curve
+        if (corners.length >= 3) {
+          const leftCorner = corners[2];
+          const midPoint = leftCorner[1];
+          x = midPoint[0] - OFFSET;
+          y = midPoint[1];
+        } else {
+          // fallback for non-rounded diamond
+          const midPoint = getMidPoint(bottomLeft[1], topLeft[0]);
+          x = midPoint[0] - OFFSET;
+          y = midPoint[1];
+        }
+        break;
+      }
+      case "right": {
+        if (corners.length >= 1) {
+          const rightCorner = corners[0];
+          const midPoint = rightCorner[1];
+          x = midPoint[0] + OFFSET;
+          y = midPoint[1];
+        } else {
+          const midPoint = getMidPoint(topRight[1], bottomRight[0]);
+          x = midPoint[0] + OFFSET;
+          y = midPoint[1];
+        }
+        break;
+      }
+      case "top": {
+        if (corners.length >= 4) {
+          const topCorner = corners[3];
+          const midPoint = topCorner[1];
+          x = midPoint[0];
+          y = midPoint[1] - OFFSET;
+        } else {
+          const midPoint = getMidPoint(topLeft[1], topRight[0]);
+          x = midPoint[0];
+          y = midPoint[1] - OFFSET;
+        }
+        break;
+      }
+      case "bottom": {
+        if (corners.length >= 2) {
+          const bottomCorner = corners[1];
+          const midPoint = bottomCorner[1];
+          x = midPoint[0];
+          y = midPoint[1] + OFFSET;
+        } else {
+          const midPoint = getMidPoint(bottomRight[1], bottomLeft[0]);
+          x = midPoint[0];
+          y = midPoint[1] + OFFSET;
+        }
+        break;
+      }
+      case "top-right": {
+        const midPoint = getMidPoint(topRight[0], topRight[1]);
+
+        x = midPoint[0] + OFFSET * 0.707;
+        y = midPoint[1] - OFFSET * 0.707;
+        break;
+      }
+      case "bottom-right": {
+        const midPoint = getMidPoint(bottomRight[0], bottomRight[1]);
+
+        x = midPoint[0] + OFFSET * 0.707;
+        y = midPoint[1] + OFFSET * 0.707;
+        break;
+      }
+      case "bottom-left": {
+        const midPoint = getMidPoint(bottomLeft[0], bottomLeft[1]);
+        x = midPoint[0] - OFFSET * 0.707;
+        y = midPoint[1] + OFFSET * 0.707;
+        break;
+      }
+      case "top-left": {
+        const midPoint = getMidPoint(topLeft[0], topLeft[1]);
+        x = midPoint[0] - OFFSET * 0.707;
+        y = midPoint[1] - OFFSET * 0.707;
+        break;
+      }
+      default: {
+        return null;
+      }
+    }
+
+    return pointRotateRads(pointFrom(x, y), center, bindableElement.angle);
+  }
+
+  if (bindableElement.type === "ellipse") {
+    const ellipseCenterX = bindableElement.x + bindableElement.width / 2;
+    const ellipseCenterY = bindableElement.y + bindableElement.height / 2;
+    const radiusX = bindableElement.width / 2;
+    const radiusY = bindableElement.height / 2;
+
+    let x: number;
+    let y: number;
+
+    switch (side) {
+      case "top": {
+        x = ellipseCenterX;
+        y = ellipseCenterY - radiusY - OFFSET;
+        break;
+      }
+      case "right": {
+        x = ellipseCenterX + radiusX + OFFSET;
+        y = ellipseCenterY;
+        break;
+      }
+      case "bottom": {
+        x = ellipseCenterX;
+        y = ellipseCenterY + radiusY + OFFSET;
+        break;
+      }
+      case "left": {
+        x = ellipseCenterX - radiusX - OFFSET;
+        y = ellipseCenterY;
+        break;
+      }
+      case "top-right": {
+        const angle = -Math.PI / 4;
+        const ellipseX = radiusX * Math.cos(angle);
+        const ellipseY = radiusY * Math.sin(angle);
+        x = ellipseCenterX + ellipseX + OFFSET * 0.707;
+        y = ellipseCenterY + ellipseY - OFFSET * 0.707;
+        break;
+      }
+      case "bottom-right": {
+        const angle = Math.PI / 4;
+        const ellipseX = radiusX * Math.cos(angle);
+        const ellipseY = radiusY * Math.sin(angle);
+        x = ellipseCenterX + ellipseX + OFFSET * 0.707;
+        y = ellipseCenterY + ellipseY + OFFSET * 0.707;
+        break;
+      }
+      case "bottom-left": {
+        const angle = (3 * Math.PI) / 4;
+        const ellipseX = radiusX * Math.cos(angle);
+        const ellipseY = radiusY * Math.sin(angle);
+        x = ellipseCenterX + ellipseX - OFFSET * 0.707;
+        y = ellipseCenterY + ellipseY + OFFSET * 0.707;
+        break;
+      }
+      case "top-left": {
+        const angle = (-3 * Math.PI) / 4;
+        const ellipseX = radiusX * Math.cos(angle);
+        const ellipseY = radiusY * Math.sin(angle);
+        x = ellipseCenterX + ellipseX - OFFSET * 0.707;
+        y = ellipseCenterY + ellipseY - OFFSET * 0.707;
+        break;
+      }
+      default: {
+        return null;
+      }
+    }
+
+    return pointRotateRads(pointFrom(x, y), center, bindableElement.angle);
+  }
+
+  if (isRectangularElement(bindableElement)) {
+    const [sides, corners] = deconstructRectanguloidElement(
+      bindableElement as ExcalidrawRectanguloidElement,
+    );
+    const [top, right, bottom, left] = sides;
+
+    let x: number;
+    let y: number;
+    switch (side) {
+      case "top": {
+        const midPoint = getMidPoint(top[0], top[1]);
+        x = midPoint[0];
+        y = midPoint[1] - OFFSET;
+        break;
+      }
+      case "right": {
+        const midPoint = getMidPoint(right[0], right[1]);
+        x = midPoint[0] + OFFSET;
+        y = midPoint[1];
+        break;
+      }
+      case "bottom": {
+        const midPoint = getMidPoint(bottom[0], bottom[1]);
+        x = midPoint[0];
+        y = midPoint[1] + OFFSET;
+        break;
+      }
+      case "left": {
+        const midPoint = getMidPoint(left[0], left[1]);
+        x = midPoint[0] - OFFSET;
+        y = midPoint[1];
+        break;
+      }
+      case "top-left": {
+        if (corners.length >= 1) {
+          const corner = corners[0];
+
+          const p1 = corner[0];
+          const p2 = corner[3];
+          const midPoint = getMidPoint(p1, p2);
+
+          x = midPoint[0] - OFFSET * 0.707;
+          y = midPoint[1] - OFFSET * 0.707;
+        } else {
+          x = bindableElement.x - OFFSET;
+          y = bindableElement.y - OFFSET;
+        }
+        break;
+      }
+      case "top-right": {
+        if (corners.length >= 2) {
+          const corner = corners[1];
+          const p1 = corner[0];
+          const p2 = corner[3];
+          const midPoint = getMidPoint(p1, p2);
+
+          x = midPoint[0] + OFFSET * 0.707;
+          y = midPoint[1] - OFFSET * 0.707;
+        } else {
+          x = bindableElement.x + bindableElement.width + OFFSET;
+          y = bindableElement.y - OFFSET;
+        }
+        break;
+      }
+      case "bottom-right": {
+        if (corners.length >= 3) {
+          const corner = corners[2];
+          const p1 = corner[0];
+          const p2 = corner[3];
+          const midPoint = getMidPoint(p1, p2);
+
+          x = midPoint[0] + OFFSET * 0.707;
+          y = midPoint[1] + OFFSET * 0.707;
+        } else {
+          x = bindableElement.x + bindableElement.width + OFFSET;
+          y = bindableElement.y + bindableElement.height + OFFSET;
+        }
+        break;
+      }
+      case "bottom-left": {
+        if (corners.length >= 4) {
+          const corner = corners[3];
+          const p1 = corner[0];
+          const p2 = corner[3];
+          const midPoint = getMidPoint(p1, p2);
+
+          x = midPoint[0] - OFFSET * 0.707;
+          y = midPoint[1] + OFFSET * 0.707;
+        } else {
+          x = bindableElement.x - OFFSET;
+          y = bindableElement.y + bindableElement.height + OFFSET;
+        }
+        break;
+      }
+      default: {
+        return null;
+      }
+    }
+
+    return pointRotateRads(pointFrom(x, y), center, bindableElement.angle);
+  }
+
+  return null;
+};
+
+const getMidPoint = (p1: GlobalPoint, p2: GlobalPoint): GlobalPoint => {
+  return pointFrom((p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2);
+};

+ 1 - 0
packages/element/src/index.ts

@@ -92,6 +92,7 @@ export * from "./store";
 export * from "./textElement";
 export * from "./textMeasurements";
 export * from "./textWrapping";
+export * from "./transform";
 export * from "./transformHandles";
 export * from "./typeChecks";
 export * from "./utils";

+ 13 - 14
packages/excalidraw/data/transform.ts → packages/element/src/transform.ts

@@ -16,7 +16,9 @@ import {
   getLineHeight,
 } from "@excalidraw/common";
 
-import { bindBindingElement } from "@excalidraw/element";
+import type { MarkOptional } from "@excalidraw/common/utility-types";
+
+import { bindBindingElement } from "./binding";
 import {
   newArrowElement,
   newElement,
@@ -25,21 +27,20 @@ import {
   newLinearElement,
   newMagicFrameElement,
   newTextElement,
-} from "@excalidraw/element";
-import { measureText, normalizeText } from "@excalidraw/element";
-import { isArrowElement } from "@excalidraw/element";
-
-import { syncInvalidIndices } from "@excalidraw/element";
+  type ElementConstructorOpts,
+} from "./newElement";
+import { measureText, normalizeText } from "./textMeasurements";
+import { isArrowElement } from "./typeChecks";
 
-import { redrawTextBoundingBox } from "@excalidraw/element";
+import { syncInvalidIndices } from "./fractionalIndex";
 
-import { LinearElementEditor } from "@excalidraw/element";
+import { redrawTextBoundingBox } from "./textElement";
 
-import { getCommonBounds } from "@excalidraw/element";
+import { LinearElementEditor } from "./linearElementEditor";
 
-import { Scene } from "@excalidraw/element";
+import { getCommonBounds } from "./bounds";
 
-import type { ElementConstructorOpts } from "@excalidraw/element";
+import { Scene } from "./Scene";
 
 import type {
   ExcalidrawArrowElement,
@@ -59,9 +60,7 @@ import type {
   NonDeletedSceneElementsMap,
   TextAlign,
   VerticalAlign,
-} from "@excalidraw/element/types";
-
-import type { MarkOptional } from "@excalidraw/common/utility-types";
+} from "./types";
 
 export type ValidLinearElement = {
   type: "arrow" | "line";

+ 5 - 4
packages/excalidraw/actions/actionProperties.tsx

@@ -22,6 +22,7 @@ import {
   isTransparent,
   reduceToCommonValue,
   invariant,
+  FONT_SIZES,
 } from "@excalidraw/common";
 
 import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element";
@@ -758,25 +759,25 @@ export const actionChangeFontSize = register<ExcalidrawTextElement["fontSize"]>(
               group="font-size"
               options={[
                 {
-                  value: 16,
+                  value: FONT_SIZES.sm,
                   text: t("labels.small"),
                   icon: FontSizeSmallIcon,
                   testId: "fontSize-small",
                 },
                 {
-                  value: 20,
+                  value: FONT_SIZES.md,
                   text: t("labels.medium"),
                   icon: FontSizeMediumIcon,
                   testId: "fontSize-medium",
                 },
                 {
-                  value: 28,
+                  value: FONT_SIZES.lg,
                   text: t("labels.large"),
                   icon: FontSizeLargeIcon,
                   testId: "fontSize-large",
                 },
                 {
-                  value: 36,
+                  value: FONT_SIZES.xl,
                   text: t("labels.veryLarge"),
                   icon: FontSizeExtraLargeIcon,
                   testId: "fontSize-veryLarge",

+ 2 - 1
packages/excalidraw/charts.ts

@@ -9,6 +9,7 @@ import {
   VERTICAL_ALIGN,
   randomId,
   isDevEnv,
+  FONT_SIZES,
 } from "@excalidraw/common";
 
 import {
@@ -213,7 +214,7 @@ const chartXLabels = (
         y: y + BAR_GAP / 2,
         width: BAR_WIDTH,
         angle: 5.87 as Radians,
-        fontSize: 16,
+        fontSize: FONT_SIZES.sm,
         textAlign: "center",
         verticalAlign: "top",
       });

+ 3 - 2
packages/excalidraw/components/App.tsx

@@ -248,6 +248,8 @@ import {
   doBoundsIntersect,
   isPointInElement,
   maxBindingDistance_simple,
+  convertToExcalidrawElements,
+  type ExcalidrawElementSkeleton,
 } from "@excalidraw/element";
 
 import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
@@ -395,7 +397,6 @@ import {
   SnapCache,
   isGridModeEnabled,
 } from "../snapping";
-import { convertToExcalidrawElements } from "../data/transform";
 import { Renderer } from "../scene/Renderer";
 import {
   setEraserCursor,
@@ -457,7 +458,7 @@ import type { ClipboardData, PastedMixedContent } from "../clipboard";
 import type { ExportedElements } from "../data";
 import type { ContextMenuItems } from "./ContextMenu";
 import type { FileSystemHandle } from "../data/filesystem";
-import type { ExcalidrawElementSkeleton } from "../data/transform";
+
 import type {
   AppClassProperties,
   AppProps,

+ 2 - 1
packages/excalidraw/fonts/Fonts.ts

@@ -4,6 +4,7 @@ import {
   CJK_HAND_DRAWN_FALLBACK_FONT,
   WINDOWS_EMOJI_FALLBACK_FONT,
   getFontFamilyFallbacks,
+  FONT_SIZES,
 } from "@excalidraw/common";
 import { getContainerElement } from "@excalidraw/element";
 import { charWidth } from "@excalidraw/element";
@@ -240,7 +241,7 @@ export class Fonts {
     for (const [index, fontFamily] of fontFamilies.entries()) {
       const font = getFontString({
         fontFamily,
-        fontSize: 16,
+        fontSize: FONT_SIZES.sm,
       });
 
       // WARN: without "text" param it does not have to mean that all font faces are loaded as it could be just one irrelevant font face!

+ 5 - 2
packages/excalidraw/index.tsx

@@ -293,8 +293,11 @@ export { TTDDialog } from "./components/TTDDialog/TTDDialog";
 export { TTDDialogTrigger } from "./components/TTDDialog/TTDDialogTrigger";
 
 export { zoomToFitBounds } from "./actions/actionCanvas";
-export { convertToExcalidrawElements } from "./data/transform";
-export { getCommonBounds, getVisibleSceneBounds } from "@excalidraw/element";
+export {
+  getCommonBounds,
+  getVisibleSceneBounds,
+  convertToExcalidrawElements,
+} from "@excalidraw/element";
 
 export {
   elementsOverlappingBBox,

+ 3 - 1
packages/excalidraw/tests/helpers/api.ts

@@ -23,6 +23,8 @@ import { isLinearElementType } from "@excalidraw/element";
 import { getSelectedElements } from "@excalidraw/element";
 import { selectGroupsForSelectedElements } from "@excalidraw/element";
 
+import { FONT_SIZES } from "@excalidraw/common";
+
 import type {
   ExcalidrawElement,
   ExcalidrawGenericElement,
@@ -406,7 +408,7 @@ export class API {
       text: opts?.label?.text || "sample-text",
       width: 50,
       height: 20,
-      fontSize: 16,
+      fontSize: FONT_SIZES.sm,
       containerId: rectangle.id,
       frameId:
         opts?.label?.frameId === undefined