Compare commits

..
80 changed files with 1122 additions and 977 deletions
+2 -2
View File
@@ -12,10 +12,10 @@ jobs:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
with: with:
fetch-depth: 2 fetch-depth: 2
- name: Setup Node.js - name: Setup Node.js 18.x
uses: actions/setup-node@v2 uses: actions/setup-node@v2
with: with:
node-version: 20.x node-version: 18.x
- name: Set up publish access - name: Set up publish access
run: | run: |
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN} npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
+2 -2
View File
@@ -9,10 +9,10 @@ jobs:
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Setup Node.js - name: Setup Node.js 18.x
uses: actions/setup-node@v2 uses: actions/setup-node@v2
with: with:
node-version: 20.x node-version: 18.x
- name: Install and lint - name: Install and lint
run: | run: |
+2 -2
View File
@@ -14,10 +14,10 @@ jobs:
with: with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }} token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
- name: Setup Node.js - name: Setup Node.js 18.x
uses: actions/setup-node@v2 uses: actions/setup-node@v2
with: with:
node-version: 20.x node-version: 18.x
- name: Create report file - name: Create report file
run: | run: |
+2 -2
View File
@@ -10,10 +10,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Setup Node.js - name: Setup Node.js 18.x
uses: actions/setup-node@v2 uses: actions/setup-node@v2
with: with:
node-version: 20.x node-version: 18.x
- name: Install and build - name: Install and build
run: | run: |
yarn --frozen-lockfile yarn --frozen-lockfile
+2 -2
View File
@@ -11,10 +11,10 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Setup Node.js - name: Setup Node.js 18.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 20.x node-version: 18.x
- name: Install in packages/excalidraw - name: Install in packages/excalidraw
run: yarn run: yarn
working-directory: packages/excalidraw working-directory: packages/excalidraw
+3 -6
View File
@@ -441,7 +441,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
}; };
private decryptPayload = async ( private decryptPayload = async (
iv: Uint8Array<ArrayBuffer>, iv: Uint8Array,
encryptedData: ArrayBuffer, encryptedData: ArrayBuffer,
decryptionKey: string, decryptionKey: string,
): Promise<ValueOf<SocketUpdateDataSource>> => { ): Promise<ValueOf<SocketUpdateDataSource>> => {
@@ -562,7 +562,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
// All socket listeners are moving to Portal // All socket listeners are moving to Portal
this.portal.socket.on( this.portal.socket.on(
"client-broadcast", "client-broadcast",
async (encryptedData: ArrayBuffer, iv: Uint8Array<ArrayBuffer>) => { async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
if (!this.portal.roomKey) { if (!this.portal.roomKey) {
return; return;
} }
@@ -746,10 +746,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
): ReconciledExcalidrawElement[] => { ): ReconciledExcalidrawElement[] => {
const localElements = this.getSceneElementsIncludingDeleted(); const localElements = this.getSceneElementsIncludingDeleted();
const appState = this.excalidrawAPI.getAppState(); const appState = this.excalidrawAPI.getAppState();
const restoredRemoteElements = restoreElements( const restoredRemoteElements = restoreElements(remoteElements, null);
remoteElements,
this.excalidrawAPI.getSceneElementsMapIncludingDeleted(),
);
const reconciledElements = reconcileElements( const reconciledElements = reconcileElements(
localElements, localElements,
restoredRemoteElements as RemoteExcalidrawElement[], restoredRemoteElements as RemoteExcalidrawElement[],
+2 -2
View File
@@ -105,8 +105,8 @@ const decryptElements = async (
data: FirebaseStoredScene, data: FirebaseStoredScene,
roomKey: string, roomKey: string,
): Promise<readonly ExcalidrawElement[]> => { ): Promise<readonly ExcalidrawElement[]> => {
const ciphertext = data.ciphertext.toUint8Array() as Uint8Array<ArrayBuffer>; const ciphertext = data.ciphertext.toUint8Array();
const iv = data.iv.toUint8Array() as Uint8Array<ArrayBuffer>; const iv = data.iv.toUint8Array();
const decrypted = await decryptData(iv, ciphertext, roomKey); const decrypted = await decryptData(iv, ciphertext, roomKey);
const decodedData = new TextDecoder("utf-8").decode( const decodedData = new TextDecoder("utf-8").decode(
+1 -1
View File
@@ -34,7 +34,7 @@
"prettier": "2.6.2", "prettier": "2.6.2",
"rewire": "6.0.0", "rewire": "6.0.0",
"rimraf": "^5.0.0", "rimraf": "^5.0.0",
"typescript": "5.9.3", "typescript": "4.9.4",
"vite": "5.0.12", "vite": "5.0.12",
"vite-plugin-checker": "0.7.2", "vite-plugin-checker": "0.7.2",
"vite-plugin-ejs": "1.7.0", "vite-plugin-ejs": "1.7.0",
-17
View File
@@ -1,17 +0,0 @@
/**
* x and y position of top left corner, x and y position of bottom right corner
*/
export type Bounds = readonly [
minX: number,
minY: number,
maxX: number,
maxY: number,
];
export const isBounds = (box: unknown): box is Bounds =>
Array.isArray(box) &&
box.length === 4 &&
typeof box[0] === "number" &&
typeof box[1] === "number" &&
typeof box[2] === "number" &&
typeof box[3] === "number";
+5 -7
View File
@@ -108,13 +108,6 @@ export const CLASSES = {
FRAME_NAME: "frame-name", 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 CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji"; export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
@@ -417,6 +410,7 @@ export const DEFAULT_ELEMENT_PROPS: {
roughness: ExcalidrawElement["roughness"]; roughness: ExcalidrawElement["roughness"];
opacity: ExcalidrawElement["opacity"]; opacity: ExcalidrawElement["opacity"];
locked: ExcalidrawElement["locked"]; locked: ExcalidrawElement["locked"];
containerBehavior: ExcalidrawElement["containerBehavior"];
} = { } = {
strokeColor: COLOR_PALETTE.black, strokeColor: COLOR_PALETTE.black,
backgroundColor: COLOR_PALETTE.transparent, backgroundColor: COLOR_PALETTE.transparent,
@@ -426,6 +420,10 @@ export const DEFAULT_ELEMENT_PROPS: {
roughness: ROUGHNESS.artist, roughness: ROUGHNESS.artist,
opacity: 100, opacity: 100,
locked: false, locked: false,
containerBehavior: {
textFlow: "growing",
margin: BOUND_TEXT_PADDING,
},
}; };
export const LIBRARY_SIDEBAR_TAB = "library"; export const LIBRARY_SIDEBAR_TAB = "library";
-1
View File
@@ -1,5 +1,4 @@
export * from "./binary-heap"; export * from "./binary-heap";
export * from "./bounds";
export * from "./colors"; export * from "./colors";
export * from "./constants"; export * from "./constants";
export * from "./font-metadata"; export * from "./font-metadata";
+3 -1
View File
@@ -6,10 +6,12 @@ import {
type LocalPoint, type LocalPoint,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { isBounds } from "@excalidraw/element";
import type { Curve } from "@excalidraw/math"; import type { Curve } from "@excalidraw/math";
import type { LineSegment } from "@excalidraw/utils"; import type { LineSegment } from "@excalidraw/utils";
import { type Bounds, isBounds } from "./bounds"; import type { Bounds } from "@excalidraw/element";
// The global data holder to collect the debug operations // The global data holder to collect the debug operations
declare global { declare global {
+8 -490
View File
@@ -22,9 +22,10 @@ import {
} from "@excalidraw/math"; } from "@excalidraw/math";
import type { LineSegment, LocalPoint, Radians } from "@excalidraw/math"; import type { LineSegment, LocalPoint, Radians } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types"; import type { AppState } from "@excalidraw/excalidraw/types";
import type { MapEntry, Mutable } from "@excalidraw/common/utility-types"; import type { MapEntry, Mutable } from "@excalidraw/common/utility-types";
import type { Bounds } from "@excalidraw/common";
import { import {
doBoundsIntersect, doBoundsIntersect,
@@ -53,21 +54,17 @@ import {
isBindableElement, isBindableElement,
isBoundToContainer, isBoundToContainer,
isElbowArrow, isElbowArrow,
isRectangularElement,
isRectanguloidElement, isRectanguloidElement,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { aabbForElement, elementCenterPoint } from "./bounds"; import { aabbForElement, elementCenterPoint } from "./bounds";
import { updateElbowArrowPoints } from "./elbowArrow"; import { updateElbowArrowPoints } from "./elbowArrow";
import { import { projectFixedPointOntoDiagonal } from "./utils";
deconstructDiamondElement,
deconstructRectanguloidElement,
projectFixedPointOntoDiagonal,
} from "./utils";
import type { Scene } from "./Scene"; import type { Scene } from "./Scene";
import type { Bounds } from "./bounds";
import type { ElementUpdate } from "./mutateElement"; import type { ElementUpdate } from "./mutateElement";
import type { import type {
BindMode, BindMode,
@@ -76,7 +73,6 @@ import type {
ExcalidrawBindableElement, ExcalidrawBindableElement,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawRectanguloidElement,
ExcalidrawTextElement, ExcalidrawTextElement,
FixedPoint, FixedPoint,
FixedPointBinding, FixedPointBinding,
@@ -150,22 +146,17 @@ export const isBindingEnabled = (appState: AppState): boolean => {
export const bindOrUnbindBindingElement = ( export const bindOrUnbindBindingElement = (
arrow: NonDeleted<ExcalidrawArrowElement>, arrow: NonDeleted<ExcalidrawArrowElement>,
draggingPoints: PointsPositionUpdates, draggingPoints: PointsPositionUpdates,
scenePointerX: number,
scenePointerY: number,
scene: Scene, scene: Scene,
appState: AppState, appState: AppState,
opts?: { opts?: {
newArrow?: boolean; newArrow?: boolean;
altKey?: boolean; altKey?: boolean;
angleLocked?: boolean;
initialBinding?: boolean; initialBinding?: boolean;
}, },
) => { ) => {
const { start, end } = getBindingStrategyForDraggingBindingElementEndpoints( const { start, end } = getBindingStrategyForDraggingBindingElementEndpoints(
arrow, arrow,
draggingPoints, draggingPoints,
scenePointerX,
scenePointerY,
scene.getNonDeletedElementsMap(), scene.getNonDeletedElementsMap(),
scene.getNonDeletedElements(), scene.getNonDeletedElements(),
appState, appState,
@@ -565,14 +556,12 @@ const bindingStrategyForSimpleArrowEndpointDragging_complex = (
export const getBindingStrategyForDraggingBindingElementEndpoints = ( export const getBindingStrategyForDraggingBindingElementEndpoints = (
arrow: NonDeleted<ExcalidrawArrowElement>, arrow: NonDeleted<ExcalidrawArrowElement>,
draggingPoints: PointsPositionUpdates, draggingPoints: PointsPositionUpdates,
screenPointerX: number,
screenPointerY: number,
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
elements: readonly Ordered<NonDeletedExcalidrawElement>[], elements: readonly Ordered<NonDeletedExcalidrawElement>[],
appState: AppState, appState: AppState,
opts?: { opts?: {
newArrow?: boolean; newArrow?: boolean;
angleLocked?: boolean; shiftKey?: boolean;
altKey?: boolean; altKey?: boolean;
finalize?: boolean; finalize?: boolean;
initialBinding?: boolean; initialBinding?: boolean;
@@ -593,8 +582,6 @@ export const getBindingStrategyForDraggingBindingElementEndpoints = (
return getBindingStrategyForDraggingBindingElementEndpoints_simple( return getBindingStrategyForDraggingBindingElementEndpoints_simple(
arrow, arrow,
draggingPoints, draggingPoints,
screenPointerX,
screenPointerY,
elementsMap, elementsMap,
elements, elements,
appState, appState,
@@ -605,14 +592,12 @@ export const getBindingStrategyForDraggingBindingElementEndpoints = (
const getBindingStrategyForDraggingBindingElementEndpoints_simple = ( const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
arrow: NonDeleted<ExcalidrawArrowElement>, arrow: NonDeleted<ExcalidrawArrowElement>,
draggingPoints: PointsPositionUpdates, draggingPoints: PointsPositionUpdates,
scenePointerX: number,
scenePointerY: number,
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
elements: readonly Ordered<NonDeletedExcalidrawElement>[], elements: readonly Ordered<NonDeletedExcalidrawElement>[],
appState: AppState, appState: AppState,
opts?: { opts?: {
newArrow?: boolean; newArrow?: boolean;
angleLocked?: boolean; shiftKey?: boolean;
altKey?: boolean; altKey?: boolean;
finalize?: boolean; finalize?: boolean;
initialBinding?: boolean; initialBinding?: boolean;
@@ -684,15 +669,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
elementsMap, elementsMap,
(e) => maxBindingDistance_simple(appState.zoom), (e) => maxBindingDistance_simple(appState.zoom),
); );
const pointInElement = const pointInElement = hit && isPointInElement(globalPoint, hit, elementsMap);
hit &&
(opts?.angleLocked
? isPointInElement(
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
hit,
elementsMap,
)
: isPointInElement(globalPoint, hit, elementsMap));
const otherBindableElement = otherBinding const otherBindableElement = otherBinding
? (elementsMap.get( ? (elementsMap.get(
otherBinding.elementId, otherBinding.elementId,
@@ -793,12 +770,6 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
} }
: { mode: null }; : { mode: null };
const otherEndpoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
startDragged ? -1 : 0,
elementsMap,
);
const other: BindingStrategy = const other: BindingStrategy =
otherBindableElement && otherBindableElement &&
!otherFocusPointIsInElement && !otherFocusPointIsInElement &&
@@ -808,19 +779,6 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
element: otherBindableElement, element: otherBindableElement,
focusPoint: appState.selectedLinearElement.initialState.altFocusPoint, focusPoint: appState.selectedLinearElement.initialState.altFocusPoint,
} }
: opts?.angleLocked && otherBindableElement
? {
mode: "orbit",
element: otherBindableElement,
focusPoint:
projectFixedPointOntoDiagonal(
arrow,
otherEndpoint,
otherBindableElement,
startDragged ? "end" : "start",
elementsMap,
) || otherEndpoint,
}
: { mode: undefined }; : { mode: undefined };
return { return {
@@ -966,8 +924,6 @@ export const bindOrUnbindBindingElements = (
bindOrUnbindBindingElement( bindOrUnbindBindingElement(
arrow, arrow,
new Map(), // No dragging points in this case new Map(), // No dragging points in this case
Infinity,
Infinity,
scene, scene,
appState, appState,
); );
@@ -1170,14 +1126,7 @@ export const updateBindings = (
}, },
) => { ) => {
if (isArrowElement(latestElement)) { if (isArrowElement(latestElement)) {
bindOrUnbindBindingElement( bindOrUnbindBindingElement(latestElement, new Map(), scene, appState);
latestElement,
new Map(),
Infinity,
Infinity,
scene,
appState,
);
} else { } else {
updateBoundElements(latestElement, scene, { updateBoundElements(latestElement, scene, {
...options, ...options,
@@ -2340,434 +2289,3 @@ export const normalizeFixedPoint = <T extends FixedPoint | null>(
} }
return fixedPoint as any as T extends null ? null : FixedPoint; 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);
};
+10 -1
View File
@@ -2,7 +2,6 @@ import rough from "roughjs/bin/rough";
import { import {
arrayToMap, arrayToMap,
type Bounds,
invariant, invariant,
rescalePoints, rescalePoints,
sizeOf, sizeOf,
@@ -79,6 +78,16 @@ export type RectangleBox = {
type MaybeQuadraticSolution = [number | null, number | null] | false; type MaybeQuadraticSolution = [number | null, number | null] | false;
/**
* x and y position of top left corner, x and y position of bottom right corner
*/
export type Bounds = readonly [
minX: number,
minY: number,
maxX: number,
maxY: number,
];
export type SceneBounds = readonly [ export type SceneBounds = readonly [
sceneX: number, sceneX: number,
sceneY: number, sceneY: number,
+2 -1
View File
@@ -1,4 +1,4 @@
import { invariant, isTransparent, type Bounds } from "@excalidraw/common"; import { invariant, isTransparent } from "@excalidraw/common";
import { import {
curveIntersectLineSegment, curveIntersectLineSegment,
isPointWithinBounds, isPointWithinBounds,
@@ -29,6 +29,7 @@ import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
import { isPathALoop } from "./utils"; import { isPathALoop } from "./utils";
import { import {
type Bounds,
doBoundsIntersect, doBoundsIntersect,
elementCenterPoint, elementCenterPoint,
getCenterForBounds, getCenterForBounds,
+4 -2
View File
@@ -16,8 +16,7 @@ export const hasStrokeColor = (type: ElementOrToolType) =>
type === "freedraw" || type === "freedraw" ||
type === "arrow" || type === "arrow" ||
type === "line" || type === "line" ||
type === "text" || type === "text";
type === "embeddable";
export const hasStrokeWidth = (type: ElementOrToolType) => export const hasStrokeWidth = (type: ElementOrToolType) =>
type === "rectangle" || type === "rectangle" ||
@@ -46,6 +45,9 @@ export const canChangeRoundness = (type: ElementOrToolType) =>
type === "diamond" || type === "diamond" ||
type === "image"; type === "image";
export const hasContainerBehavior = (type: ElementOrToolType) =>
type === "rectangle" || type === "diamond" || type === "ellipse";
export const toolIsArrow = (type: ElementOrToolType) => type === "arrow"; export const toolIsArrow = (type: ElementOrToolType) => type === "arrow";
export const canHaveArrowheads = (type: ElementOrToolType) => type === "arrow"; export const canHaveArrowheads = (type: ElementOrToolType) => type === "arrow";
+8 -5
View File
@@ -2056,11 +2056,14 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
} }
} }
private static stripIrrelevantProps( private static stripIrrelevantProps(partial: ElementPartial): ElementPartial {
partial: Partial<OrderedExcalidrawElement>, const {
): ElementPartial { id: _id,
const { id, updated, ...strippedPartial } = partial; updated: _updated,
seed: _seed,
...strippedPartial
} = partial as any;
return strippedPartial; return strippedPartial as ElementPartial;
} }
} }
+1 -1
View File
@@ -1,5 +1,4 @@
import { import {
type Bounds,
TEXT_AUTOWRAP_THRESHOLD, TEXT_AUTOWRAP_THRESHOLD,
getGridPoint, getGridPoint,
getFontString, getFontString,
@@ -30,6 +29,7 @@ import {
import type { Scene } from "./Scene"; import type { Scene } from "./Scene";
import type { Bounds } from "./bounds";
import type { ExcalidrawElement } from "./types"; import type { ExcalidrawElement } from "./types";
export const dragSelectedElements = ( export const dragSelectedElements = (
+1 -1
View File
@@ -14,7 +14,6 @@ import {
} from "@excalidraw/math"; } from "@excalidraw/math";
import { import {
type Bounds,
BinaryHeap, BinaryHeap,
invariant, invariant,
isAnyTrue, isAnyTrue,
@@ -55,6 +54,7 @@ import {
import { aabbForElement, pointInsideBounds } from "./bounds"; import { aabbForElement, pointInsideBounds } from "./bounds";
import { getHoveredElementForBinding } from "./collision"; import { getHoveredElementForBinding } from "./collision";
import type { Bounds } from "./bounds";
import type { Heading } from "./heading"; import type { Heading } from "./heading";
import type { import type {
Arrowhead, Arrowhead,
+2
View File
@@ -268,6 +268,7 @@ const addNewNode = (
opacity: element.opacity, opacity: element.opacity,
fillStyle: element.fillStyle, fillStyle: element.fillStyle,
strokeStyle: element.strokeStyle, strokeStyle: element.strokeStyle,
containerBehavior: element.containerBehavior,
}); });
invariant( invariant(
@@ -346,6 +347,7 @@ export const addNewNodes = (
opacity: startNode.opacity, opacity: startNode.opacity,
fillStyle: startNode.fillStyle, fillStyle: startNode.fillStyle,
strokeStyle: startNode.strokeStyle, strokeStyle: startNode.strokeStyle,
containerBehavior: startNode.containerBehavior,
}); });
invariant( invariant(
+2 -7
View File
@@ -1,9 +1,4 @@
import { import { invariant, isDevEnv, isTestEnv } from "@excalidraw/common";
invariant,
isDevEnv,
isTestEnv,
type Bounds,
} from "@excalidraw/common";
import { import {
pointFrom, pointFrom,
@@ -24,7 +19,7 @@ import type {
Vector, Vector,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { getCenterForBounds } from "./bounds"; import { getCenterForBounds, type Bounds } from "./bounds";
import type { ExcalidrawBindableElement } from "./types"; import type { ExcalidrawBindableElement } from "./types";
-1
View File
@@ -92,7 +92,6 @@ export * from "./store";
export * from "./textElement"; export * from "./textElement";
export * from "./textMeasurements"; export * from "./textMeasurements";
export * from "./textWrapping"; export * from "./textWrapping";
export * from "./transform";
export * from "./transformHandles"; export * from "./transformHandles";
export * from "./typeChecks"; export * from "./typeChecks";
export * from "./utils"; export * from "./utils";
+33 -23
View File
@@ -26,6 +26,7 @@ import {
import { import {
deconstructLinearOrFreeDrawElement, deconstructLinearOrFreeDrawElement,
getHoveredElementForBinding,
isPathALoop, isPathALoop,
moveArrowAboveBindable, moveArrowAboveBindable,
projectFixedPointOntoDiagonal, projectFixedPointOntoDiagonal,
@@ -42,7 +43,6 @@ import type {
NullableGridSize, NullableGridSize,
Zoom, Zoom,
} from "@excalidraw/excalidraw/types"; } from "@excalidraw/excalidraw/types";
import type { Bounds } from "@excalidraw/common";
import { import {
calculateFixedPointForNonElbowArrowBinding, calculateFixedPointForNonElbowArrowBinding,
@@ -69,6 +69,7 @@ import { isLineElement } from "./typeChecks";
import type { Scene } from "./Scene"; import type { Scene } from "./Scene";
import type { Bounds } from "./bounds";
import type { import type {
NonDeleted, NonDeleted,
ExcalidrawLinearElement, ExcalidrawLinearElement,
@@ -305,11 +306,21 @@ export class LinearElementEditor {
const customLineAngle = const customLineAngle =
linearElementEditor.customLineAngle ?? linearElementEditor.customLineAngle ??
determineCustomLinearAngle(pivotPoint, element.points[idx]); determineCustomLinearAngle(pivotPoint, element.points[idx]);
const hoveredElement = getHoveredElementForBinding(
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
elements,
elementsMap,
);
// Determine if point movement should happen and how much // Determine if point movement should happen and how much
let deltaX = 0; let deltaX = 0;
let deltaY = 0; let deltaY = 0;
if (shouldRotateWithDiscreteAngle(event)) { if (
shouldRotateWithDiscreteAngle(event) &&
!hoveredElement &&
!element.startBinding &&
!element.endBinding
) {
const [width, height] = LinearElementEditor._getShiftLockedDelta( const [width, height] = LinearElementEditor._getShiftLockedDelta(
element, element,
elementsMap, elementsMap,
@@ -343,13 +354,11 @@ export class LinearElementEditor {
[idx], [idx],
deltaX, deltaX,
deltaY, deltaY,
scenePointerX,
scenePointerY,
elementsMap, elementsMap,
element, element,
elements, elements,
app, app,
shouldRotateWithDiscreteAngle(event), event.shiftKey,
event.altKey, event.altKey,
); );
@@ -483,11 +492,22 @@ export class LinearElementEditor {
const endIsSelected = selectedPointsIndices.includes( const endIsSelected = selectedPointsIndices.includes(
element.points.length - 1, element.points.length - 1,
); );
const hoveredElement = getHoveredElementForBinding(
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
elements,
elementsMap,
);
// Determine if point movement should happen and how much // Determine if point movement should happen and how much
let deltaX = 0; let deltaX = 0;
let deltaY = 0; let deltaY = 0;
if (shouldRotateWithDiscreteAngle(event) && singlePointDragged) { if (
shouldRotateWithDiscreteAngle(event) &&
singlePointDragged &&
!hoveredElement &&
!element.startBinding &&
!element.endBinding
) {
const [width, height] = LinearElementEditor._getShiftLockedDelta( const [width, height] = LinearElementEditor._getShiftLockedDelta(
element, element,
elementsMap, elementsMap,
@@ -500,6 +520,7 @@ export class LinearElementEditor {
width + pivotPoint[0], width + pivotPoint[0],
height + pivotPoint[1], height + pivotPoint[1],
); );
deltaX = target[0] - draggingPoint[0]; deltaX = target[0] - draggingPoint[0];
deltaY = target[1] - draggingPoint[1]; deltaY = target[1] - draggingPoint[1];
} else { } else {
@@ -520,13 +541,11 @@ export class LinearElementEditor {
selectedPointsIndices, selectedPointsIndices,
deltaX, deltaX,
deltaY, deltaY,
scenePointerX,
scenePointerY,
elementsMap, elementsMap,
element, element,
elements, elements,
app, app,
shouldRotateWithDiscreteAngle(event) && singlePointDragged, event.shiftKey,
event.altKey, event.altKey,
); );
@@ -2069,13 +2088,11 @@ const pointDraggingUpdates = (
selectedPointsIndices: readonly number[], selectedPointsIndices: readonly number[],
deltaX: number, deltaX: number,
deltaY: number, deltaY: number,
scenePointerX: number,
scenePointerY: number,
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
elements: readonly Ordered<NonDeletedExcalidrawElement>[], elements: readonly Ordered<NonDeletedExcalidrawElement>[],
app: AppClassProperties, app: AppClassProperties,
angleLocked: boolean, shiftKey: boolean,
altKey: boolean, altKey: boolean,
): { ): {
positions: PointsPositionUpdates; positions: PointsPositionUpdates;
@@ -2111,14 +2128,12 @@ const pointDraggingUpdates = (
const { start, end } = getBindingStrategyForDraggingBindingElementEndpoints( const { start, end } = getBindingStrategyForDraggingBindingElementEndpoints(
element, element,
naiveDraggingPoints, naiveDraggingPoints,
scenePointerX,
scenePointerY,
elementsMap, elementsMap,
elements, elements,
app.state, app.state,
{ {
newArrow: !!app.state.newElement, newArrow: !!app.state.newElement,
angleLocked, shiftKey,
altKey, altKey,
}, },
); );
@@ -2235,15 +2250,10 @@ const pointDraggingUpdates = (
// We need to use a custom intersector to ensure that if there is a big "jump" // We need to use a custom intersector to ensure that if there is a big "jump"
// in the arrow's position, we can position it with outline avoidance // in the arrow's position, we can position it with outline avoidance
// pixel-perfectly and avoid "dancing" arrows. // pixel-perfectly and avoid "dancing" arrows.
// NOTE: Direction matters here, so we create two intersectors const customIntersector =
const startCustomIntersector =
start.focusPoint && end.focusPoint start.focusPoint && end.focusPoint
? lineSegment(start.focusPoint, end.focusPoint) ? lineSegment(start.focusPoint, end.focusPoint)
: undefined; : undefined;
const endCustomIntersector =
start.focusPoint && end.focusPoint
? lineSegment(end.focusPoint, start.focusPoint)
: undefined;
// Needed to handle a special case where an existing arrow is dragged over // Needed to handle a special case where an existing arrow is dragged over
// the same element it is bound to on the other side // the same element it is bound to on the other side
@@ -2280,7 +2290,7 @@ const pointDraggingUpdates = (
nextArrow.endBinding, nextArrow.endBinding,
endBindable, endBindable,
elementsMap, elementsMap,
endCustomIntersector, customIntersector,
) || nextArrow.points[nextArrow.points.length - 1] ) || nextArrow.points[nextArrow.points.length - 1]
: nextArrow.points[nextArrow.points.length - 1]; : nextArrow.points[nextArrow.points.length - 1];
@@ -2311,7 +2321,7 @@ const pointDraggingUpdates = (
nextArrow.startBinding, nextArrow.startBinding,
startBindable, startBindable,
elementsMap, elementsMap,
startCustomIntersector, customIntersector,
) || nextArrow.points[0] ) || nextArrow.points[0]
: nextArrow.points[0]; : nextArrow.points[0];
+124 -17
View File
@@ -10,6 +10,7 @@ import {
getFontString, getFontString,
getUpdatedTimestamp, getUpdatedTimestamp,
getLineHeight, getLineHeight,
BOUND_TEXT_PADDING,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { Radians } from "@excalidraw/math"; import type { Radians } from "@excalidraw/math";
@@ -21,11 +22,11 @@ import {
getResizedElementAbsoluteCoords, getResizedElementAbsoluteCoords,
} from "./bounds"; } from "./bounds";
import { newElementWith } from "./mutateElement"; import { newElementWith } from "./mutateElement";
import { getBoundTextMaxWidth, getInitialTextMetrics } from "./textElement"; import { getBoundTextMaxWidth, getBoundTextMaxHeight } from "./textElement";
import { normalizeText, measureText } from "./textMeasurements"; import { normalizeText, measureText } from "./textMeasurements";
import { wrapText } from "./textWrapping"; import { wrapText } from "./textWrapping";
import { isLineElement } from "./typeChecks"; import { isFlowchartType, isLineElement } from "./typeChecks";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
@@ -48,6 +49,8 @@ import type {
ExcalidrawArrowElement, ExcalidrawArrowElement,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
ExcalidrawLineElement, ExcalidrawLineElement,
ExcalidrawFlowchartNodeElement,
ContainerBehavior,
} from "./types"; } from "./types";
export type ElementConstructorOpts = MarkOptional< export type ElementConstructorOpts = MarkOptional<
@@ -158,9 +161,26 @@ const _newElementBase = <T extends ExcalidrawElement>(
export const newElement = ( export const newElement = (
opts: { opts: {
type: ExcalidrawGenericElement["type"]; type: ExcalidrawGenericElement["type"];
containerBehavior?: ContainerBehavior;
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawGenericElement> => ): NonDeleted<ExcalidrawGenericElement> => {
_newElementBase<ExcalidrawGenericElement>(opts.type, opts); if (isFlowchartType(opts.type)) {
return {
..._newElementBase<ExcalidrawFlowchartNodeElement>(
opts.type as ExcalidrawFlowchartNodeElement["type"],
opts,
),
containerBehavior: {
textFlow: opts.containerBehavior?.textFlow ?? "growing",
margin: opts.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
},
} as NonDeleted<ExcalidrawFlowchartNodeElement>;
}
return _newElementBase<ExcalidrawGenericElement>(
opts.type,
opts,
) as NonDeleted<ExcalidrawGenericElement>;
};
export const newEmbeddableElement = ( export const newEmbeddableElement = (
opts: { opts: {
@@ -236,7 +256,8 @@ const getTextElementPositionOffsets = (
}; };
}; };
export type NewTextElementOptions = { export const newTextElement = (
opts: {
text: string; text: string;
originalText?: string; originalText?: string;
fontSize?: number; fontSize?: number;
@@ -246,20 +267,16 @@ export type NewTextElementOptions = {
containerId?: ExcalidrawTextContainer["id"] | null; containerId?: ExcalidrawTextContainer["id"] | null;
lineHeight?: ExcalidrawTextElement["lineHeight"]; lineHeight?: ExcalidrawTextElement["lineHeight"];
autoResize?: ExcalidrawTextElement["autoResize"]; autoResize?: ExcalidrawTextElement["autoResize"];
} & ElementConstructorOpts; } & ElementConstructorOpts,
export const newTextElement = (
opts: NewTextElementOptions,
): NonDeleted<ExcalidrawTextElement> => { ): NonDeleted<ExcalidrawTextElement> => {
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY; const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
const fontSize = opts.fontSize || DEFAULT_FONT_SIZE; const fontSize = opts.fontSize || DEFAULT_FONT_SIZE;
const lineHeight = opts.lineHeight || getLineHeight(fontFamily); const lineHeight = opts.lineHeight || getLineHeight(fontFamily);
const normalizedText = normalizeText(opts.text); const text = normalizeText(opts.text);
const originalText = opts.originalText ?? normalizedText; const metrics = measureText(
const metrics = getInitialTextMetrics( text,
{ ...opts, text: normalizedText }, getFontString({ fontFamily, fontSize }),
fontFamily, lineHeight,
fontSize,
); );
const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN; const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN;
const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN; const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN;
@@ -270,7 +287,7 @@ export const newTextElement = (
const textElementProps: ExcalidrawTextElement = { const textElementProps: ExcalidrawTextElement = {
..._newElementBase<ExcalidrawTextElement>("text", opts), ..._newElementBase<ExcalidrawTextElement>("text", opts),
text: normalizedText, text,
fontSize, fontSize,
fontFamily, fontFamily,
textAlign, textAlign,
@@ -280,7 +297,7 @@ export const newTextElement = (
width: metrics.width, width: metrics.width,
height: metrics.height, height: metrics.height,
containerId: opts.containerId || null, containerId: opts.containerId || null,
originalText, originalText: opts.originalText ?? text,
autoResize: opts.autoResize ?? true, autoResize: opts.autoResize ?? true,
lineHeight, lineHeight,
}; };
@@ -420,6 +437,96 @@ const adjustXYWithRotation = (
return [x, y]; return [x, y];
}; };
// Sticky note font sizing constants
export const STICKY_NOTE_FONT_STEP = 2;
export const STICKY_NOTE_MIN_FONT_SIZE = 8;
export const STICKY_NOTE_MAX_FONT_SIZE = 72;
export interface StickyNoteFontComputationResult {
fontSize: number;
width: number;
height: number;
wrappedText: string;
}
/**
* Computes the appropriate font size (snapped to step) so that the text fits
* inside the sticky note container without resizing the container.
* It first tries to shrink if overflowing, otherwise opportunistically enlarges
* (still snapped) while it fits. Width is constrained by wrap at container width.
*/
export const computeStickyNoteFontSize = (
text: string,
element: ExcalidrawTextElement,
container: ExcalidrawTextContainer,
maxGrowFontSize?: number,
): StickyNoteFontComputationResult => {
const step = STICKY_NOTE_FONT_STEP;
const maxH = getBoundTextMaxHeight(container, element as any);
const maxW = getBoundTextMaxWidth(container, element);
const snap = (size: number) =>
Math.max(
STICKY_NOTE_MIN_FONT_SIZE,
Math.max(step, Math.floor(size / step) * step),
);
let size = snap(element.fontSize);
const growthCap = snap(
maxGrowFontSize != null ? maxGrowFontSize : STICKY_NOTE_MAX_FONT_SIZE,
);
const lineHeight = element.lineHeight;
const fontFamily = element.fontFamily;
const measure = (fontSize: number) => {
const font = getFontString({ fontFamily, fontSize });
const wrappedText = wrapText(text, font, maxW);
const metrics = measureText(wrappedText, font, lineHeight);
return {
wrappedText,
width: Math.min(metrics.width, maxW),
height: metrics.height,
};
};
let { wrappedText, width, height } = measure(size);
if (height > maxH) {
// shrink until fits or min
while (size > STICKY_NOTE_MIN_FONT_SIZE) {
const next = snap(size - step);
if (next === size) {
break;
}
size = next;
({ wrappedText, width, height } = measure(size));
if (height <= maxH) {
break;
}
}
} else {
// grow back only up to growthCap (initial session font size)
while (size < growthCap) {
const next = snap(Math.min(size + step, growthCap));
if (next === size) {
break;
}
const m = measure(next);
if (m.height <= maxH) {
size = next;
wrappedText = m.wrappedText;
width = m.width;
height = m.height;
} else {
break;
}
}
}
return { fontSize: size, width, height, wrappedText };
};
export const refreshTextDimensions = ( export const refreshTextDimensions = (
textElement: ExcalidrawTextElement, textElement: ExcalidrawTextElement,
container: ExcalidrawTextContainer | null, container: ExcalidrawTextContainer | null,
+12 -4
View File
@@ -329,16 +329,24 @@ const generateElementCanvas = (
boundTextCanvasContext.translate(-shiftX, -shiftY); boundTextCanvasContext.translate(-shiftX, -shiftY);
// Clear the bound text area // Clear the bound text area
boundTextCanvasContext.clearRect( boundTextCanvasContext.clearRect(
-(boundTextElement.width / 2 + BOUND_TEXT_PADDING) * -(
boundTextElement.width / 2 +
(element.containerBehavior?.margin ?? BOUND_TEXT_PADDING)
) *
window.devicePixelRatio * window.devicePixelRatio *
scale, scale,
-(boundTextElement.height / 2 + BOUND_TEXT_PADDING) * -(
boundTextElement.height / 2 +
(element.containerBehavior?.margin ?? BOUND_TEXT_PADDING)
) *
window.devicePixelRatio * window.devicePixelRatio *
scale, scale,
(boundTextElement.width + BOUND_TEXT_PADDING * 2) * (boundTextElement.width +
(element.containerBehavior?.margin ?? BOUND_TEXT_PADDING) * 2) *
window.devicePixelRatio * window.devicePixelRatio *
scale, scale,
(boundTextElement.height + BOUND_TEXT_PADDING * 2) * (boundTextElement.height +
(element.containerBehavior?.margin ?? BOUND_TEXT_PADDING) * 2) *
window.devicePixelRatio * window.devicePixelRatio *
scale, scale,
); );
+3
View File
@@ -12,6 +12,7 @@ import {
SHIFT_LOCKING_ANGLE, SHIFT_LOCKING_ANGLE,
rescalePoints, rescalePoints,
getFontString, getFontString,
BOUND_TEXT_PADDING,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { GlobalPoint } from "@excalidraw/math"; import type { GlobalPoint } from "@excalidraw/math";
@@ -784,10 +785,12 @@ export const resizeSingleElement = (
const minWidth = getApproxMinLineWidth( const minWidth = getApproxMinLineWidth(
getFontString(boundTextElement), getFontString(boundTextElement),
boundTextElement.lineHeight, boundTextElement.lineHeight,
latestElement.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
); );
const minHeight = getApproxMinLineHeight( const minHeight = getApproxMinLineHeight(
boundTextElement.fontSize, boundTextElement.fontSize,
boundTextElement.lineHeight, boundTextElement.lineHeight,
latestElement.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
); );
nextWidth = Math.max(nextWidth, minWidth); nextWidth = Math.max(nextWidth, minWidth);
nextHeight = Math.max(nextHeight, minHeight); nextHeight = Math.max(nextHeight, minHeight);
+1 -1
View File
@@ -13,7 +13,6 @@ import {
import type { GlobalPoint, LineSegment, LocalPoint } from "@excalidraw/math"; import type { GlobalPoint, LineSegment, LocalPoint } from "@excalidraw/math";
import type { AppState, Zoom } from "@excalidraw/excalidraw/types"; import type { AppState, Zoom } from "@excalidraw/excalidraw/types";
import type { Bounds } from "@excalidraw/common";
import { getElementAbsoluteCoords } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds";
import { import {
@@ -24,6 +23,7 @@ import {
} from "./transformHandles"; } from "./transformHandles";
import { isImageElement, isLinearElement } from "./typeChecks"; import { isImageElement, isLinearElement } from "./typeChecks";
import type { Bounds } from "./bounds";
import type { import type {
TransformHandleType, TransformHandleType,
TransformHandle, TransformHandle,
+18 -36
View File
@@ -8,8 +8,6 @@ import {
getFontString, getFontString,
isProdEnv, isProdEnv,
invariant, invariant,
DEFAULT_FONT_FAMILY,
getLineHeight,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math"; import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
@@ -32,8 +30,6 @@ import {
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import type { NewTextElementOptions } from "./newElement";
import type { Scene } from "./Scene"; import type { Scene } from "./Scene";
import type { MaybeTransformHandleType } from "./transformHandles"; import type { MaybeTransformHandleType } from "./transformHandles";
@@ -44,7 +40,6 @@ import type {
ExcalidrawTextContainer, ExcalidrawTextContainer,
ExcalidrawTextElement, ExcalidrawTextElement,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
FontFamilyValues,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./types"; } from "./types";
@@ -113,6 +108,7 @@ export const redrawTextBoundingBox = (
const nextHeight = computeContainerDimensionForBoundText( const nextHeight = computeContainerDimensionForBoundText(
metrics.height, metrics.height,
container.type, container.type,
container.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
); );
scene.mutateElement(container, { height: nextHeight }); scene.mutateElement(container, { height: nextHeight });
updateOriginalContainerCache(container.id, nextHeight); updateOriginalContainerCache(container.id, nextHeight);
@@ -122,6 +118,7 @@ export const redrawTextBoundingBox = (
const nextWidth = computeContainerDimensionForBoundText( const nextWidth = computeContainerDimensionForBoundText(
metrics.width, metrics.width,
container.type, container.type,
container.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
); );
scene.mutateElement(container, { width: nextWidth }); scene.mutateElement(container, { width: nextWidth });
} }
@@ -192,6 +189,7 @@ export const handleBindTextResize = (
containerHeight = computeContainerDimensionForBoundText( containerHeight = computeContainerDimensionForBoundText(
nextHeight, nextHeight,
container.type, container.type,
container.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
); );
const diff = containerHeight - container.height; const diff = containerHeight - container.height;
@@ -358,8 +356,8 @@ export const getContainerCenter = (
}; };
export const getContainerCoords = (container: NonDeletedExcalidrawElement) => { export const getContainerCoords = (container: NonDeletedExcalidrawElement) => {
let offsetX = BOUND_TEXT_PADDING; let offsetX = container.containerBehavior?.margin ?? BOUND_TEXT_PADDING;
let offsetY = BOUND_TEXT_PADDING; let offsetY = container.containerBehavior?.margin ?? BOUND_TEXT_PADDING;
if (container.type === "ellipse") { if (container.type === "ellipse") {
// The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6172 // The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6172
@@ -451,9 +449,10 @@ export const isValidTextContainer = (element: {
export const computeContainerDimensionForBoundText = ( export const computeContainerDimensionForBoundText = (
dimension: number, dimension: number,
containerType: ExtractSetType<typeof VALID_CONTAINER_TYPES>, containerType: ExtractSetType<typeof VALID_CONTAINER_TYPES>,
boundTextPadding: number,
) => { ) => {
dimension = Math.ceil(dimension); dimension = Math.ceil(dimension);
const padding = BOUND_TEXT_PADDING * 2; const padding = boundTextPadding * 2;
if (containerType === "ellipse") { if (containerType === "ellipse") {
return Math.round(((dimension + padding) / Math.sqrt(2)) * 2); return Math.round(((dimension + padding) / Math.sqrt(2)) * 2);
@@ -472,6 +471,8 @@ export const getBoundTextMaxWidth = (
boundTextElement: ExcalidrawTextElement | null, boundTextElement: ExcalidrawTextElement | null,
) => { ) => {
const { width } = container; const { width } = container;
const boundTextPadding =
container.containerBehavior?.margin ?? BOUND_TEXT_PADDING;
if (isArrowElement(container)) { if (isArrowElement(container)) {
const minWidth = const minWidth =
(boundTextElement?.fontSize ?? DEFAULT_FONT_SIZE) * (boundTextElement?.fontSize ?? DEFAULT_FONT_SIZE) *
@@ -482,14 +483,14 @@ export const getBoundTextMaxWidth = (
// The width of the largest rectangle inscribed inside an ellipse is // The width of the largest rectangle inscribed inside an ellipse is
// Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from // Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from
// equation of an ellipse -https://github.com/excalidraw/excalidraw/pull/6172 // equation of an ellipse -https://github.com/excalidraw/excalidraw/pull/6172
return Math.round((width / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2; return Math.round((width / 2) * Math.sqrt(2)) - boundTextPadding * 2;
} }
if (container.type === "diamond") { if (container.type === "diamond") {
// The width of the largest rectangle inscribed inside a rhombus is // The width of the largest rectangle inscribed inside a rhombus is
// Math.round(width / 2) - https://github.com/excalidraw/excalidraw/pull/6265 // Math.round(width / 2) - https://github.com/excalidraw/excalidraw/pull/6265
return Math.round(width / 2) - BOUND_TEXT_PADDING * 2; return Math.round(width / 2) - boundTextPadding * 2;
} }
return width - BOUND_TEXT_PADDING * 2; return width - boundTextPadding * 2;
}; };
export const getBoundTextMaxHeight = ( export const getBoundTextMaxHeight = (
@@ -497,8 +498,10 @@ export const getBoundTextMaxHeight = (
boundTextElement: ExcalidrawTextElementWithContainer, boundTextElement: ExcalidrawTextElementWithContainer,
) => { ) => {
const { height } = container; const { height } = container;
const boundTextPadding =
container.containerBehavior?.margin ?? BOUND_TEXT_PADDING;
if (isArrowElement(container)) { if (isArrowElement(container)) {
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2; const containerHeight = height - boundTextPadding * 8 * 2;
if (containerHeight <= 0) { if (containerHeight <= 0) {
return boundTextElement.height; return boundTextElement.height;
} }
@@ -508,14 +511,14 @@ export const getBoundTextMaxHeight = (
// The height of the largest rectangle inscribed inside an ellipse is // The height of the largest rectangle inscribed inside an ellipse is
// Math.round((ellipse.height / 2) * Math.sqrt(2)) which is derived from // Math.round((ellipse.height / 2) * Math.sqrt(2)) which is derived from
// equation of an ellipse - https://github.com/excalidraw/excalidraw/pull/6172 // equation of an ellipse - https://github.com/excalidraw/excalidraw/pull/6172
return Math.round((height / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2; return Math.round((height / 2) * Math.sqrt(2)) - boundTextPadding * 2;
} }
if (container.type === "diamond") { if (container.type === "diamond") {
// The height of the largest rectangle inscribed inside a rhombus is // The height of the largest rectangle inscribed inside a rhombus is
// Math.round(height / 2) - https://github.com/excalidraw/excalidraw/pull/6265 // Math.round(height / 2) - https://github.com/excalidraw/excalidraw/pull/6265
return Math.round(height / 2) - BOUND_TEXT_PADDING * 2; return Math.round(height / 2) - boundTextPadding * 2;
} }
return height - BOUND_TEXT_PADDING * 2; return height - boundTextPadding * 2;
}; };
/** retrieves text from text elements and concatenates to a single string */ /** retrieves text from text elements and concatenates to a single string */
@@ -533,24 +536,3 @@ export const getTextFromElements = (
.join(separator); .join(separator);
return text; return text;
}; };
/** When text is already measured and wrapped, we want to respect those dimensions */
export const getInitialTextMetrics = (
text: NewTextElementOptions,
fontFamily: FontFamilyValues = DEFAULT_FONT_FAMILY,
fontSize: number = DEFAULT_FONT_SIZE,
) => {
const shouldUseProvidedDimensions =
text.autoResize === false && text.width && text.height;
return shouldUseProvidedDimensions
? {
width: text.width,
height: text.height,
}
: measureText(
text.text,
getFontString({ fontFamily, fontSize }),
text.lineHeight ?? getLineHeight(fontFamily),
);
};
+7 -4
View File
@@ -32,22 +32,24 @@ const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
export const getApproxMinLineWidth = ( export const getApproxMinLineWidth = (
font: FontString, font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"], lineHeight: ExcalidrawTextElement["lineHeight"],
boundTextPadding: number = BOUND_TEXT_PADDING,
) => { ) => {
const maxCharWidth = getMaxCharWidth(font); const maxCharWidth = getMaxCharWidth(font);
if (maxCharWidth === 0) { if (maxCharWidth === 0) {
return ( return (
measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width + measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
BOUND_TEXT_PADDING * 2 boundTextPadding * 2
); );
} }
return maxCharWidth + BOUND_TEXT_PADDING * 2; return maxCharWidth + boundTextPadding * 2;
}; };
export const getMinTextElementWidth = ( export const getMinTextElementWidth = (
font: FontString, font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"], lineHeight: ExcalidrawTextElement["lineHeight"],
boundTextPadding: number = BOUND_TEXT_PADDING,
) => { ) => {
return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2; return measureText("", font, lineHeight).width + boundTextPadding * 2;
}; };
export const isMeasureTextSupported = () => { export const isMeasureTextSupported = () => {
@@ -99,8 +101,9 @@ export const getLineHeightInPx = (
export const getApproxMinLineHeight = ( export const getApproxMinLineHeight = (
fontSize: ExcalidrawTextElement["fontSize"], fontSize: ExcalidrawTextElement["fontSize"],
lineHeight: ExcalidrawTextElement["lineHeight"], lineHeight: ExcalidrawTextElement["lineHeight"],
boundTextPadding: number = BOUND_TEXT_PADDING,
) => { ) => {
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2; return getLineHeightInPx(fontSize, lineHeight) + boundTextPadding * 2;
}; };
let textMetricsProvider: TextMetricsProvider | undefined; let textMetricsProvider: TextMetricsProvider | undefined;
+1 -1
View File
@@ -11,7 +11,6 @@ import type {
InteractiveCanvasAppState, InteractiveCanvasAppState,
Zoom, Zoom,
} from "@excalidraw/excalidraw/types"; } from "@excalidraw/excalidraw/types";
import type { Bounds } from "@excalidraw/common";
import { getElementAbsoluteCoords } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds";
import { import {
@@ -21,6 +20,7 @@ import {
isLinearElement, isLinearElement,
} from "./typeChecks"; } from "./typeChecks";
import type { Bounds } from "./bounds";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawElement, ExcalidrawElement,
+14 -7
View File
@@ -6,6 +6,7 @@ import type { ElementOrToolType } from "@excalidraw/excalidraw/types";
import type { MarkNonNullable } from "@excalidraw/common/utility-types"; import type { MarkNonNullable } from "@excalidraw/common/utility-types";
import type { Bounds } from "./bounds";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawTextElement, ExcalidrawTextElement,
@@ -271,15 +272,12 @@ export const isExcalidrawElement = (
} }
}; };
export const isFlowchartType = (type: string): boolean =>
["rectangle", "ellipse", "diamond"].includes(type);
export const isFlowchartNodeElement = ( export const isFlowchartNodeElement = (
element: ExcalidrawElement, element: ExcalidrawElement,
): element is ExcalidrawFlowchartNodeElement => { ): element is ExcalidrawFlowchartNodeElement => isFlowchartType(element.type);
return (
element.type === "rectangle" ||
element.type === "ellipse" ||
element.type === "diamond"
);
};
export const hasBoundTextElement = ( export const hasBoundTextElement = (
element: ExcalidrawElement | null, element: ExcalidrawElement | null,
@@ -355,6 +353,15 @@ export const getDefaultRoundnessTypeForElement = (
return null; return null;
}; };
// TODO: Move this to @excalidraw/math
export const isBounds = (box: unknown): box is Bounds =>
Array.isArray(box) &&
box.length === 4 &&
typeof box[0] === "number" &&
typeof box[1] === "number" &&
typeof box[2] === "number" &&
typeof box[3] === "number";
export const getLinearElementSubType = ( export const getLinearElementSubType = (
element: ExcalidrawLinearElement, element: ExcalidrawLinearElement,
): ExcalidrawLinearElementSubType => { ): ExcalidrawLinearElementSubType => {
+12 -3
View File
@@ -27,6 +27,10 @@ export type StrokeRoundness = "round" | "sharp";
export type RoundnessType = ValueOf<typeof ROUNDNESS>; export type RoundnessType = ValueOf<typeof ROUNDNESS>;
export type StrokeStyle = "solid" | "dashed" | "dotted"; export type StrokeStyle = "solid" | "dashed" | "dotted";
export type TextAlign = typeof TEXT_ALIGN[keyof typeof TEXT_ALIGN]; export type TextAlign = typeof TEXT_ALIGN[keyof typeof TEXT_ALIGN];
export type ContainerBehavior = {
textFlow: "growing" | "fixed";
margin?: number;
};
type VerticalAlignKeys = keyof typeof VERTICAL_ALIGN; type VerticalAlignKeys = keyof typeof VERTICAL_ALIGN;
export type VerticalAlign = typeof VERTICAL_ALIGN[VerticalAlignKeys]; export type VerticalAlign = typeof VERTICAL_ALIGN[VerticalAlignKeys];
@@ -79,21 +83,26 @@ type _ExcalidrawElementBase = Readonly<{
link: string | null; link: string | null;
locked: boolean; locked: boolean;
customData?: Record<string, any>; customData?: Record<string, any>;
containerBehavior?: ContainerBehavior;
}>; }>;
export type ExcalidrawSelectionElement = _ExcalidrawElementBase & { export type ExcalidrawSelectionElement = _ExcalidrawElementBase & {
type: "selection"; type: "selection";
}; };
export type ExcalidrawRectangleElement = _ExcalidrawElementBase & { type _ExcalidrawStickyNoteContainer = _ExcalidrawElementBase & {
containerBehavior: ContainerBehavior;
};
export type ExcalidrawRectangleElement = _ExcalidrawStickyNoteContainer & {
type: "rectangle"; type: "rectangle";
}; };
export type ExcalidrawDiamondElement = _ExcalidrawElementBase & { export type ExcalidrawDiamondElement = _ExcalidrawStickyNoteContainer & {
type: "diamond"; type: "diamond";
}; };
export type ExcalidrawEllipseElement = _ExcalidrawElementBase & { export type ExcalidrawEllipseElement = _ExcalidrawStickyNoteContainer & {
type: "ellipse"; type: "ellipse";
}; };
-1
View File
@@ -493,7 +493,6 @@ describe("binding for simple arrows", () => {
expect(arrow.endBinding?.elementId).toBe(rectRight.id); expect(arrow.endBinding?.elementId).toBe(rectRight.id);
mouse.downAt(-100, -100); mouse.downAt(-100, -100);
mouse.moveTo(0, 0);
mouse.moveTo(650, 750); mouse.moveTo(650, 750);
mouse.up(0, 0); mouse.up(0, 0);
+1 -1
View File
@@ -2,7 +2,6 @@ import { pointFrom } from "@excalidraw/math";
import { Excalidraw } from "@excalidraw/excalidraw"; import { Excalidraw } from "@excalidraw/excalidraw";
import { import {
type Bounds,
KEYS, KEYS,
getSizeFromPoints, getSizeFromPoints,
reseed, reseed,
@@ -23,6 +22,7 @@ import { resizeSingleElement } from "../src/resizeElements";
import { LinearElementEditor } from "../src/linearElementEditor"; import { LinearElementEditor } from "../src/linearElementEditor";
import { getElementPointsCoords } from "../src/bounds"; import { getElementPointsCoords } from "../src/bounds";
import type { Bounds } from "../src/bounds";
import type { import type {
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
+22 -10
View File
@@ -1,4 +1,4 @@
import { getLineHeight } from "@excalidraw/common"; import { BOUND_TEXT_PADDING, getLineHeight } from "@excalidraw/common";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "@excalidraw/common"; import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "@excalidraw/common";
@@ -63,9 +63,13 @@ describe("Test measureText", () => {
type: "rectangle", type: "rectangle",
...params, ...params,
}); });
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual( expect(
160, computeContainerDimensionForBoundText(
); 150,
element.type,
BOUND_TEXT_PADDING,
),
).toEqual(160);
}); });
it("should compute container height correctly for ellipse", () => { it("should compute container height correctly for ellipse", () => {
@@ -73,9 +77,13 @@ describe("Test measureText", () => {
type: "ellipse", type: "ellipse",
...params, ...params,
}); });
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual( expect(
226, computeContainerDimensionForBoundText(
); 150,
element.type,
BOUND_TEXT_PADDING,
),
).toEqual(226);
}); });
it("should compute container height correctly for diamond", () => { it("should compute container height correctly for diamond", () => {
@@ -83,9 +91,13 @@ describe("Test measureText", () => {
type: "diamond", type: "diamond",
...params, ...params,
}); });
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual( expect(
320, computeContainerDimensionForBoundText(
); 150,
element.type,
BOUND_TEXT_PADDING,
),
).toEqual(320);
}); });
}); });
@@ -236,6 +236,9 @@ export const actionWrapTextInContainer = register({
let updatedElements: readonly ExcalidrawElement[] = elements.slice(); let updatedElements: readonly ExcalidrawElement[] = elements.slice();
const containerIds: Mutable<AppState["selectedElementIds"]> = {}; const containerIds: Mutable<AppState["selectedElementIds"]> = {};
const boundTextPadding =
appState.currentItemContainerBehavior?.margin ?? BOUND_TEXT_PADDING;
for (const textElement of selectedElements) { for (const textElement of selectedElements) {
if (isTextElement(textElement) && !isBoundToContainer(textElement)) { if (isTextElement(textElement) && !isBoundToContainer(textElement)) {
const container = newElement({ const container = newElement({
@@ -261,18 +264,21 @@ export const actionWrapTextInContainer = register({
: null, : null,
opacity: 100, opacity: 100,
locked: false, locked: false,
x: textElement.x - BOUND_TEXT_PADDING, x: textElement.x - boundTextPadding,
y: textElement.y - BOUND_TEXT_PADDING, y: textElement.y - boundTextPadding,
width: computeContainerDimensionForBoundText( width: computeContainerDimensionForBoundText(
textElement.width, textElement.width,
"rectangle", "rectangle",
boundTextPadding,
), ),
height: computeContainerDimensionForBoundText( height: computeContainerDimensionForBoundText(
textElement.height, textElement.height,
"rectangle", "rectangle",
boundTextPadding,
), ),
groupIds: textElement.groupIds, groupIds: textElement.groupIds,
frameId: textElement.frameId, frameId: textElement.frameId,
containerBehavior: appState.currentItemContainerBehavior,
}); });
// update bindings // update bindings
+2 -12
View File
@@ -18,7 +18,6 @@ import {
KEYS, KEYS,
arrayToMap, arrayToMap,
invariant, invariant,
shouldRotateWithDiscreteAngle,
updateActiveTool, updateActiveTool,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { isPathALoop } from "@excalidraw/element"; import { isPathALoop } from "@excalidraw/element";
@@ -103,19 +102,10 @@ export const actionFinalize = register<FormData>({
return map; return map;
}, new Map()) ?? new Map(); }, new Map()) ?? new Map();
bindOrUnbindBindingElement( bindOrUnbindBindingElement(element, draggedPoints, scene, appState, {
element,
draggedPoints,
sceneCoords.x,
sceneCoords.y,
scene,
appState,
{
newArrow, newArrow,
altKey: event.altKey, altKey: event.altKey,
angleLocked: shouldRotateWithDiscreteAngle(event), });
},
);
} else if (isLineElement(element)) { } else if (isLineElement(element)) {
if ( if (
appState.selectedLinearElement?.isEditing && appState.selectedLinearElement?.isEditing &&
@@ -21,11 +21,16 @@ import {
getLineHeight, getLineHeight,
isTransparent, isTransparent,
reduceToCommonValue, reduceToCommonValue,
BOUND_TEXT_PADDING,
invariant, invariant,
FONT_SIZES,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element"; import {
canBecomePolygon,
getNonDeletedElements,
hasContainerBehavior,
isFlowchartNodeElement,
} from "@excalidraw/element";
import { import {
bindBindingElement, bindBindingElement,
@@ -66,6 +71,7 @@ import type { LocalPoint, Radians } from "@excalidraw/math";
import type { import type {
Arrowhead, Arrowhead,
ContainerBehavior,
ElementsMap, ElementsMap,
ExcalidrawBindableElement, ExcalidrawBindableElement,
ExcalidrawElement, ExcalidrawElement,
@@ -127,6 +133,11 @@ import {
ArrowheadCrowfootIcon, ArrowheadCrowfootIcon,
ArrowheadCrowfootOneIcon, ArrowheadCrowfootOneIcon,
ArrowheadCrowfootOneOrManyIcon, ArrowheadCrowfootOneOrManyIcon,
stickyNoteIcon,
growingContainerIcon,
marginLargeIcon,
marginMediumIcon,
marginSmallIcon,
} from "../components/icons"; } from "../components/icons";
import { Fonts } from "../fonts"; import { Fonts } from "../fonts";
@@ -759,25 +770,25 @@ export const actionChangeFontSize = register<ExcalidrawTextElement["fontSize"]>(
group="font-size" group="font-size"
options={[ options={[
{ {
value: FONT_SIZES.sm, value: 16,
text: t("labels.small"), text: t("labels.small"),
icon: FontSizeSmallIcon, icon: FontSizeSmallIcon,
testId: "fontSize-small", testId: "fontSize-small",
}, },
{ {
value: FONT_SIZES.md, value: 20,
text: t("labels.medium"), text: t("labels.medium"),
icon: FontSizeMediumIcon, icon: FontSizeMediumIcon,
testId: "fontSize-medium", testId: "fontSize-medium",
}, },
{ {
value: FONT_SIZES.lg, value: 28,
text: t("labels.large"), text: t("labels.large"),
icon: FontSizeLargeIcon, icon: FontSizeLargeIcon,
testId: "fontSize-large", testId: "fontSize-large",
}, },
{ {
value: FONT_SIZES.xl, value: 36,
text: t("labels.veryLarge"), text: t("labels.veryLarge"),
icon: FontSizeExtraLargeIcon, icon: FontSizeExtraLargeIcon,
testId: "fontSize-veryLarge", testId: "fontSize-veryLarge",
@@ -1549,6 +1560,275 @@ export const actionChangeRoundness = register<"sharp" | "round">({
}, },
}); });
const getMargin = (value: "small" | "medium" | "large") => {
switch (value) {
case "small":
return BOUND_TEXT_PADDING;
case "medium":
return 15;
case "large":
return 25;
default:
return BOUND_TEXT_PADDING;
}
};
const getMarginValue = (margin: number | null) => {
if (margin === null) {
return null;
}
switch (margin) {
case BOUND_TEXT_PADDING:
return "small";
case 15:
return "medium";
case 25:
return "large";
default:
return null;
}
};
export const actionChangeContainerBehavior = register<
| { textFlow: ContainerBehavior["textFlow"] }
| { margin: NonNullable<ReturnType<typeof getMarginValue>> }
>({
name: "changeContainerBehavior",
label: "labels.container",
trackEvent: false,
perform: (elements, appState, value, app) => {
invariant(value, "actionChangeContainerBehavior: value must be defined");
const elementsMap = app.scene.getNonDeletedElementsMap();
let selected = getSelectedElements(elements, appState, {
includeBoundTextElement: true,
});
if (selected.length === 0 && appState.editingTextElement) {
selected = [appState.editingTextElement];
}
const containerIdsToUpdate = new Set<string>();
// collect directly selected eligible containers
for (const el of selected) {
if (isFlowchartNodeElement(el)) {
containerIdsToUpdate.add(el.id);
}
}
// if none, and exactly one selected text element -> use its container if eligible
if (
containerIdsToUpdate.size === 0 &&
selected.length === 1 &&
isTextElement(selected[0]) &&
selected[0].containerId
) {
const container = elementsMap.get(selected[0].containerId);
if (
container &&
isFlowchartNodeElement(container) &&
getBoundTextElement(container, elementsMap)
) {
containerIdsToUpdate.add(container.id);
}
}
if ("margin" in value) {
const marginSize = getMargin(value.margin);
if (containerIdsToUpdate.size === 0) {
return {
appState: {
...appState,
currentItemContainerBehavior: {
textFlow:
appState.currentItemContainerBehavior?.textFlow ?? "growing",
margin: marginSize,
},
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
}
const nextElements = elements.map((el) =>
containerIdsToUpdate.has(el.id)
? newElementWith(el, {
containerBehavior: {
textFlow: el.containerBehavior?.textFlow ?? "growing",
margin: marginSize,
},
})
: el,
);
// Invalidate containers to trigger re-render
containerIdsToUpdate.forEach((id) => {
const container = nextElements.find((el) => el.id === id);
if (container) {
const boundText = getBoundTextElement(
container,
arrayToMap(nextElements),
);
if (boundText) {
redrawTextBoundingBox(boundText, container, app.scene);
}
}
});
return {
elements: nextElements,
appState: {
...appState,
currentItemContainerBehavior: {
textFlow:
appState.currentItemContainerBehavior?.textFlow ?? "growing",
margin: marginSize,
},
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
}
const textFlow = value.textFlow;
const nextElements = elements.map((el) =>
containerIdsToUpdate.has(el.id)
? newElementWith(el, {
containerBehavior: {
textFlow,
margin: el.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
},
})
: el,
);
return {
elements: nextElements,
appState: {
...appState,
currentItemContainerBehavior: {
textFlow,
margin:
appState.currentItemContainerBehavior?.margin ?? BOUND_TEXT_PADDING,
},
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => {
const elementsMap = app.scene.getNonDeletedElementsMap();
let selected = getSelectedElements(elements, appState, {
includeBoundTextElement: true,
});
if (selected.length === 0 && appState.editingTextElement) {
selected = [appState.editingTextElement];
}
let targetContainers: ExcalidrawElement[] = [];
// case 1: one text element selected -> target its container if eligible
if (
selected.length === 1 &&
isTextElement(selected[0]) &&
selected[0].containerId
) {
const container = elementsMap.get(selected[0].containerId);
if (
container &&
isFlowchartNodeElement(container) &&
getBoundTextElement(container, elementsMap)
) {
targetContainers = [container];
}
} else {
// case 2: any eligible containers directly selected
targetContainers = selected.filter((el) => isFlowchartNodeElement(el));
}
if (
targetContainers.length === 0 &&
!hasContainerBehavior(appState.activeTool.type)
) {
return null;
}
const textFlow =
targetContainers.length === 0
? appState.currentItemContainerBehavior?.textFlow ?? "growing"
: reduceToCommonValue(
targetContainers,
(el) => el.containerBehavior?.textFlow ?? "growing",
) ??
// mixed selection -> show null so nothing appears selected
null;
const marginValue =
targetContainers.length === 0
? appState.currentItemContainerBehavior?.margin ?? BOUND_TEXT_PADDING
: reduceToCommonValue(
targetContainers,
(el) => el.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
) ??
// mixed selection -> show null so nothing appears selected
null;
return (
<fieldset>
<legend>{t("labels.container")}</legend>
<div className="buttonList">
<RadioSelection
group="container"
options={[
{
value: "growing",
text: t("labels.container_growing"),
icon: growingContainerIcon,
},
{
value: "fixed",
text: t("labels.container_fixed"),
icon: stickyNoteIcon,
},
]}
value={
textFlow ??
(targetContainers.length
? null
: appState.currentItemContainerBehavior?.textFlow ?? "growing")
}
onChange={(val) => updateData({ textFlow: val })}
/>
</div>
<div className="buttonList">
<RadioSelection
group="container"
options={[
{
value: "small",
text: t("labels.container_margin_small"),
icon: marginSmallIcon,
},
{
value: "medium",
text: t("labels.container_margin_medium"),
icon: marginMediumIcon,
},
{
value: "large",
text: t("labels.container_margin_large"),
icon: marginLargeIcon,
},
]}
value={getMarginValue(
marginValue ??
(targetContainers.length
? null
: appState.currentItemContainerBehavior?.margin ??
BOUND_TEXT_PADDING),
)}
onChange={(val) => updateData({ margin: val })}
/>
</div>
</fieldset>
);
},
});
const getArrowheadOptions = (flip: boolean) => { const getArrowheadOptions = (flip: boolean) => {
return [ return [
{ {
+1
View File
@@ -19,6 +19,7 @@ export {
actionChangeTextAlign, actionChangeTextAlign,
actionChangeVerticalAlign, actionChangeVerticalAlign,
actionChangeArrowProperties, actionChangeArrowProperties,
actionChangeContainerBehavior,
} from "./actionProperties"; } from "./actionProperties";
export { export {
+1
View File
@@ -67,6 +67,7 @@ export type ActionName =
| "changeStrokeShape" | "changeStrokeShape"
| "changeSloppiness" | "changeSloppiness"
| "changeStrokeStyle" | "changeStrokeStyle"
| "changeContainerBehavior"
| "changeArrowhead" | "changeArrowhead"
| "changeArrowType" | "changeArrowType"
| "changeArrowProperties" | "changeArrowProperties"
+2
View File
@@ -42,6 +42,7 @@ export const getDefaultAppState = (): Omit<
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle, currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth, currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
currentItemTextAlign: DEFAULT_TEXT_ALIGN, currentItemTextAlign: DEFAULT_TEXT_ALIGN,
currentItemContainerBehavior: DEFAULT_ELEMENT_PROPS.containerBehavior,
currentHoveredFontFamily: null, currentHoveredFontFamily: null,
cursorButton: "up", cursorButton: "up",
activeEmbeddable: null, activeEmbeddable: null,
@@ -173,6 +174,7 @@ const APP_STATE_STORAGE_CONF = (<
currentItemStrokeStyle: { browser: true, export: false, server: false }, currentItemStrokeStyle: { browser: true, export: false, server: false },
currentItemStrokeWidth: { browser: true, export: false, server: false }, currentItemStrokeWidth: { browser: true, export: false, server: false },
currentItemTextAlign: { browser: true, export: false, server: false }, currentItemTextAlign: { browser: true, export: false, server: false },
currentItemContainerBehavior: { browser: true, export: false, server: false },
currentHoveredFontFamily: { browser: false, export: false, server: false }, currentHoveredFontFamily: { browser: false, export: false, server: false },
cursorButton: { browser: true, export: false, server: false }, cursorButton: { browser: true, export: false, server: false },
activeEmbeddable: { browser: false, export: false, server: false }, activeEmbeddable: { browser: false, export: false, server: false },
+14 -2
View File
@@ -9,7 +9,7 @@ import {
VERTICAL_ALIGN, VERTICAL_ALIGN,
randomId, randomId,
isDevEnv, isDevEnv,
FONT_SIZES, BOUND_TEXT_PADDING,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
@@ -214,7 +214,7 @@ const chartXLabels = (
y: y + BAR_GAP / 2, y: y + BAR_GAP / 2,
width: BAR_WIDTH, width: BAR_WIDTH,
angle: 5.87 as Radians, angle: 5.87 as Radians,
fontSize: FONT_SIZES.sm, fontSize: 16,
textAlign: "center", textAlign: "center",
verticalAlign: "top", verticalAlign: "top",
}); });
@@ -335,6 +335,10 @@ const chartBaseElements = (
strokeColor: COLOR_PALETTE.black, strokeColor: COLOR_PALETTE.black,
fillStyle: "solid", fillStyle: "solid",
opacity: 6, opacity: 6,
containerBehavior: {
textFlow: "growing",
margin: BOUND_TEXT_PADDING,
},
}) })
: null; : null;
@@ -367,6 +371,10 @@ const chartTypeBar = (
y: y - barHeight - BAR_GAP, y: y - barHeight - BAR_GAP,
width: BAR_WIDTH, width: BAR_WIDTH,
height: barHeight, height: barHeight,
containerBehavior: {
textFlow: "growing",
margin: BOUND_TEXT_PADDING,
},
}); });
}); });
@@ -433,6 +441,10 @@ const chartTypeLine = (
y: y + cy - BAR_GAP * 2, y: y + cy - BAR_GAP * 2,
width: BAR_GAP, width: BAR_GAP,
height: BAR_GAP, height: BAR_GAP,
containerBehavior: {
textFlow: "growing",
margin: BOUND_TEXT_PADDING,
},
}); });
}); });
+1
View File
@@ -113,6 +113,7 @@ export const createPasteEvent = ({
if (typeof value !== "string") { if (typeof value !== "string") {
files = files || []; files = files || [];
files.push(value); files.push(value);
event.clipboardData?.items.add(value);
continue; continue;
} }
try { try {
@@ -10,6 +10,9 @@ import {
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
getContainerElement,
hasContainerBehavior,
isFlowchartNodeElement,
shouldAllowVerticalAlign, shouldAllowVerticalAlign,
suppportsHorizontalAlign, suppportsHorizontalAlign,
hasBoundTextElement, hasBoundTextElement,
@@ -155,6 +158,15 @@ export const SelectedShapeActions = ({
) { ) {
isSingleElementBoundContainer = true; isSingleElementBoundContainer = true;
} }
const textContainer =
targetElements.length === 1 && isTextElement(targetElements[0])
? getContainerElement(targetElements[0], elementsMap)
: null;
const isStickyNoteContainer =
textContainer && isFlowchartNodeElement(textContainer);
const isEditingTextOrNewElement = Boolean( const isEditingTextOrNewElement = Boolean(
appState.editingTextElement || appState.newElement, appState.editingTextElement || appState.newElement,
); );
@@ -218,6 +230,11 @@ export const SelectedShapeActions = ({
<>{renderAction("changeRoundness")}</> <>{renderAction("changeRoundness")}</>
)} )}
{(hasContainerBehavior(appState.activeTool.type) ||
targetElements.some((element) => isFlowchartNodeElement(element))) && (
<>{renderAction("changeContainerBehavior")}</>
)}
{(toolIsArrow(appState.activeTool.type) || {(toolIsArrow(appState.activeTool.type) ||
targetElements.some((element) => toolIsArrow(element.type))) && ( targetElements.some((element) => toolIsArrow(element.type))) && (
<>{renderAction("changeArrowType")}</> <>{renderAction("changeArrowType")}</>
@@ -241,6 +258,8 @@ export const SelectedShapeActions = ({
<>{renderAction("changeArrowhead")}</> <>{renderAction("changeArrowhead")}</>
)} )}
{isStickyNoteContainer && <>{renderAction("changeContainerBehavior")}</>}
{renderAction("changeOpacity")} {renderAction("changeOpacity")}
<fieldset> <fieldset>
@@ -408,6 +427,10 @@ const CombinedShapeProperties = ({
canChangeRoundness(element.type), canChangeRoundness(element.type),
)) && )) &&
renderAction("changeRoundness")} renderAction("changeRoundness")}
{(hasContainerBehavior(appState.activeTool.type) ||
targetElements.some((element) =>
isFlowchartNodeElement(element),
)) && <>{renderAction("changeContainerBehavior")}</>}
{renderAction("changeOpacity")} {renderAction("changeOpacity")}
</div> </div>
</PropertiesPopover> </PropertiesPopover>
@@ -532,6 +555,14 @@ const CombinedTextProperties = ({
const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus(); const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus();
const isOpen = appState.openPopup === "compactTextProperties"; const isOpen = appState.openPopup === "compactTextProperties";
const textContainer =
targetElements.length === 1 && isTextElement(targetElements[0])
? getContainerElement(targetElements[0], elementsMap)
: null;
const isStickyNoteContainer =
textContainer && isFlowchartNodeElement(textContainer);
return ( return (
<div className="compact-action-item"> <div className="compact-action-item">
<Popover.Root <Popover.Root
@@ -597,6 +628,9 @@ const CombinedTextProperties = ({
renderAction("changeTextAlign")} renderAction("changeTextAlign")}
{shouldAllowVerticalAlign(targetElements, elementsMap) && {shouldAllowVerticalAlign(targetElements, elementsMap) &&
renderAction("changeVerticalAlign")} renderAction("changeVerticalAlign")}
{isStickyNoteContainer && (
<>{renderAction("changeContainerBehavior")}</>
)}
</div> </div>
</PropertiesPopover> </PropertiesPopover>
)} )}
+15 -19
View File
@@ -96,6 +96,7 @@ import {
MINIMUM_ARROW_SIZE, MINIMUM_ARROW_SIZE,
DOUBLE_TAP_POSITION_THRESHOLD, DOUBLE_TAP_POSITION_THRESHOLD,
BIND_MODE_TIMEOUT, BIND_MODE_TIMEOUT,
BOUND_TEXT_PADDING,
invariant, invariant,
getFeatureFlag, getFeatureFlag,
createUserAgentDescriptor, createUserAgentDescriptor,
@@ -246,10 +247,9 @@ import {
mutateElement, mutateElement,
getElementBounds, getElementBounds,
doBoundsIntersect, doBoundsIntersect,
isFlowchartType,
isPointInElement, isPointInElement,
maxBindingDistance_simple, maxBindingDistance_simple,
convertToExcalidrawElements,
type ExcalidrawElementSkeleton,
} from "@excalidraw/element"; } from "@excalidraw/element";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
@@ -397,6 +397,7 @@ import {
SnapCache, SnapCache,
isGridModeEnabled, isGridModeEnabled,
} from "../snapping"; } from "../snapping";
import { convertToExcalidrawElements } from "../data/transform";
import { Renderer } from "../scene/Renderer"; import { Renderer } from "../scene/Renderer";
import { import {
setEraserCursor, setEraserCursor,
@@ -458,7 +459,7 @@ import type { ClipboardData, PastedMixedContent } from "../clipboard";
import type { ExportedElements } from "../data"; import type { ExportedElements } from "../data";
import type { ContextMenuItems } from "./ContextMenu"; import type { ContextMenuItems } from "./ContextMenu";
import type { FileSystemHandle } from "../data/filesystem"; import type { FileSystemHandle } from "../data/filesystem";
import type { ExcalidrawElementSkeleton } from "../data/transform";
import type { import type {
AppClassProperties, AppClassProperties,
AppProps, AppProps,
@@ -5777,8 +5778,13 @@ class App extends React.Component<AppProps, AppState> {
const minWidth = getApproxMinLineWidth( const minWidth = getApproxMinLineWidth(
getFontString(fontString), getFontString(fontString),
lineHeight, lineHeight,
container.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
);
const minHeight = getApproxMinLineHeight(
fontSize,
lineHeight,
container.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
); );
const minHeight = getApproxMinLineHeight(fontSize, lineHeight);
const newHeight = Math.max(container.height, minHeight); const newHeight = Math.max(container.height, minHeight);
const newWidth = Math.max(container.width, minWidth); const newWidth = Math.max(container.width, minWidth);
this.scene.mutateElement(container, { this.scene.mutateElement(container, {
@@ -8618,16 +8624,9 @@ class App extends React.Component<AppProps, AppState> {
}, },
], ],
]), ]),
point[0],
point[1],
this.scene, this.scene,
this.state, this.state,
{ { newArrow: true, altKey: event.altKey, initialBinding: true },
newArrow: true,
altKey: event.altKey,
initialBinding: true,
angleLocked: shouldRotateWithDiscreteAngle(event.nativeEvent),
},
); );
} }
@@ -8729,6 +8728,9 @@ class App extends React.Component<AppProps, AppState> {
roundness: this.getCurrentItemRoundness(elementType), roundness: this.getCurrentItemRoundness(elementType),
locked: false, locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null, frameId: topLayerFrame ? topLayerFrame.id : null,
...(isFlowchartType(elementType) && {
containerBehavior: this.state.currentItemContainerBehavior,
}),
} as const; } as const;
let element; let element;
@@ -11471,11 +11473,7 @@ class App extends React.Component<AppProps, AppState> {
): void => { ): void => {
const selectionElement = this.state.selectionElement; const selectionElement = this.state.selectionElement;
const pointerCoords = pointerDownState.lastCoords; const pointerCoords = pointerDownState.lastCoords;
if ( if (selectionElement && this.state.activeTool.type !== "eraser") {
selectionElement &&
pointerDownState.boxSelection.hasOccurred &&
this.state.activeTool.type !== "eraser"
) {
dragNewElement({ dragNewElement({
newElement: selectionElement, newElement: selectionElement,
elementType: this.state.activeTool.type, elementType: this.state.activeTool.type,
@@ -11539,7 +11537,6 @@ class App extends React.Component<AppProps, AppState> {
snapLines, snapLines,
}); });
if (!isBindingElement(newElement)) {
dragNewElement({ dragNewElement({
newElement, newElement,
elementType: this.state.activeTool.type, elementType: this.state.activeTool.type,
@@ -11559,7 +11556,6 @@ class App extends React.Component<AppProps, AppState> {
originOffset: this.state.originSnapOffset, originOffset: this.state.originSnapOffset,
informMutation, informMutation,
}); });
}
this.setState({ this.setState({
newElement, newElement,
@@ -6,7 +6,7 @@ import { hitElementBoundingBox } from "@excalidraw/element";
import type { GlobalPoint, Radians } from "@excalidraw/math"; import type { GlobalPoint, Radians } from "@excalidraw/math";
import type { Bounds } from "@excalidraw/common"; import type { Bounds } from "@excalidraw/element";
import type { import type {
ElementsMap, ElementsMap,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
+98
View File
@@ -2373,3 +2373,101 @@ export const presentationIcon = createIcon(
</g>, </g>,
tablerIconProps, tablerIconProps,
); );
export const stickyNoteIcon = createIcon(
<g>
<path d="M16 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8Z" />
<path d="M15 3v4a2 2 0 0 0 2 2h4" />
</g>,
tablerIconProps,
);
export const marginSmallIcon = createIcon(
<g fill="none">
<rect
x="0"
y="0"
width="24"
height="24"
stroke="currentColor"
stroke-width="1.5"
/>
<rect
x="2"
y="2"
width="20"
height="20"
stroke="currentColor"
stroke-width="1"
stroke-dasharray="1 1"
/>
</g>,
tablerIconProps,
);
export const marginMediumIcon = createIcon(
<g fill="none">
<rect
x="0"
y="0"
width="24"
height="24"
stroke="currentColor"
stroke-width="1.5"
/>
<rect
x="4"
y="4"
width="16"
height="16"
stroke="currentColor"
stroke-width="1"
stroke-dasharray="1 1"
/>
</g>,
tablerIconProps,
);
export const marginLargeIcon = createIcon(
<g fill="none">
<rect
x="0"
y="0"
width="24"
height="24"
stroke="currentColor"
stroke-width="1.5"
/>
<rect
x="6"
y="6"
width="12"
height="12"
stroke="currentColor"
stroke-width="1"
stroke-dasharray="1 1"
/>
</g>,
tablerIconProps,
);
export const growingContainerIcon = createIcon(
<g
stroke="currentColor"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
>
<path d="M14 21h1" />
<path d="M21 14v1" />
<path d="M21 19a2 2 0 0 1-2 2" />
<path d="M21 9v1" />
<path d="M3 14v1" />
<path d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2" />
<path d="M3 9v1" />
<path d="M5 21a2 2 0 0 1-2-2" />
<path d="M9 21h1" />
</g>,
tablerIconProps,
);
+6 -6
View File
@@ -222,7 +222,7 @@ function dataView(
* *
* @param buffers each buffer (chunk) must be at most 2^32 bits large (~4GB) * @param buffers each buffer (chunk) must be at most 2^32 bits large (~4GB)
*/ */
const concatBuffers = (...buffers: Uint8Array[]): Uint8Array<ArrayBuffer> => { const concatBuffers = (...buffers: Uint8Array[]) => {
const bufferView = new Uint8Array( const bufferView = new Uint8Array(
VERSION_DATAVIEW_BYTES + VERSION_DATAVIEW_BYTES +
NEXT_CHUNK_SIZE_DATAVIEW_BYTES * buffers.length + NEXT_CHUNK_SIZE_DATAVIEW_BYTES * buffers.length +
@@ -295,12 +295,12 @@ const splitBuffers = (concatenatedBuffer: Uint8Array) => {
/** @private */ /** @private */
const _encryptAndCompress = async ( const _encryptAndCompress = async (
data: Uint8Array<ArrayBuffer> | string, data: Uint8Array | string,
encryptionKey: string, encryptionKey: string,
) => { ) => {
const { encryptedBuffer, iv } = await encryptData( const { encryptedBuffer, iv } = await encryptData(
encryptionKey, encryptionKey,
deflate(data) as Uint8Array<ArrayBuffer>, deflate(data),
); );
return { iv, buffer: new Uint8Array(encryptedBuffer) }; return { iv, buffer: new Uint8Array(encryptedBuffer) };
@@ -330,7 +330,7 @@ export const compressData = async <T extends Record<string, any> = never>(
: { : {
metadata: T; metadata: T;
}), }),
): Promise<Uint8Array<ArrayBuffer>> => { ): Promise<Uint8Array> => {
const fileInfo: FileEncodingInfo = { const fileInfo: FileEncodingInfo = {
version: 2, version: 2,
compression: "pako@1", compression: "pako@1",
@@ -355,8 +355,8 @@ export const compressData = async <T extends Record<string, any> = never>(
/** @private */ /** @private */
const _decryptAndDecompress = async ( const _decryptAndDecompress = async (
iv: Uint8Array<ArrayBuffer>, iv: Uint8Array,
decryptedBuffer: Uint8Array<ArrayBuffer>, decryptedBuffer: Uint8Array,
decryptionKey: string, decryptionKey: string,
isCompressed: boolean, isCompressed: boolean,
) => { ) => {
+7 -7
View File
@@ -4,7 +4,7 @@ import { blobToArrayBuffer } from "./blob";
export const IV_LENGTH_BYTES = 12; export const IV_LENGTH_BYTES = 12;
export const createIV = (): Uint8Array<ArrayBuffer> => { export const createIV = () => {
const arr = new Uint8Array(IV_LENGTH_BYTES); const arr = new Uint8Array(IV_LENGTH_BYTES);
return window.crypto.getRandomValues(arr); return window.crypto.getRandomValues(arr);
}; };
@@ -49,12 +49,12 @@ export const getCryptoKey = (key: string, usage: KeyUsage) =>
export const encryptData = async ( export const encryptData = async (
key: string | CryptoKey, key: string | CryptoKey,
data: Uint8Array<ArrayBuffer> | ArrayBuffer | Blob | File | string, data: Uint8Array | ArrayBuffer | Blob | File | string,
): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array<ArrayBuffer> }> => { ): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array }> => {
const importedKey = const importedKey =
typeof key === "string" ? await getCryptoKey(key, "encrypt") : key; typeof key === "string" ? await getCryptoKey(key, "encrypt") : key;
const iv = createIV(); const iv = createIV();
const buffer: ArrayBuffer | Uint8Array<ArrayBuffer> = const buffer: ArrayBuffer | Uint8Array =
typeof data === "string" typeof data === "string"
? new TextEncoder().encode(data) ? new TextEncoder().encode(data)
: data instanceof Uint8Array : data instanceof Uint8Array
@@ -71,15 +71,15 @@ export const encryptData = async (
iv, iv,
}, },
importedKey, importedKey,
buffer, buffer as ArrayBuffer | Uint8Array,
); );
return { encryptedBuffer, iv }; return { encryptedBuffer, iv };
}; };
export const decryptData = async ( export const decryptData = async (
iv: Uint8Array<ArrayBuffer>, iv: Uint8Array,
encrypted: Uint8Array<ArrayBuffer> | ArrayBuffer, encrypted: Uint8Array | ArrayBuffer,
privateKey: string, privateKey: string,
): Promise<ArrayBuffer> => { ): Promise<ArrayBuffer> => {
const key = await getCryptoKey(privateKey, "decrypt"); const key = await getCryptoKey(privateKey, "decrypt");
+48 -39
View File
@@ -17,10 +17,12 @@ import {
getSizeFromPoints, getSizeFromPoints,
normalizeLink, normalizeLink,
getLineHeight, getLineHeight,
BOUND_TEXT_PADDING,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
calculateFixedPointForNonElbowArrowBinding, calculateFixedPointForNonElbowArrowBinding,
getNonDeletedElements, getNonDeletedElements,
isFlowchartType,
isPointInElement, isPointInElement,
isValidPolygon, isValidPolygon,
projectFixedPointOntoDiagonal, projectFixedPointOntoDiagonal,
@@ -56,11 +58,11 @@ import type { LocalPoint, Radians } from "@excalidraw/math";
import type { import type {
ElementsMap, ElementsMap,
ElementsMapOrArray,
ExcalidrawArrowElement, ExcalidrawArrowElement,
ExcalidrawBindableElement, ExcalidrawBindableElement,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawElementType,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawSelectionElement, ExcalidrawSelectionElement,
ExcalidrawTextElement, ExcalidrawTextElement,
@@ -130,8 +132,7 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
const repairBinding = <T extends ExcalidrawArrowElement>( const repairBinding = <T extends ExcalidrawArrowElement>(
element: T, element: T,
binding: FixedPointBinding | null, binding: FixedPointBinding | null,
targetElementsMap: Readonly<ElementsMap>, elementsMap: Readonly<ElementsMap>,
localElementsMap: Readonly<ElementsMap> | null | undefined,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
): FixedPointBinding | null => { ): FixedPointBinding | null => {
if (!binding) { if (!binding) {
@@ -150,27 +151,18 @@ const repairBinding = <T extends ExcalidrawArrowElement>(
return fixedPointBinding; return fixedPointBinding;
} }
// Fallback if the bound element is missing but the binding is at least const boundElement =
// looking like a valid one shape-wise (elementsMap.get(binding.elementId) as ExcalidrawBindableElement) ||
if (binding.mode && binding.fixedPoint && binding.elementId) { undefined;
if (boundElement) {
if (binding.mode) {
return { return {
elementId: binding.elementId, elementId: binding.elementId,
mode: binding.mode, mode: binding.mode || "orbit",
fixedPoint: normalizeFixedPoint(binding.fixedPoint || [0.5, 0.5]), fixedPoint: normalizeFixedPoint(binding.fixedPoint || [0.5, 0.5]),
} as FixedPointBinding | null; } as FixedPointBinding | null;
} }
const targetBoundElement =
(targetElementsMap.get(binding.elementId) as ExcalidrawBindableElement) ||
undefined;
const boundElement =
targetBoundElement ||
(localElementsMap?.get(binding.elementId) as ExcalidrawBindableElement) ||
undefined;
const elementsMap = targetBoundElement ? targetElementsMap : localElementsMap;
// migrating legacy focus point bindings
if (boundElement && elementsMap) {
const p = LinearElementEditor.getPointAtIndexGlobalCoordinates( const p = LinearElementEditor.getPointAtIndexGlobalCoordinates(
element, element,
startOrEnd === "start" ? 0 : element.points.length - 1, startOrEnd === "start" ? 0 : element.points.length - 1,
@@ -204,19 +196,30 @@ const repairBinding = <T extends ExcalidrawArrowElement>(
}; };
} }
console.error(`could not repair binding for element`);
return null; return null;
}; };
const restoreElementWithProperties = < type _ElementForRestoreBase = Required<
T extends Required<Omit<ExcalidrawElement, "customData">> & { Omit<ExcalidrawElement, "customData" | "containerBehavior">
> &
Pick<ExcalidrawElement, "containerBehavior"> & {
customData?: ExcalidrawElement["customData"]; customData?: ExcalidrawElement["customData"];
/** @deprecated */ /** @deprecated */
boundElementIds?: readonly ExcalidrawElement["id"][]; boundElementIds?: readonly ExcalidrawElement["id"][];
/** @deprecated */ /** @deprecated */
strokeSharpness?: StrokeRoundness; strokeSharpness?: StrokeRoundness;
}, };
const restoreElementWithProperties = <
T extends _ElementForRestoreBase &
Omit<
any,
| keyof ExcalidrawElement
| "boundElementIds"
| "strokeSharpness"
| "customData"
| "containerBehavior"
>,
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>, K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
>( >(
element: T, element: T,
@@ -228,11 +231,13 @@ const restoreElementWithProperties = <
> & > &
Partial<Pick<ExcalidrawElement, "type" | "x" | "y" | "customData">>, Partial<Pick<ExcalidrawElement, "type" | "x" | "y" | "customData">>,
): T => { ): T => {
const nextType = (extra.type || element.type) as ExcalidrawElementType;
const base: Pick<T, keyof ExcalidrawElement> = { const base: Pick<T, keyof ExcalidrawElement> = {
type: extra.type || element.type, type: nextType,
version: element.version || 1,
// all elements must have version > 0 so getSceneVersion() will pick up // all elements must have version > 0 so getSceneVersion() will pick up
// newly added elements // newly added elements
version: element.version || 1,
versionNonce: element.versionNonce ?? 0, versionNonce: element.versionNonce ?? 0,
index: element.index ?? null, index: element.index ?? null,
isDeleted: element.isDeleted ?? false, isDeleted: element.isDeleted ?? false,
@@ -260,7 +265,7 @@ const restoreElementWithProperties = <
? { ? {
// for old elements that would now use adaptive radius algo, // for old elements that would now use adaptive radius algo,
// use legacy algo instead // use legacy algo instead
type: isUsingAdaptiveRadius(element.type) type: isUsingAdaptiveRadius(nextType)
? ROUNDNESS.LEGACY ? ROUNDNESS.LEGACY
: ROUNDNESS.PROPORTIONAL_RADIUS, : ROUNDNESS.PROPORTIONAL_RADIUS,
} }
@@ -271,10 +276,18 @@ const restoreElementWithProperties = <
updated: element.updated ?? getUpdatedTimestamp(), updated: element.updated ?? getUpdatedTimestamp(),
link: element.link ? normalizeLink(element.link) : null, link: element.link ? normalizeLink(element.link) : null,
locked: element.locked ?? false, locked: element.locked ?? false,
...(isFlowchartType(nextType)
? {
containerBehavior: {
textFlow: element.containerBehavior?.textFlow ?? "growing",
margin: element.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
},
}
: {}),
}; };
if ("customData" in element || "customData" in extra) { if ("customData" in element || "customData" in extra) {
base.customData = (base as any).customData =
"customData" in extra ? extra.customData : element.customData; "customData" in extra ? extra.customData : element.customData;
} }
@@ -289,16 +302,15 @@ const restoreElementWithProperties = <
} as unknown as T; } as unknown as T;
// strip legacy props (migrated in previous steps) // strip legacy props (migrated in previous steps)
delete ret.strokeSharpness; delete (ret as any).strokeSharpness;
delete ret.boundElementIds; delete (ret as any).boundElementIds;
return ret; return ret;
}; };
export const restoreElement = ( export const restoreElement = (
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>, element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
targetElementsMap: Readonly<ElementsMap>, elementsMap: Readonly<ElementsMap>,
localElementsMap: Readonly<ElementsMap> | null | undefined,
opts?: { opts?: {
deleteInvisibleElements?: boolean; deleteInvisibleElements?: boolean;
}, },
@@ -419,15 +431,13 @@ export const restoreElement = (
startBinding: repairBinding( startBinding: repairBinding(
element as ExcalidrawArrowElement, element as ExcalidrawArrowElement,
element.startBinding, element.startBinding,
targetElementsMap, elementsMap,
localElementsMap,
"start", "start",
), ),
endBinding: repairBinding( endBinding: repairBinding(
element as ExcalidrawArrowElement, element as ExcalidrawArrowElement,
element.endBinding, element.endBinding,
targetElementsMap, elementsMap,
localElementsMap,
"end", "end",
), ),
startArrowhead, startArrowhead,
@@ -591,7 +601,7 @@ const repairFrameMembership = (
export const restoreElements = ( export const restoreElements = (
targetElements: ImportedDataState["elements"], targetElements: ImportedDataState["elements"],
/** NOTE doesn't serve for reconciliation */ /** NOTE doesn't serve for reconciliation */
localElements: Readonly<ElementsMapOrArray> | null | undefined, localElements: readonly ExcalidrawElement[] | null | undefined,
opts?: opts?:
| { | {
refreshDimensions?: boolean; refreshDimensions?: boolean;
@@ -602,7 +612,7 @@ export const restoreElements = (
): OrderedExcalidrawElement[] => { ): OrderedExcalidrawElement[] => {
// used to detect duplicate top-level element ids // used to detect duplicate top-level element ids
const existingIds = new Set<string>(); const existingIds = new Set<string>();
const targetElementsMap = arrayToMap(targetElements || []); const elementsMap = arrayToMap(targetElements || []);
const localElementsMap = localElements ? arrayToMap(localElements) : null; const localElementsMap = localElements ? arrayToMap(localElements) : null;
const restoredElements = syncInvalidIndices( const restoredElements = syncInvalidIndices(
(targetElements || []).reduce((elements, element) => { (targetElements || []).reduce((elements, element) => {
@@ -614,8 +624,7 @@ export const restoreElements = (
let migratedElement: ExcalidrawElement | null = restoreElement( let migratedElement: ExcalidrawElement | null = restoreElement(
element, element,
targetElementsMap, elementsMap,
localElementsMap,
{ {
deleteInvisibleElements: opts?.deleteInvisibleElements, deleteInvisibleElements: opts?.deleteInvisibleElements,
}, },
@@ -1,12 +1,11 @@
import { pointFrom } from "@excalidraw/math"; import { pointFrom } from "@excalidraw/math";
import { vi } from "vitest"; import { vi } from "vitest";
import { import type { ExcalidrawArrowElement } from "@excalidraw/element/types";
convertToExcalidrawElements,
type ExcalidrawElementSkeleton,
} from "../transform";
import type { ExcalidrawArrowElement } from "../types"; import { convertToExcalidrawElements } from "./transform";
import type { ExcalidrawElementSkeleton } from "./transform";
const opts = { regenerateIds: false }; const opts = { regenerateIds: false };
@@ -10,13 +10,13 @@ import {
arrayToMap, arrayToMap,
assertNever, assertNever,
cloneJSON, cloneJSON,
getFontString,
isDevEnv, isDevEnv,
toBrandedType, toBrandedType,
getLineHeight,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { MarkOptional } from "@excalidraw/common/utility-types"; import { bindBindingElement } from "@excalidraw/element";
import { bindBindingElement } from "./binding";
import { import {
newArrowElement, newArrowElement,
newElement, newElement,
@@ -25,19 +25,21 @@ import {
newLinearElement, newLinearElement,
newMagicFrameElement, newMagicFrameElement,
newTextElement, newTextElement,
type ElementConstructorOpts, } from "@excalidraw/element";
} from "./newElement"; import { measureText, normalizeText } from "@excalidraw/element";
import { isArrowElement } from "./typeChecks"; import { isArrowElement } from "@excalidraw/element";
import { syncInvalidIndices } from "./fractionalIndex"; import { syncInvalidIndices } from "@excalidraw/element";
import { getInitialTextMetrics, redrawTextBoundingBox } from "./textElement"; import { redrawTextBoundingBox } from "@excalidraw/element";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "@excalidraw/element";
import { getCommonBounds } from "./bounds"; import { getCommonBounds } from "@excalidraw/element";
import { Scene } from "./Scene"; import { Scene } from "@excalidraw/element";
import type { ElementConstructorOpts } from "@excalidraw/element";
import type { import type {
ExcalidrawArrowElement, ExcalidrawArrowElement,
@@ -57,7 +59,9 @@ import type {
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
TextAlign, TextAlign,
VerticalAlign, VerticalAlign,
} from "./types"; } from "@excalidraw/element/types";
import type { MarkOptional } from "@excalidraw/common/utility-types";
export type ValidLinearElement = { export type ValidLinearElement = {
type: "arrow" | "line"; type: "arrow" | "line";
@@ -576,7 +580,14 @@ export const convertToExcalidrawElements = (
case "text": { case "text": {
const fontFamily = element?.fontFamily || DEFAULT_FONT_FAMILY; const fontFamily = element?.fontFamily || DEFAULT_FONT_FAMILY;
const fontSize = element?.fontSize || DEFAULT_FONT_SIZE; const fontSize = element?.fontSize || DEFAULT_FONT_SIZE;
const metrics = getInitialTextMetrics(element, fontFamily, fontSize); const lineHeight = element?.lineHeight || getLineHeight(fontFamily);
const text = element.text ?? "";
const normalizedText = normalizeText(text);
const metrics = measureText(
normalizedText,
getFontString({ fontFamily, fontSize }),
lineHeight,
);
excalidrawElement = newTextElement({ excalidrawElement = newTextElement({
width: metrics.width, width: metrics.width,
+1 -1
View File
@@ -28,7 +28,7 @@ import { shouldTestInside } from "@excalidraw/element";
import { hasBoundTextElement, isBoundToContainer } from "@excalidraw/element"; import { hasBoundTextElement, isBoundToContainer } from "@excalidraw/element";
import { getBoundTextElementId } from "@excalidraw/element"; import { getBoundTextElementId } from "@excalidraw/element";
import type { Bounds } from "@excalidraw/common"; import type { Bounds } from "@excalidraw/element";
import type { GlobalPoint, LineSegment } from "@excalidraw/math/types"; import type { GlobalPoint, LineSegment } from "@excalidraw/math/types";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types"; import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
+1 -2
View File
@@ -4,7 +4,6 @@ import {
CJK_HAND_DRAWN_FALLBACK_FONT, CJK_HAND_DRAWN_FALLBACK_FONT,
WINDOWS_EMOJI_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT,
getFontFamilyFallbacks, getFontFamilyFallbacks,
FONT_SIZES,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { getContainerElement } from "@excalidraw/element"; import { getContainerElement } from "@excalidraw/element";
import { charWidth } from "@excalidraw/element"; import { charWidth } from "@excalidraw/element";
@@ -241,7 +240,7 @@ export class Fonts {
for (const [index, fontFamily] of fontFamilies.entries()) { for (const [index, fontFamily] of fontFamilies.entries()) {
const font = getFontString({ const font = getFontString({
fontFamily, fontFamily,
fontSize: FONT_SIZES.sm, fontSize: 16,
}); });
// 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! // 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!
+1 -1
View File
@@ -42,7 +42,7 @@ declare module "png-chunk-text" {
function decode(data: Uint8Array): { keyword: string; text: string }; function decode(data: Uint8Array): { keyword: string; text: string };
} }
declare module "png-chunks-encode" { declare module "png-chunks-encode" {
function encode(chunks: TEXtChunk[]): Uint8Array<ArrayBuffer>; function encode(chunks: TEXtChunk[]): Uint8Array;
export = encode; export = encode;
} }
declare module "png-chunks-extract" { declare module "png-chunks-extract" {
+1 -1
View File
@@ -18,7 +18,7 @@ export function useOutsideClick<T extends HTMLElement>(
* Returning `undefined` will fallback to the default behavior. * Returning `undefined` will fallback to the default behavior.
*/ */
isInside?: ( isInside?: (
event: Event & { target: T }, event: Event & { target: HTMLElement },
/** the element of the passed ref */ /** the element of the passed ref */
container: T, container: T,
) => boolean | undefined, ) => boolean | undefined,
+2 -5
View File
@@ -293,11 +293,8 @@ export { TTDDialog } from "./components/TTDDialog/TTDDialog";
export { TTDDialogTrigger } from "./components/TTDDialog/TTDDialogTrigger"; export { TTDDialogTrigger } from "./components/TTDDialog/TTDDialogTrigger";
export { zoomToFitBounds } from "./actions/actionCanvas"; export { zoomToFitBounds } from "./actions/actionCanvas";
export { export { convertToExcalidrawElements } from "./data/transform";
getCommonBounds, export { getCommonBounds, getVisibleSceneBounds } from "@excalidraw/element";
getVisibleSceneBounds,
convertToExcalidrawElements,
} from "@excalidraw/element";
export { export {
elementsOverlappingBBox, elementsOverlappingBBox,
+1 -2
View File
@@ -6,9 +6,8 @@ import {
polygonIncludesPointNonZero, polygonIncludesPointNonZero,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { type Bounds } from "@excalidraw/common";
import { import {
type Bounds,
computeBoundTextPosition, computeBoundTextPosition,
doBoundsIntersect, doBoundsIntersect,
getBoundTextElement, getBoundTextElement,
+6
View File
@@ -31,6 +31,12 @@
"strokeStyle_dashed": "Dashed", "strokeStyle_dashed": "Dashed",
"strokeStyle_dotted": "Dotted", "strokeStyle_dotted": "Dotted",
"sloppiness": "Sloppiness", "sloppiness": "Sloppiness",
"container": "Container",
"container_fixed": "Sticky note",
"container_growing": "Fit to text",
"container_margin_small": "Small margin",
"container_margin_medium": "Medium margin",
"container_margin_large": "Large margin",
"opacity": "Opacity", "opacity": "Opacity",
"textAlign": "Text align", "textAlign": "Text align",
"edges": "Edges", "edges": "Edges",
+1 -1
View File
@@ -133,6 +133,6 @@
"fonteditor-core": "2.4.1", "fonteditor-core": "2.4.1",
"harfbuzzjs": "0.3.6", "harfbuzzjs": "0.3.6",
"jest-diff": "29.7.0", "jest-diff": "29.7.0",
"typescript": "5.9.3" "typescript": "4.9.4"
} }
} }
+1 -1
View File
@@ -39,7 +39,7 @@ import { type Mutable } from "@excalidraw/common/utility-types";
import { newTextElement } from "@excalidraw/element"; import { newTextElement } from "@excalidraw/element";
import type { Bounds } from "@excalidraw/common"; import type { Bounds } from "@excalidraw/element";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
+1 -1
View File
@@ -24,7 +24,7 @@ import {
import type { InclusiveRange } from "@excalidraw/math"; import type { InclusiveRange } from "@excalidraw/math";
import type { Bounds } from "@excalidraw/common"; import type { Bounds } from "@excalidraw/element";
import type { MaybeTransformHandleType } from "@excalidraw/element"; import type { MaybeTransformHandleType } from "@excalidraw/element";
import type { import type {
ElementsMap, ElementsMap,
@@ -20,7 +20,7 @@ const load = (): Promise<{
subset: ( subset: (
fontBuffer: ArrayBuffer, fontBuffer: ArrayBuffer,
codePoints: ReadonlySet<number>, codePoints: ReadonlySet<number>,
) => Uint8Array<ArrayBuffer>; ) => Uint8Array;
}> => { }> => {
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
try { try {
@@ -18,7 +18,7 @@ type Vector = any;
let loadedWasm: ReturnType<typeof load> | null = null; let loadedWasm: ReturnType<typeof load> | null = null;
// re-map from internal vector into byte array // re-map from internal vector into byte array
function convertFromVecToUint8Array(vector: Vector): Uint8Array<ArrayBuffer> { function convertFromVecToUint8Array(vector: Vector): Uint8Array {
const arr = []; const arr = [];
for (let i = 0, l = vector.size(); i < l; i++) { for (let i = 0, l = vector.size(); i < l; i++) {
arr.push(vector.get(i)); arr.push(vector.get(i));
@@ -29,8 +29,8 @@ function convertFromVecToUint8Array(vector: Vector): Uint8Array<ArrayBuffer> {
// TODO: consider adding support for fetching the wasm from an URL (external CDN, data URL, etc.) // TODO: consider adding support for fetching the wasm from an URL (external CDN, data URL, etc.)
const load = (): Promise<{ const load = (): Promise<{
compress: (buffer: ArrayBuffer) => Uint8Array<ArrayBuffer>; compress: (buffer: ArrayBuffer) => Uint8Array;
decompress: (buffer: ArrayBuffer) => Uint8Array<ArrayBuffer>; decompress: (buffer: ArrayBuffer) => Uint8Array;
}> => { }> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
@@ -1223,7 +1223,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": 401146281, "versionNonce": 2019559783,
"width": 10, "width": 10,
"x": -20, "x": -20,
"y": -10, "y": -10,
@@ -1431,14 +1431,14 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 1014066025, "seed": 238820263,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": 1604849351, "versionNonce": 1505387817,
"width": 20, "width": 20,
"x": 20, "x": 20,
"y": 30, "y": 30,
@@ -1463,14 +1463,14 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 1278240551, "seed": 449462985,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 493213705, "versionNonce": 915032327,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -1765,14 +1765,14 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 1014066025, "seed": 238820263,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": 1604849351, "versionNonce": 1505387817,
"width": 20, "width": 20,
"x": 20, "x": 20,
"y": 30, "y": 30,
@@ -1797,14 +1797,14 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 1278240551, "seed": 449462985,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 493213705, "versionNonce": 915032327,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -2108,7 +2108,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": 401146281, "versionNonce": 2019559783,
"width": 10, "width": 10,
"x": -20, "x": -20,
"y": -10, "y": -10,
@@ -2321,7 +2321,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 1116226695, "versionNonce": 1014066025,
"width": 10, "width": 10,
"x": -20, "x": -20,
"y": -10, "y": -10,
@@ -2567,7 +2567,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": 401146281, "versionNonce": 2019559783,
"width": 10, "width": 10,
"x": -20, "x": -20,
"y": -10, "y": -10,
@@ -2592,14 +2592,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 1014066025, "seed": 238820263,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 5, "version": 5,
"versionNonce": 400692809, "versionNonce": 1604849351,
"width": 10, "width": 10,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -2868,14 +2868,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 1278240551, "seed": 449462985,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 915032327, "versionNonce": 81784553,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -2902,14 +2902,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 1014066025, "seed": 238820263,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 81784553, "versionNonce": 747212839,
"width": 20, "width": 20,
"x": 20, "x": 20,
"y": 30, "y": 30,
@@ -3238,14 +3238,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"opacity": 60, "opacity": 60,
"roughness": 2, "roughness": 2,
"roundness": null, "roundness": null,
"seed": 1278240551, "seed": 449462985,
"strokeColor": "#e03131", "strokeColor": "#e03131",
"strokeStyle": "dotted", "strokeStyle": "dotted",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 1402203177, "versionNonce": 1359939303,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -3270,14 +3270,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"opacity": 60, "opacity": 60,
"roughness": 2, "roughness": 2,
"roundness": null, "roundness": null,
"seed": 1898319239, "seed": 640725609,
"strokeColor": "#e03131", "strokeColor": "#e03131",
"strokeStyle": "dotted", "strokeStyle": "dotted",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 9, "version": 9,
"versionNonce": 941653321, "versionNonce": 908564423,
"width": 20, "width": 20,
"x": 20, "x": 20,
"y": 30, "y": 30,
@@ -3732,14 +3732,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 238820263, "seed": 400692809,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 915032327, "versionNonce": 81784553,
"width": 20, "width": 20,
"x": 20, "x": 20,
"y": 30, "y": 30,
@@ -3764,14 +3764,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 1278240551, "seed": 449462985,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": 2019559783, "versionNonce": 1150084233,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -4058,14 +4058,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 1014066025, "seed": 238820263,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 493213705, "versionNonce": 915032327,
"width": 20, "width": 20,
"x": 20, "x": 20,
"y": 30, "y": 30,
@@ -4090,14 +4090,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 1278240551, "seed": 449462985,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": 2019559783, "versionNonce": 1150084233,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -4387,14 +4387,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 1278240551, "seed": 449462985,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 5, "version": 5,
"versionNonce": 760410951, "versionNonce": 1006504105,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -4419,14 +4419,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 238820263, "seed": 400692809,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 5, "version": 5,
"versionNonce": 1006504105, "versionNonce": 289600103,
"width": 20, "width": 20,
"x": 20, "x": 20,
"y": 30, "y": 30,
@@ -5675,14 +5675,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 1278240551, "seed": 453191,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": 1150084233, "versionNonce": 1014066025,
"width": 10, "width": 10,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -5707,14 +5707,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 400692809, "seed": 1505387817,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": 23633383, "versionNonce": 915032327,
"width": 10, "width": 10,
"x": 12, "x": 12,
"y": 0, "y": 0,
@@ -6899,14 +6899,14 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 1278240551, "seed": 449462985,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 747212839, "versionNonce": 1723083209,
"width": 10, "width": 10,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -6933,14 +6933,14 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 238820263, "seed": 400692809,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 1723083209, "versionNonce": 760410951,
"width": 10, "width": 10,
"x": 12, "x": 12,
"y": 0, "y": 0,
@@ -9837,7 +9837,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] el
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": 401146281, "versionNonce": 2019559783,
"width": 10, "width": 10,
"x": -20, "x": -20,
"y": -10, "y": -10,
@@ -13935,7 +13935,7 @@ exports[`regression tests > switches from group of selected elements to another
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 1006504105, "seed": 289600103,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
@@ -41,8 +41,8 @@ exports[`select single element on the scene > arrow 1`] = `
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 5, "version": 4,
"versionNonce": 1116226695, "versionNonce": 2019559783,
"width": 30, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
@@ -88,8 +88,8 @@ exports[`select single element on the scene > arrow escape 1`] = `
"strokeWidth": 2, "strokeWidth": 2,
"type": "line", "type": "line",
"updated": 1, "updated": 1,
"version": 5, "version": 4,
"versionNonce": 1116226695, "versionNonce": 2019559783,
"width": 30, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
@@ -120,8 +120,8 @@ exports[`select single element on the scene > diamond 1`] = `
"strokeWidth": 2, "strokeWidth": 2,
"type": "diamond", "type": "diamond",
"updated": 1, "updated": 1,
"version": 4, "version": 3,
"versionNonce": 2019559783, "versionNonce": 401146281,
"width": 30, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
@@ -152,8 +152,8 @@ exports[`select single element on the scene > ellipse 1`] = `
"strokeWidth": 2, "strokeWidth": 2,
"type": "ellipse", "type": "ellipse",
"updated": 1, "updated": 1,
"version": 4, "version": 3,
"versionNonce": 2019559783, "versionNonce": 401146281,
"width": 30, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
@@ -184,8 +184,8 @@ exports[`select single element on the scene > rectangle 1`] = `
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 3,
"versionNonce": 2019559783, "versionNonce": 401146281,
"width": 30, "width": 30,
"x": 10, "x": 10,
"y": 10, "y": 10,
+9 -5
View File
@@ -1,4 +1,4 @@
import { DEFAULT_FONT_FAMILY } from "@excalidraw/common"; import { BOUND_TEXT_PADDING, DEFAULT_FONT_FAMILY } from "@excalidraw/common";
import type { Radians } from "@excalidraw/math"; import type { Radians } from "@excalidraw/math";
@@ -34,25 +34,29 @@ const elementBase: Omit<ExcalidrawElement, "type"> = {
export const rectangleFixture: ExcalidrawElement = { export const rectangleFixture: ExcalidrawElement = {
...elementBase, ...elementBase,
containerBehavior: { textFlow: "growing", margin: BOUND_TEXT_PADDING },
type: "rectangle", type: "rectangle",
}; } as unknown as ExcalidrawElement;
export const embeddableFixture: ExcalidrawElement = { export const embeddableFixture: ExcalidrawElement = {
...elementBase, ...elementBase,
type: "embeddable", type: "embeddable",
}; };
export const ellipseFixture: ExcalidrawElement = { export const ellipseFixture: ExcalidrawElement = {
...elementBase, ...elementBase,
containerBehavior: { textFlow: "growing", margin: BOUND_TEXT_PADDING },
type: "ellipse", type: "ellipse",
}; } as unknown as ExcalidrawElement;
export const diamondFixture: ExcalidrawElement = { export const diamondFixture: ExcalidrawElement = {
...elementBase, ...elementBase,
containerBehavior: { textFlow: "growing", margin: BOUND_TEXT_PADDING },
type: "diamond", type: "diamond",
}; } as unknown as ExcalidrawElement;
export const rectangleWithLinkFixture: ExcalidrawElement = { export const rectangleWithLinkFixture: ExcalidrawElement = {
...elementBase, ...elementBase,
containerBehavior: { textFlow: "growing", margin: BOUND_TEXT_PADDING },
type: "rectangle", type: "rectangle",
link: "excalidraw.com", link: "excalidraw.com",
}; } as unknown as ExcalidrawElement;
export const textFixture: ExcalidrawElement = { export const textFixture: ExcalidrawElement = {
...elementBase, ...elementBase,
+11 -5
View File
@@ -4,7 +4,7 @@ import util from "util";
import { pointFrom, type LocalPoint, type Radians } from "@excalidraw/math"; import { pointFrom, type LocalPoint, type Radians } from "@excalidraw/math";
import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS, assertNever } from "@excalidraw/common"; import { BOUND_TEXT_PADDING, DEFAULT_VERTICAL_ALIGN, ROUNDNESS, assertNever } from "@excalidraw/common";
import { import {
newArrowElement, newArrowElement,
@@ -23,8 +23,6 @@ import { isLinearElementType } from "@excalidraw/element";
import { getSelectedElements } from "@excalidraw/element"; import { getSelectedElements } from "@excalidraw/element";
import { selectGroupsForSelectedElements } from "@excalidraw/element"; import { selectGroupsForSelectedElements } from "@excalidraw/element";
import { FONT_SIZES } from "@excalidraw/common";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawGenericElement, ExcalidrawGenericElement,
@@ -219,6 +217,10 @@ export class API {
: never; : never;
elbowed?: boolean; elbowed?: boolean;
fixedSegments?: FixedSegment[] | null; fixedSegments?: FixedSegment[] | null;
containerBehavior?: T extends "rectangle" | "diamond" | "ellipse"
? ExcalidrawGenericElement["containerBehavior"]
: never;
} = {
}): T extends "arrow" | "line" }): T extends "arrow" | "line"
? ExcalidrawLinearElement ? ExcalidrawLinearElement
: T extends "freedraw" : T extends "freedraw"
@@ -284,6 +286,10 @@ export class API {
element = newElement({ element = newElement({
type: type as "rectangle" | "diamond" | "ellipse", type: type as "rectangle" | "diamond" | "ellipse",
...base, ...base,
containerBehavior: {
textFlow: rest.containerBehavior?.textFlow ?? "growing",
margin: rest.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
},
}); });
break; break;
case "embeddable": case "embeddable":
@@ -408,7 +414,7 @@ export class API {
text: opts?.label?.text || "sample-text", text: opts?.label?.text || "sample-text",
width: 50, width: 50,
height: 20, height: 20,
fontSize: FONT_SIZES.sm, fontSize: 16,
containerId: rectangle.id, containerId: rectangle.id,
frameId: frameId:
opts?.label?.frameId === undefined opts?.label?.frameId === undefined
@@ -466,7 +472,7 @@ export class API {
static readFile = async <T extends "utf8" | null>( static readFile = async <T extends "utf8" | null>(
filepath: string, filepath: string,
encoding?: T, encoding?: T,
): Promise<T extends "utf8" ? string : ArrayBuffer> => { ): Promise<T extends "utf8" ? string : Buffer> => {
filepath = path.isAbsolute(filepath) filepath = path.isAbsolute(filepath)
? filepath ? filepath
: path.resolve(path.join(__dirname, "../", filepath)); : path.resolve(path.join(__dirname, "../", filepath));
@@ -315,7 +315,6 @@ describe("history", () => {
]); ]);
mouse.downAt(0, 0); mouse.downAt(0, 0);
mouse.moveTo(25, 25);
mouse.moveTo(50, 50); mouse.moveTo(50, 50);
mouse.upAt(50, 50); mouse.upAt(50, 50);
expect(API.getUndoStack().length).toBe(3); expect(API.getUndoStack().length).toBe(3);
@@ -467,7 +467,6 @@ describe("regression tests", () => {
mouse.reset(); mouse.reset();
mouse.down(); mouse.down();
mouse.move(-1000, -1000);
mouse.restorePosition(...end); mouse.restorePosition(...end);
mouse.up(); mouse.up();
@@ -518,7 +517,6 @@ describe("regression tests", () => {
mouse.reset(); mouse.reset();
mouse.down(); mouse.down();
mouse.move(-1000, -1000);
mouse.restorePosition(...end); mouse.restorePosition(...end);
mouse.up(); mouse.up();
@@ -536,7 +534,6 @@ describe("regression tests", () => {
mouse.moveTo(-10, -10); // the NW resizing handle is at [0, 0], so moving further mouse.moveTo(-10, -10); // the NW resizing handle is at [0, 0], so moving further
mouse.down(); mouse.down();
mouse.move(-1000, -1000);
mouse.restorePosition(...end); mouse.restorePosition(...end);
mouse.up(); mouse.up();
+6 -20
View File
@@ -65,7 +65,6 @@ describe("box-selection", () => {
API.setElements([rect1, rect2]); API.setElements([rect1, rect2]);
mouse.downAt(175, -20); mouse.downAt(175, -20);
mouse.move(-1000, -1000);
mouse.moveTo(85, 70); mouse.moveTo(85, 70);
mouse.up(); mouse.up();
@@ -73,7 +72,6 @@ describe("box-selection", () => {
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.downAt(75, -20); mouse.downAt(75, -20);
mouse.move(-1000, -1000);
mouse.moveTo(-15, 70); mouse.moveTo(-15, 70);
mouse.up(); mouse.up();
}); });
@@ -95,7 +93,6 @@ describe("box-selection", () => {
API.setElements([rect1]); API.setElements([rect1]);
mouse.downAt(75, -20); mouse.downAt(75, -20);
mouse.move(-1000, -1000);
mouse.moveTo(-15, 70); mouse.moveTo(-15, 70);
assertSelectedElements([rect1.id]); assertSelectedElements([rect1.id]);
@@ -141,7 +138,6 @@ describe("inner box-selection", () => {
API.setElements([rect1, rect2, rect3]); API.setElements([rect1, rect2, rect3]);
Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.downAt(40, 40); mouse.downAt(40, 40);
mouse.move(-1000, -1000);
mouse.moveTo(290, 290); mouse.moveTo(290, 290);
mouse.up(); mouse.up();
@@ -179,7 +175,6 @@ describe("inner box-selection", () => {
Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.downAt(40, 40); mouse.downAt(40, 40);
mouse.move(-1000, -1000);
mouse.moveTo(rect2.x + rect2.width + 10, rect2.y + rect2.height + 10); mouse.moveTo(rect2.x + rect2.width + 10, rect2.y + rect2.height + 10);
mouse.up(); mouse.up();
@@ -217,7 +212,6 @@ describe("inner box-selection", () => {
API.setElements([rect1, rect2, rect3]); API.setElements([rect1, rect2, rect3]);
Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.downAt(rect2.x - 20, rect2.y - 20); mouse.downAt(rect2.x - 20, rect2.y - 20);
mouse.move(-1000, -1000);
mouse.moveTo(rect2.x + rect2.width + 10, rect2.y + rect2.height + 10); mouse.moveTo(rect2.x + rect2.width + 10, rect2.y + rect2.height + 10);
assertSelectedElements([rect2.id, rect3.id]); assertSelectedElements([rect2.id, rect3.id]);
expect(h.state.selectedGroupIds).toEqual({ A: true }); expect(h.state.selectedGroupIds).toEqual({ A: true });
@@ -259,10 +253,9 @@ describe("selection element", () => {
const canvas = container.querySelector("canvas.interactive")!; const canvas = container.querySelector("canvas.interactive")!;
fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 }); fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
fireEvent.pointerMove(canvas, { clientX: -1000, clientY: -1000 });
fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 }); fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 });
expect(renderInteractiveScene).toHaveBeenCalledTimes(5); expect(renderInteractiveScene).toHaveBeenCalledTimes(4);
expect(renderStaticScene).toHaveBeenCalledTimes(3); expect(renderStaticScene).toHaveBeenCalledTimes(3);
const selectionElement = h.state.selectionElement!; const selectionElement = h.state.selectionElement!;
expect(selectionElement).not.toBeNull(); expect(selectionElement).not.toBeNull();
@@ -282,11 +275,10 @@ describe("selection element", () => {
const canvas = container.querySelector("canvas.interactive")!; const canvas = container.querySelector("canvas.interactive")!;
fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 }); fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
fireEvent.pointerMove(canvas, { clientX: -1000, clientY: -1000 });
fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 }); fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(6); expect(renderInteractiveScene).toHaveBeenCalledTimes(5);
expect(renderStaticScene).toHaveBeenCalledTimes(3); expect(renderStaticScene).toHaveBeenCalledTimes(3);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
}); });
@@ -311,7 +303,6 @@ describe("select single element on the scene", () => {
const tool = getByToolName("rectangle"); const tool = getByToolName("rectangle");
fireEvent.click(tool); fireEvent.click(tool);
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
fireEvent.pointerMove(canvas, { clientX: -1000, clientY: -1000 });
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
fireEvent.keyDown(document, { fireEvent.keyDown(document, {
@@ -344,7 +335,6 @@ describe("select single element on the scene", () => {
const tool = getByToolName("diamond"); const tool = getByToolName("diamond");
fireEvent.click(tool); fireEvent.click(tool);
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
fireEvent.pointerMove(canvas, { clientX: -1000, clientY: -1000 });
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
fireEvent.keyDown(document, { fireEvent.keyDown(document, {
@@ -377,7 +367,6 @@ describe("select single element on the scene", () => {
const tool = getByToolName("ellipse"); const tool = getByToolName("ellipse");
fireEvent.click(tool); fireEvent.click(tool);
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
fireEvent.pointerMove(canvas, { clientX: -1000, clientY: -1000 });
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
fireEvent.keyDown(document, { fireEvent.keyDown(document, {
@@ -410,7 +399,6 @@ describe("select single element on the scene", () => {
const tool = getByToolName("arrow"); const tool = getByToolName("arrow");
fireEvent.click(tool); fireEvent.click(tool);
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
fireEvent.pointerMove(canvas, { clientX: -1000, clientY: -1000 });
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
fireEvent.keyDown(document, { fireEvent.keyDown(document, {
@@ -437,8 +425,8 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 }); fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(10); expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(8); expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -455,7 +443,6 @@ describe("select single element on the scene", () => {
const tool = getByToolName("line"); const tool = getByToolName("line");
fireEvent.click(tool); fireEvent.click(tool);
fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
fireEvent.pointerMove(canvas, { clientX: -1000, clientY: -1000 });
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
fireEvent.keyDown(document, { fireEvent.keyDown(document, {
@@ -482,8 +469,8 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 }); fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(10); expect(renderInteractiveScene).toHaveBeenCalledTimes(9);
expect(renderStaticScene).toHaveBeenCalledTimes(8); expect(renderStaticScene).toHaveBeenCalledTimes(7);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -538,7 +525,6 @@ describe("selectedElementIds stability", () => {
expect(h.state.selectedElementIds).toBe(selectedElementIds_1); expect(h.state.selectedElementIds).toBe(selectedElementIds_1);
mouse.downAt(-50, -50); mouse.downAt(-50, -50);
mouse.move(-1000, -1000);
mouse.moveTo(50, 50); mouse.moveTo(50, 50);
const selectedElementIds_2 = h.state.selectedElementIds; const selectedElementIds_2 = h.state.selectedElementIds;
+1
View File
@@ -348,6 +348,7 @@ export interface AppState {
currentHoveredFontFamily: FontFamilyValues | null; currentHoveredFontFamily: FontFamilyValues | null;
currentItemRoundness: StrokeRoundness; currentItemRoundness: StrokeRoundness;
currentItemArrowType: "sharp" | "round" | "elbow"; currentItemArrowType: "sharp" | "round" | "elbow";
currentItemContainerBehavior: ExcalidrawElement["containerBehavior"];
viewBackgroundColor: string; viewBackgroundColor: string;
scrollX: number; scrollX: number;
scrollY: number; scrollY: number;
+66 -4
View File
@@ -8,6 +8,7 @@ import {
getFontFamilyString, getFontFamilyString,
isTestEnv, isTestEnv,
MIME_TYPES, MIME_TYPES,
BOUND_TEXT_PADDING,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
@@ -36,6 +37,7 @@ import {
isBoundToContainer, isBoundToContainer,
isTextElement, isTextElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { computeStickyNoteFontSize } from "@excalidraw/element";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
@@ -130,6 +132,8 @@ export const textWysiwyg = ({
return false; return false;
}; };
let stickyNoteInitialFontSize: number | null = null;
const updateWysiwygStyle = () => { const updateWysiwygStyle = () => {
const appState = app.state; const appState = app.state;
const updatedTextElement = app.scene.getElement<ExcalidrawTextElement>(id); const updatedTextElement = app.scene.getElement<ExcalidrawTextElement>(id);
@@ -157,6 +161,52 @@ export const textWysiwyg = ({
let maxHeight = updatedTextElement.height; let maxHeight = updatedTextElement.height;
if (container && updatedTextElement.containerId) { if (container && updatedTextElement.containerId) {
if ((container as any).containerBehavior?.textFlow === "fixed") {
if (stickyNoteInitialFontSize == null) {
stickyNoteInitialFontSize = updatedTextElement.fontSize;
}
const elementsMap = app.scene.getNonDeletedElementsMap();
maxWidth = getBoundTextMaxWidth(container, updatedTextElement);
maxHeight = getBoundTextMaxHeight(
container,
updatedTextElement as ExcalidrawTextElementWithContainer,
);
const {
fontSize,
width: fittedW,
height: fittedH,
wrappedText,
} = computeStickyNoteFontSize(
editable.value,
updatedTextElement,
container,
stickyNoteInitialFontSize,
);
const needsUpdate =
fontSize !== updatedTextElement.fontSize ||
fittedW !== updatedTextElement.width ||
fittedH !== updatedTextElement.height ||
wrappedText !== updatedTextElement.text;
if (needsUpdate) {
app.scene.mutateElement(updatedTextElement, {
fontSize,
width: fittedW,
height: fittedH,
text: wrappedText,
});
}
const { x, y } = computeBoundTextPosition(
container,
updatedTextElement as ExcalidrawTextElementWithContainer,
elementsMap,
);
coordX = x;
coordY = y;
width = fittedW;
height = fittedH;
} else {
if (isArrowElement(container)) { if (isArrowElement(container)) {
const boundTextCoords = const boundTextCoords =
LinearElementEditor.getBoundTextElementPosition( LinearElementEditor.getBoundTextElementPosition(
@@ -194,14 +244,20 @@ export const textWysiwyg = ({
updatedTextElement as ExcalidrawTextElementWithContainer, updatedTextElement as ExcalidrawTextElementWithContainer,
); );
// autogrow / autoshrink only for non-sticky behaviors
if ((container as any).containerBehavior?.textFlow !== "fixed") {
// autogrow container height if text exceeds // autogrow container height if text exceeds
if (!isArrowElement(container) && height > maxHeight) { if (!isArrowElement(container) && height > maxHeight) {
const targetContainerHeight = computeContainerDimensionForBoundText( const targetContainerHeight =
computeContainerDimensionForBoundText(
height, height,
container.type, container.type,
container.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
); );
app.scene.mutateElement(container, { height: targetContainerHeight }); app.scene.mutateElement(container, {
height: targetContainerHeight,
});
return; return;
} else if ( } else if (
// autoshrink container height until original container height // autoshrink container height until original container height
@@ -210,11 +266,15 @@ export const textWysiwyg = ({
container.height > originalContainerData.height && container.height > originalContainerData.height &&
height < maxHeight height < maxHeight
) { ) {
const targetContainerHeight = computeContainerDimensionForBoundText( const targetContainerHeight =
computeContainerDimensionForBoundText(
height, height,
container.type, container.type,
container.containerBehavior?.margin ?? BOUND_TEXT_PADDING,
); );
app.scene.mutateElement(container, { height: targetContainerHeight }); app.scene.mutateElement(container, {
height: targetContainerHeight,
});
} else { } else {
const { x, y } = computeBoundTextPosition( const { x, y } = computeBoundTextPosition(
container, container,
@@ -225,6 +285,8 @@ export const textWysiwyg = ({
coordY = y; coordY = y;
} }
} }
}
}
const [viewportX, viewportY] = getViewportCoords(coordX, coordY); const [viewportX, viewportY] = getViewportCoords(coordX, coordY);
if (!container) { if (!container) {
+1 -1
View File
@@ -62,7 +62,7 @@
"devDependencies": { "devDependencies": {
"cross-env": "7.0.3", "cross-env": "7.0.3",
"fonteditor-core": "2.4.0", "fonteditor-core": "2.4.0",
"typescript": "5.9.3", "typescript": "4.9.4",
"wawoff2": "2.0.1", "wawoff2": "2.0.1",
"which": "4.0.0" "which": "4.0.0"
}, },
+1 -1
View File
@@ -5,7 +5,7 @@ import {
type LocalPoint, type LocalPoint,
} from "@excalidraw/math"; } from "@excalidraw/math";
import type { Bounds } from "@excalidraw/common"; import type { Bounds } from "@excalidraw/element";
export type LineSegment<P extends LocalPoint | GlobalPoint> = [P, P]; export type LineSegment<P extends LocalPoint | GlobalPoint> = [P, P];
+2 -1
View File
@@ -1,4 +1,4 @@
import { arrayToMap, type Bounds } from "@excalidraw/common"; import { arrayToMap } from "@excalidraw/common";
import { getElementBounds } from "@excalidraw/element"; import { getElementBounds } from "@excalidraw/element";
import { import {
isArrowElement, isArrowElement,
@@ -14,6 +14,7 @@ import {
rangeInclusive, rangeInclusive,
} from "@excalidraw/math"; } from "@excalidraw/math";
import type { Bounds } from "@excalidraw/element";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
+1 -1
View File
@@ -1,6 +1,6 @@
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import type { Bounds } from "@excalidraw/common"; import type { Bounds } from "@excalidraw/element";
import { import {
elementPartiallyOverlapsWithOrContainsBBox, elementPartiallyOverlapsWithOrContainsBBox,
+4 -4
View File
@@ -9290,10 +9290,10 @@ typed-array-length@^1.0.7:
possible-typed-array-names "^1.0.0" possible-typed-array-names "^1.0.0"
reflect.getprototypeof "^1.0.6" reflect.getprototypeof "^1.0.6"
typescript@5.9.3: typescript@4.9.4:
version "5.9.3" version "4.9.4"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.4.tgz#a2a3d2756c079abda241d75f149df9d561091e78"
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== integrity sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==
typescript@^5: typescript@^5:
version "5.8.2" version "5.8.2"