diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index efacd4075f..f713704651 100644 --- a/packages/common/src/constants.ts +++ b/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"; diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index c3fcf2767b..b24eeb17e2 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -54,13 +54,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"; @@ -73,6 +78,7 @@ import type { ExcalidrawBindableElement, ExcalidrawElbowArrowElement, ExcalidrawElement, + ExcalidrawRectanguloidElement, ExcalidrawTextElement, FixedPoint, FixedPointBinding, @@ -2289,3 +2295,434 @@ export const normalizeFixedPoint = ( } 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 = { + // 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); +}; diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index ee1a44944e..2d6b040a34 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/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( 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", diff --git a/packages/excalidraw/charts.ts b/packages/excalidraw/charts.ts index 97ca081553..26f8802936 100644 --- a/packages/excalidraw/charts.ts +++ b/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", }); diff --git a/packages/excalidraw/fonts/Fonts.ts b/packages/excalidraw/fonts/Fonts.ts index c1de529cd7..195fb51f47 100644 --- a/packages/excalidraw/fonts/Fonts.ts +++ b/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! diff --git a/packages/excalidraw/tests/helpers/api.ts b/packages/excalidraw/tests/helpers/api.ts index 68b0813160..8a47c8c73e 100644 --- a/packages/excalidraw/tests/helpers/api.ts +++ b/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