Compare commits

..

2 Commits

Author SHA1 Message Date
Ryan Di cbf4d5a591 fix other imports as well 2025-11-19 14:40:16 +08:00
Ryan Di 38d0966227 fix: use index path instead of ellipse 2025-11-19 14:37:05 +08:00
183 changed files with 5131 additions and 7922 deletions
+2 -2
View File
@@ -12,10 +12,10 @@ jobs:
- uses: actions/checkout@v2
with:
fetch-depth: 2
- name: Setup Node.js
- name: Setup Node.js 18.x
uses: actions/setup-node@v2
with:
node-version: 20.x
node-version: 18.x
- name: Set up publish access
run: |
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
+2 -2
View File
@@ -9,10 +9,10 @@ jobs:
steps:
- uses: actions/checkout@v2
- name: Setup Node.js
- name: Setup Node.js 18.x
uses: actions/setup-node@v2
with:
node-version: 20.x
node-version: 18.x
- name: Install and lint
run: |
+2 -2
View File
@@ -14,10 +14,10 @@ jobs:
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
- name: Setup Node.js
- name: Setup Node.js 18.x
uses: actions/setup-node@v2
with:
node-version: 20.x
node-version: 18.x
- name: Create report file
run: |
+2 -2
View File
@@ -10,10 +10,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Node.js
- name: Setup Node.js 18.x
uses: actions/setup-node@v2
with:
node-version: 20.x
node-version: 18.x
- name: Install and build
run: |
yarn --frozen-lockfile
+2 -2
View File
@@ -11,10 +11,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Setup Node.js
- name: Setup Node.js 18.x
uses: actions/setup-node@v3
with:
node-version: 20.x
node-version: 18.x
- name: Install in packages/excalidraw
run: yarn
working-directory: packages/excalidraw
+12 -6
View File
@@ -23,17 +23,23 @@
<br />
<p align="center">
<a href="https://github.com/excalidraw/excalidraw/blob/master/LICENSE">
<img alt="Excalidraw is released under the MIT license." src="https://img.shields.io/badge/license-MIT-blue.svg" /></a>
<img alt="Excalidraw is released under the MIT license." src="https://img.shields.io/badge/license-MIT-blue.svg" />
</a>
<a href="https://www.npmjs.com/package/@excalidraw/excalidraw">
<img alt="npm downloads/month" src="https://img.shields.io/npm/dm/@excalidraw/excalidraw" /></a>
<img alt="npm downloads/month" src="https://img.shields.io/npm/dm/@excalidraw/excalidraw" />
</a>
<a href="https://docs.excalidraw.com/docs/introduction/contributing">
<img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" /></a>
<img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" />
</a>
<a href="https://discord.gg/UexuTaE">
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/></a>
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/>
</a>
<a href="https://deepwiki.com/excalidraw/excalidraw">
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" /></a>
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" />
</a>
<a href="https://twitter.com/excalidraw">
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/></a>
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/>
</a>
</p>
<div align="center">
+3 -6
View File
@@ -441,7 +441,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
};
private decryptPayload = async (
iv: Uint8Array<ArrayBuffer>,
iv: Uint8Array,
encryptedData: ArrayBuffer,
decryptionKey: string,
): Promise<ValueOf<SocketUpdateDataSource>> => {
@@ -562,7 +562,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
// All socket listeners are moving to Portal
this.portal.socket.on(
"client-broadcast",
async (encryptedData: ArrayBuffer, iv: Uint8Array<ArrayBuffer>) => {
async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
if (!this.portal.roomKey) {
return;
}
@@ -746,10 +746,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
): ReconciledExcalidrawElement[] => {
const localElements = this.getSceneElementsIncludingDeleted();
const appState = this.excalidrawAPI.getAppState();
const restoredRemoteElements = restoreElements(
remoteElements,
this.excalidrawAPI.getSceneElementsMapIncludingDeleted(),
);
const restoredRemoteElements = restoreElements(remoteElements, null);
const reconciledElements = reconcileElements(
localElements,
restoredRemoteElements as RemoteExcalidrawElement[],
+68 -62
View File
@@ -15,14 +15,15 @@ import {
getGlobalFixedPointForBindableElement,
isArrowElement,
isBindableElement,
isFixedPointBinding,
} from "@excalidraw/element";
import {
isLineSegment,
type GlobalPoint,
type LineSegment,
isCurve,
} from "@excalidraw/math";
import { isCurve } from "@excalidraw/math/curve";
import React from "react";
@@ -34,6 +35,7 @@ import type {
ExcalidrawBindableElement,
FixedPointBinding,
OrderedExcalidrawElement,
PointBinding,
} from "@excalidraw/element/types";
import { STORAGE_KEYS } from "../app_constants";
@@ -89,46 +91,48 @@ const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
const _renderBinding = (
context: CanvasRenderingContext2D,
binding: FixedPointBinding,
binding: FixedPointBinding | PointBinding,
elementsMap: ElementsMap,
zoom: number,
width: number,
height: number,
color: string,
) => {
if (!binding.fixedPoint) {
console.warn("Binding must have a fixedPoint");
return;
if (isFixedPointBinding(binding)) {
if (!binding.fixedPoint) {
console.warn("Binding must have a fixedPoint");
return;
}
const bindable = elementsMap.get(
binding.elementId,
) as ExcalidrawBindableElement;
const [x, y] = getGlobalFixedPointForBindableElement(
binding.fixedPoint,
bindable,
elementsMap,
);
context.save();
context.strokeStyle = color;
context.lineWidth = 1;
context.beginPath();
context.moveTo(x * zoom, y * zoom);
context.bezierCurveTo(
x * zoom - width,
y * zoom - height,
x * zoom - width,
y * zoom + height,
x * zoom,
y * zoom,
);
context.stroke();
context.restore();
}
const bindable = elementsMap.get(
binding.elementId,
) as ExcalidrawBindableElement;
const [x, y] = getGlobalFixedPointForBindableElement(
binding.fixedPoint,
bindable,
elementsMap,
);
context.save();
context.strokeStyle = color;
context.lineWidth = 1;
context.beginPath();
context.moveTo(x * zoom, y * zoom);
context.bezierCurveTo(
x * zoom - width,
y * zoom - height,
x * zoom - width,
y * zoom + height,
x * zoom,
y * zoom,
);
context.stroke();
context.restore();
};
const _renderBindableBinding = (
binding: FixedPointBinding,
binding: FixedPointBinding | PointBinding,
context: CanvasRenderingContext2D,
elementsMap: ElementsMap,
zoom: number,
@@ -136,35 +140,37 @@ const _renderBindableBinding = (
height: number,
color: string,
) => {
const bindable = elementsMap.get(
binding.elementId,
) as ExcalidrawBindableElement;
if (!binding.fixedPoint) {
console.warn("Binding must have a fixedPoint");
return;
if (isFixedPointBinding(binding)) {
const bindable = elementsMap.get(
binding.elementId,
) as ExcalidrawBindableElement;
if (!binding.fixedPoint) {
console.warn("Binding must have a fixedPoint");
return;
}
const [x, y] = getGlobalFixedPointForBindableElement(
binding.fixedPoint,
bindable,
elementsMap,
);
context.save();
context.strokeStyle = color;
context.lineWidth = 1;
context.beginPath();
context.moveTo(x * zoom, y * zoom);
context.bezierCurveTo(
x * zoom + width,
y * zoom + height,
x * zoom + width,
y * zoom - height,
x * zoom,
y * zoom,
);
context.stroke();
context.restore();
}
const [x, y] = getGlobalFixedPointForBindableElement(
binding.fixedPoint,
bindable,
elementsMap,
);
context.save();
context.strokeStyle = color;
context.lineWidth = 1;
context.beginPath();
context.moveTo(x * zoom, y * zoom);
context.bezierCurveTo(
x * zoom + width,
y * zoom + height,
x * zoom + width,
y * zoom - height,
x * zoom,
y * zoom,
);
context.stroke();
context.restore();
};
const renderBindings = (
@@ -191,12 +197,12 @@ const renderBindings = (
_renderBinding(
context,
element.startBinding,
element.startBinding as FixedPointBinding,
elementsMap,
zoom,
dim,
dim,
element.startBinding?.mode === "orbit" ? "red" : "black",
"red",
);
}
@@ -215,7 +221,7 @@ const renderBindings = (
zoom,
dim,
dim,
element.endBinding?.mode === "orbit" ? "red" : "black",
"red",
);
}
}
+2 -2
View File
@@ -16,6 +16,7 @@ import {
DEFAULT_SIDEBAR,
debounce,
} from "@excalidraw/common";
import { clearElementsForLocalStorage } from "@excalidraw/element";
import {
createStore,
entries,
@@ -27,7 +28,6 @@ import {
} from "idb-keyval";
import { appJotaiStore, atom } from "excalidraw-app/app-jotai";
import { getNonDeletedElements } from "@excalidraw/element";
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
@@ -88,7 +88,7 @@ const saveDataStateToLocalStorage = (
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
JSON.stringify(getNonDeletedElements(elements)),
JSON.stringify(clearElementsForLocalStorage(elements)),
);
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
+2 -2
View File
@@ -105,8 +105,8 @@ const decryptElements = async (
data: FirebaseStoredScene,
roomKey: string,
): Promise<readonly ExcalidrawElement[]> => {
const ciphertext = data.ciphertext.toUint8Array() as Uint8Array<ArrayBuffer>;
const iv = data.iv.toUint8Array() as Uint8Array<ArrayBuffer>;
const ciphertext = data.ciphertext.toUint8Array();
const iv = data.iv.toUint8Array();
const decrypted = await decryptData(iv, ciphertext, roomKey);
const decodedData = new TextDecoder("utf-8").decode(
+2 -1
View File
@@ -2,6 +2,7 @@ import {
clearAppStateForLocalStorage,
getDefaultAppState,
} from "@excalidraw/excalidraw/appState";
import { clearElementsForLocalStorage } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { AppState } from "@excalidraw/excalidraw/types";
@@ -49,7 +50,7 @@ export const importFromLocalStorage = () => {
let elements: ExcalidrawElement[] = [];
if (savedElements) {
try {
elements = JSON.parse(savedElements);
elements = clearElementsForLocalStorage(JSON.parse(savedElements));
} catch (error: any) {
console.error(error);
// Do nothing because elements array is already empty
-13
View File
@@ -1,4 +1,3 @@
import { getFeatureFlag } from "@excalidraw/common";
import * as Sentry from "@sentry/browser";
import callsites from "callsites";
@@ -34,7 +33,6 @@ Sentry.init({
Sentry.captureConsoleIntegration({
levels: ["error"],
}),
Sentry.featureFlagsIntegration(),
],
beforeSend(event) {
if (event.request?.url) {
@@ -81,14 +79,3 @@ Sentry.init({
return event;
},
});
const flagsIntegration =
Sentry.getClient()?.getIntegrationByName<Sentry.FeatureFlagsIntegration>(
"FeatureFlags",
);
if (flagsIntegration) {
flagsIntegration.addFeatureFlag(
"COMPLEX_BINDINGS",
getFeatureFlag("COMPLEX_BINDINGS"),
);
}
+1 -1
View File
@@ -34,7 +34,7 @@
"prettier": "2.6.2",
"rewire": "6.0.0",
"rimraf": "^5.0.0",
"typescript": "5.9.3",
"typescript": "4.9.4",
"vite": "5.0.12",
"vite-plugin-checker": "0.7.2",
"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";
-9
View File
@@ -108,13 +108,6 @@ export const CLASSES = {
FRAME_NAME: "frame-name",
};
export const FONT_SIZES = {
sm: 16,
md: 20,
lg: 28,
xl: 36,
} as const;
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
@@ -506,8 +499,6 @@ export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
export const DOUBLE_TAP_POSITION_THRESHOLD = 35;
export const BIND_MODE_TIMEOUT = 700; // ms
// glass background for mobile action buttons
export const MOBILE_ACTION_BUTTON_BG = {
background: "var(--mobile-action-button-bg)",
-1
View File
@@ -1,5 +1,4 @@
export * from "./binary-heap";
export * from "./bounds";
export * from "./colors";
export * from "./constants";
export * from "./font-metadata";
+8 -49
View File
@@ -1,6 +1,10 @@
import { average } from "@excalidraw/math";
import type { FontFamilyValues, FontString } from "@excalidraw/element/types";
import type {
ExcalidrawBindableElement,
FontFamilyValues,
FontString,
} from "@excalidraw/element/types";
import type {
ActiveTool,
@@ -378,10 +382,6 @@ export const removeSelection = () => {
export const distance = (x: number, y: number) => Math.abs(x - y);
export const isSelectionLikeTool = (type: ToolType | "custom") => {
return type === "selection" || type === "lasso";
};
export const updateActiveTool = (
appState: Pick<AppState, "activeTool">,
data: ((
@@ -558,6 +558,9 @@ export const isTransparent = (color: string) => {
);
};
export const isBindingFallthroughEnabled = (el: ExcalidrawBindableElement) =>
el.fillStyle !== "solid" || isTransparent(el.backgroundColor);
export type ResolvablePromise<T> = Promise<T> & {
resolve: [T] extends [undefined]
? (value?: MaybePromise<Awaited<T>>) => void
@@ -1267,47 +1270,3 @@ export const reduceToCommonValue = <T, R = T>(
return commonValue;
};
type FEATURE_FLAGS = {
COMPLEX_BINDINGS: boolean;
};
const FEATURE_FLAGS_STORAGE_KEY = "excalidraw-feature-flags";
const DEFAULT_FEATURE_FLAGS: FEATURE_FLAGS = {
COMPLEX_BINDINGS: false,
};
let featureFlags: FEATURE_FLAGS | null = null;
export const getFeatureFlag = <F extends keyof FEATURE_FLAGS>(
flag: F,
): FEATURE_FLAGS[F] => {
if (!featureFlags) {
try {
const serializedFlags = localStorage.getItem(FEATURE_FLAGS_STORAGE_KEY);
if (serializedFlags) {
const flags = JSON.parse(serializedFlags);
featureFlags = flags ?? DEFAULT_FEATURE_FLAGS;
}
} catch {}
}
return (featureFlags || DEFAULT_FEATURE_FLAGS)[flag];
};
export const setFeatureFlag = <F extends keyof FEATURE_FLAGS>(
flag: F,
value: FEATURE_FLAGS[F],
) => {
try {
featureFlags = {
...(featureFlags || DEFAULT_FEATURE_FLAGS),
[flag]: value,
};
localStorage.setItem(
FEATURE_FLAGS_STORAGE_KEY,
JSON.stringify(featureFlags),
);
} catch (e) {
console.error("unable to set feature flag", e);
}
};
+4 -2
View File
@@ -6,10 +6,12 @@ import {
type LocalPoint,
} from "@excalidraw/math";
import { isBounds } from "@excalidraw/element";
import type { Curve } from "@excalidraw/math";
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
declare global {
@@ -137,7 +139,7 @@ export const debugDrawPoints = (
}: {
x: number;
y: number;
points: readonly LocalPoint[];
points: LocalPoint[];
},
options?: any,
) => {
File diff suppressed because it is too large Load Diff
+10 -8
View File
@@ -2,7 +2,6 @@ import rough from "roughjs/bin/rough";
import {
arrayToMap,
type Bounds,
invariant,
rescalePoints,
sizeOf,
@@ -79,6 +78,16 @@ export type RectangleBox = {
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 [
sceneX: number,
sceneY: number,
@@ -1267,13 +1276,6 @@ export const elementCenterPoint = (
xOffset: number = 0,
yOffset: number = 0,
) => {
if (isLinearElement(element)) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const [x, y] = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
return pointFrom<GlobalPoint>(x + xOffset, y + yOffset);
}
const [x, y] = getCenterForBounds(getElementBounds(element, elementsMap));
return pointFrom<GlobalPoint>(x + xOffset, y + yOffset);
+4 -187
View File
@@ -1,4 +1,4 @@
import { invariant, isTransparent, type Bounds } from "@excalidraw/common";
import { isTransparent } from "@excalidraw/common";
import {
curveIntersectLineSegment,
isPointWithinBounds,
@@ -11,12 +11,9 @@ import {
vectorFromPoint,
vectorNormalize,
vectorScale,
} from "@excalidraw/math";
import {
ellipse,
ellipseSegmentInterceptPoints,
} from "@excalidraw/math/ellipse";
} from "@excalidraw/math";
import type {
Curve,
@@ -29,18 +26,15 @@ import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
import { isPathALoop } from "./utils";
import {
type Bounds,
doBoundsIntersect,
elementCenterPoint,
getCenterForBounds,
getCubicBezierCurveBound,
getDiamondPoints,
getElementBounds,
pointInsideBounds,
} from "./bounds";
import {
hasBoundTextElement,
isBindableElement,
isFrameLikeElement,
isFreeDrawElement,
isIframeLikeElement,
isImageElement,
@@ -61,17 +55,12 @@ import { distanceToElement } from "./distance";
import type {
ElementsMap,
ExcalidrawBindableElement,
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement,
NonDeleted,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
Ordered,
} from "./types";
export const shouldTestInside = (element: ExcalidrawElement) => {
@@ -102,7 +91,6 @@ export type HitTestArgs = {
threshold: number;
elementsMap: ElementsMap;
frameNameBound?: FrameNameBounds | null;
overrideShouldTestInside?: boolean;
};
export const hitElementItself = ({
@@ -111,7 +99,6 @@ export const hitElementItself = ({
threshold,
elementsMap,
frameNameBound = null,
overrideShouldTestInside = false,
}: HitTestArgs) => {
// Hit test against a frame's name
const hitFrameName = frameNameBound
@@ -144,9 +131,7 @@ export const hitElementItself = ({
}
// Do the precise (and relatively costly) hit test
const hitElement = (
overrideShouldTestInside ? true : shouldTestInside(element)
)
const hitElement = shouldTestInside(element)
? // Since `inShape` tests STRICTLY againt the insides of a shape
// we would need `onShape` as well to include the "borders"
isPointInElement(point, element, elementsMap) ||
@@ -205,116 +190,6 @@ export const hitElementBoundText = (
return isPointInElement(point, boundTextElement, elementsMap);
};
const bindingBorderTest = (
element: NonDeleted<ExcalidrawBindableElement>,
[x, y]: Readonly<GlobalPoint>,
elementsMap: NonDeletedSceneElementsMap,
tolerance: number = 0,
): boolean => {
const p = pointFrom<GlobalPoint>(x, y);
const shouldTestInside =
// disable fullshape snapping for frame elements so we
// can bind to frame children
!isFrameLikeElement(element);
// PERF: Run a cheap test to see if the binding element
// is even close to the element
const t = Math.max(1, tolerance);
const bounds = [x - t, y - t, x + t, y + t] as Bounds;
const elementBounds = getElementBounds(element, elementsMap);
if (!doBoundsIntersect(bounds, elementBounds)) {
return false;
}
// If the element is inside a frame, we should clip the element
if (element.frameId) {
const enclosingFrame = elementsMap.get(element.frameId);
if (enclosingFrame && isFrameLikeElement(enclosingFrame)) {
const enclosingFrameBounds = getElementBounds(
enclosingFrame,
elementsMap,
);
if (!pointInsideBounds(p, enclosingFrameBounds)) {
return false;
}
}
}
// Do the intersection test against the element since it's close enough
const intersections = intersectElementWithLineSegment(
element,
elementsMap,
lineSegment(elementCenterPoint(element, elementsMap), p),
);
const distance = distanceToElement(element, elementsMap, p);
return shouldTestInside
? intersections.length === 0 || distance <= tolerance
: intersections.length > 0 && distance <= t;
};
export const getAllHoveredElementAtPoint = (
point: Readonly<GlobalPoint>,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
toleranceFn?: (element: ExcalidrawBindableElement) => number,
): NonDeleted<ExcalidrawBindableElement>[] => {
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
// We need to to hit testing from front (end of the array) to back (beginning of the array)
// because array is ordered from lower z-index to highest and we want element z-index
// with higher z-index
for (let index = elements.length - 1; index >= 0; --index) {
const element = elements[index];
invariant(
!element.isDeleted,
"Elements in the function parameter for getAllElementsAtPositionForBinding() should not contain deleted elements",
);
if (
isBindableElement(element, false) &&
bindingBorderTest(element, point, elementsMap, toleranceFn?.(element))
) {
candidateElements.push(element);
if (!isTransparent(element.backgroundColor)) {
break;
}
}
}
return candidateElements;
};
export const getHoveredElementForBinding = (
point: Readonly<GlobalPoint>,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
toleranceFn?: (element: ExcalidrawBindableElement) => number,
): NonDeleted<ExcalidrawBindableElement> | null => {
const candidateElements = getAllHoveredElementAtPoint(
point,
elements,
elementsMap,
toleranceFn,
);
if (!candidateElements || candidateElements.length === 0) {
return null;
}
if (candidateElements.length === 1) {
return candidateElements[0];
}
// Prefer smaller shapes
return candidateElements
.sort(
(a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2),
)
.pop() as NonDeleted<ExcalidrawBindableElement>;
};
/**
* Intersect a line with an element for binding test
*
@@ -676,61 +551,3 @@ export const isPointInElement = (
return intersections.length % 2 === 1;
};
export const isBindableElementInsideOtherBindable = (
innerElement: ExcalidrawBindableElement,
outerElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
): boolean => {
// Get corner points of the inner element based on its type
const getCornerPoints = (
element: ExcalidrawElement,
offset: number,
): GlobalPoint[] => {
const { x, y, width, height, angle } = element;
const center = elementCenterPoint(element, elementsMap);
if (element.type === "diamond") {
// Diamond has 4 corner points at the middle of each side
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element);
const corners: GlobalPoint[] = [
pointFrom(x + topX, y + topY - offset), // top
pointFrom(x + rightX + offset, y + rightY), // right
pointFrom(x + bottomX, y + bottomY + offset), // bottom
pointFrom(x + leftX - offset, y + leftY), // left
];
return corners.map((corner) => pointRotateRads(corner, center, angle));
}
if (element.type === "ellipse") {
// For ellipse, test points at the extremes (top, right, bottom, left)
const cx = x + width / 2;
const cy = y + height / 2;
const rx = width / 2;
const ry = height / 2;
const corners: GlobalPoint[] = [
pointFrom(cx, cy - ry - offset), // top
pointFrom(cx + rx + offset, cy), // right
pointFrom(cx, cy + ry + offset), // bottom
pointFrom(cx - rx - offset, cy), // left
];
return corners.map((corner) => pointRotateRads(corner, center, angle));
}
// Rectangle and other rectangular shapes (image, text, etc.)
const corners: GlobalPoint[] = [
pointFrom(x - offset, y - offset), // top-left
pointFrom(x + width + offset, y - offset), // top-right
pointFrom(x + width + offset, y + height + offset), // bottom-right
pointFrom(x - offset, y + height + offset), // bottom-left
];
return corners.map((corner) => pointRotateRads(corner, center, angle));
};
const offset = (-1 * Math.max(innerElement.width, innerElement.height)) / 20; // 5% offset
const innerCorners = getCornerPoints(innerElement, offset);
// Check if all corner points of the inner element are inside the outer element
return innerCorners.every((corner) =>
isPointInElement(corner, outerElement, elementsMap),
);
};
+1 -2
View File
@@ -16,8 +16,7 @@ export const hasStrokeColor = (type: ElementOrToolType) =>
type === "freedraw" ||
type === "arrow" ||
type === "line" ||
type === "text" ||
type === "embeddable";
type === "text";
export const hasStrokeWidth = (type: ElementOrToolType) =>
type === "rectangle" ||
+2 -2
View File
@@ -2,10 +2,10 @@ import {
curvePointDistance,
distanceToLineSegment,
pointRotateRads,
ellipse,
ellipseDistanceFromPoint,
} from "@excalidraw/math";
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
import type { GlobalPoint, Radians } from "@excalidraw/math";
import {
+3 -48
View File
@@ -1,9 +1,7 @@
import {
type Bounds,
TEXT_AUTOWRAP_THRESHOLD,
getGridPoint,
getFontString,
DRAGGING_THRESHOLD,
} from "@excalidraw/common";
import type {
@@ -15,7 +13,7 @@ import type {
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { unbindBindingElement, updateBoundElements } from "./binding";
import { updateBoundElements } from "./binding";
import { getCommonBounds } from "./bounds";
import { getPerfectElementSize } from "./sizeHelpers";
import { getBoundTextElement } from "./textElement";
@@ -30,6 +28,7 @@ import {
import type { Scene } from "./Scene";
import type { Bounds } from "./bounds";
import type { ExcalidrawElement } from "./types";
export const dragSelectedElements = (
@@ -103,26 +102,9 @@ export const dragSelectedElements = (
gridSize,
);
const elementsToUpdateIds = new Set(
Array.from(elementsToUpdate, (el) => el.id),
);
elementsToUpdate.forEach((element) => {
const isArrow = !isArrowElement(element);
const isStartBoundElementSelected =
isArrow ||
(element.startBinding
? elementsToUpdateIds.has(element.startBinding.elementId)
: false);
const isEndBoundElementSelected =
isArrow ||
(element.endBinding
? elementsToUpdateIds.has(element.endBinding.elementId)
: false);
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
if (!isArrowElement(element)) {
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
// skip arrow labels since we calculate its position during render
const textElement = getBoundTextElement(
element,
@@ -139,33 +121,6 @@ export const dragSelectedElements = (
updateBoundElements(element, scene, {
simultaneouslyUpdated: Array.from(elementsToUpdate),
});
} else if (
// NOTE: Add a little initial drag to the arrow dragging when the arrow
// is the single element being dragged to avoid accidentally unbinding
// the arrow when the user just wants to select it.
elementsToUpdate.size > 1 ||
Math.max(Math.abs(adjustedOffset.x), Math.abs(adjustedOffset.y)) >
DRAGGING_THRESHOLD ||
(!element.startBinding && !element.endBinding)
) {
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
const shouldUnbindStart =
element.startBinding && !isStartBoundElementSelected;
const shouldUnbindEnd = element.endBinding && !isEndBoundElementSelected;
if (shouldUnbindStart || shouldUnbindEnd) {
// NOTE: Moving the bound arrow should unbind it, otherwise we would
// have weird situations, like 0 lenght arrow when the user moves
// the arrow outside a filled shape suddenly forcing the arrow start
// and end point to jump "outside" the shape.
if (shouldUnbindStart) {
unbindBindingElement(element, "start", scene);
}
if (shouldUnbindEnd) {
unbindBindingElement(element, "end", scene);
}
}
}
});
};
+19 -24
View File
@@ -14,10 +14,10 @@ import {
} from "@excalidraw/math";
import {
type Bounds,
BinaryHeap,
invariant,
isAnyTrue,
tupleToCoors,
getSizeFromPoints,
isDevEnv,
arrayToMap,
@@ -27,11 +27,10 @@ import type { AppState } from "@excalidraw/excalidraw/types";
import {
bindPointToSnapToElementOutline,
FIXED_BINDING_DISTANCE,
getHeadingForElbowArrowSnap,
getGlobalFixedPointForBindableElement,
getBindingGap,
maxBindingDistance_simple,
BASE_BINDING_GAP_ELBOW,
getHoveredElementForBinding,
} from "./binding";
import { distanceToElement } from "./distance";
import {
@@ -52,9 +51,10 @@ import {
type ExcalidrawElbowArrowElement,
type NonDeletedSceneElementsMap,
} from "./types";
import { aabbForElement, pointInsideBounds } from "./bounds";
import { getHoveredElementForBinding } from "./collision";
import { aabbForElement, pointInsideBounds } from "./bounds";
import type { Bounds } from "./bounds";
import type { Heading } from "./heading";
import type {
Arrowhead,
@@ -63,7 +63,6 @@ import type {
FixedPointBinding,
FixedSegment,
NonDeletedExcalidrawElement,
Ordered,
} from "./types";
type GridAddress = [number, number] & { _brand: "gridaddress" };
@@ -1244,7 +1243,6 @@ const getElbowArrowData = (
const startGlobalPoint = getGlobalPoint(
{
...arrow,
angle: 0,
type: "arrow",
elbowed: true,
points: nextPoints,
@@ -1259,7 +1257,6 @@ const getElbowArrowData = (
const endGlobalPoint = getGlobalPoint(
{
...arrow,
angle: 0,
type: "arrow",
elbowed: true,
points: nextPoints,
@@ -1277,7 +1274,6 @@ const getElbowArrowData = (
hoveredStartElement,
origStartGlobalPoint,
elementsMap,
options?.zoom,
);
const endHeading = getBindPointHeading(
endGlobalPoint,
@@ -1285,7 +1281,6 @@ const getElbowArrowData = (
hoveredEndElement,
origEndGlobalPoint,
elementsMap,
options?.zoom,
);
const startPointBounds = [
startGlobalPoint[0] - 2,
@@ -1306,8 +1301,8 @@ const getElbowArrowData = (
offsetFromHeading(
startHeading,
arrow.startArrowhead
? getBindingGap(hoveredStartElement, { elbowed: true }) * 6
: getBindingGap(hoveredStartElement, { elbowed: true }) * 2,
? FIXED_BINDING_DISTANCE * 6
: FIXED_BINDING_DISTANCE * 2,
1,
),
)
@@ -1319,8 +1314,8 @@ const getElbowArrowData = (
offsetFromHeading(
endHeading,
arrow.endArrowhead
? getBindingGap(hoveredEndElement, { elbowed: true }) * 6
: getBindingGap(hoveredEndElement, { elbowed: true }) * 2,
? FIXED_BINDING_DISTANCE * 6
: FIXED_BINDING_DISTANCE * 2,
1,
),
)
@@ -1367,8 +1362,8 @@ const getElbowArrowData = (
? 0
: BASE_PADDING -
(arrow.startArrowhead
? BASE_BINDING_GAP_ELBOW * 6
: BASE_BINDING_GAP_ELBOW * 2),
? FIXED_BINDING_DISTANCE * 6
: FIXED_BINDING_DISTANCE * 2),
BASE_PADDING,
),
boundsOverlap
@@ -1383,8 +1378,8 @@ const getElbowArrowData = (
? 0
: BASE_PADDING -
(arrow.endArrowhead
? BASE_BINDING_GAP_ELBOW * 6
: BASE_BINDING_GAP_ELBOW * 2),
? FIXED_BINDING_DISTANCE * 6
: FIXED_BINDING_DISTANCE * 2),
BASE_PADDING,
),
boundsOverlap,
@@ -2244,7 +2239,6 @@ const getBindPointHeading = (
hoveredElement: ExcalidrawBindableElement | null | undefined,
origPoint: GlobalPoint,
elementsMap: ElementsMap,
zoom?: AppState["zoom"],
): Heading =>
getHeadingForElbowArrowSnap(
p,
@@ -2263,20 +2257,21 @@ const getBindPointHeading = (
),
origPoint,
elementsMap,
zoom,
);
const getHoveredElement = (
origPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elements: readonly NonDeletedExcalidrawElement[],
zoom?: AppState["zoom"],
) => {
return getHoveredElementForBinding(
origPoint,
tupleToCoors(origPoint),
elements,
elementsMap,
(element) => maxBindingDistance_simple(zoom),
zoom,
true,
true,
);
};
+3 -9
View File
@@ -7,7 +7,7 @@ import type {
PendingExcalidrawElements,
} from "@excalidraw/excalidraw/types";
import { bindBindingElement } from "./binding";
import { bindLinearElement } from "./binding";
import { updateElbowArrowPoints } from "./elbowArrow";
import {
HEADING_DOWN,
@@ -446,14 +446,8 @@ const createBindingArrow = (
const elementsMap = scene.getNonDeletedElementsMap();
bindBindingElement(
bindingArrow,
startBindingElement,
"orbit",
"start",
scene,
);
bindBindingElement(bindingArrow, endBindingElement, "orbit", "end", scene);
bindLinearElement(bindingArrow, startBindingElement, "start", scene);
bindLinearElement(bindingArrow, endBindingElement, "end", scene);
const changedElements = new Map<string, OrderedExcalidrawElement>();
changedElements.set(
+2 -7
View File
@@ -1,9 +1,4 @@
import {
invariant,
isDevEnv,
isTestEnv,
type Bounds,
} from "@excalidraw/common";
import { invariant, isDevEnv, isTestEnv } from "@excalidraw/common";
import {
pointFrom,
@@ -24,7 +19,7 @@ import type {
Vector,
} from "@excalidraw/math";
import { getCenterForBounds } from "./bounds";
import { getCenterForBounds, type Bounds } from "./bounds";
import type { ExcalidrawBindableElement } from "./types";
+22 -1
View File
@@ -1,6 +1,7 @@
import { toIterable } from "@excalidraw/common";
import { isInvisiblySmallElement } from "./sizeHelpers";
import { isLinearElementType } from "./typeChecks";
import type {
ExcalidrawElement,
@@ -54,6 +55,27 @@ export const isNonDeletedElement = <T extends ExcalidrawElement>(
element: T,
): element is NonDeleted<T> => !element.isDeleted;
const _clearElements = (
elements: readonly ExcalidrawElement[],
): ExcalidrawElement[] =>
getNonDeletedElements(elements).map((element) =>
isLinearElementType(element.type)
? { ...element, lastCommittedPoint: null }
: element,
);
export const clearElementsForDatabase = (
elements: readonly ExcalidrawElement[],
) => _clearElements(elements);
export const clearElementsForExport = (
elements: readonly ExcalidrawElement[],
) => _clearElements(elements);
export const clearElementsForLocalStorage = (
elements: readonly ExcalidrawElement[],
) => _clearElements(elements);
export * from "./align";
export * from "./binding";
export * from "./bounds";
@@ -92,7 +114,6 @@ export * from "./store";
export * from "./textElement";
export * from "./textMeasurements";
export * from "./textWrapping";
export * from "./transform";
export * from "./transformHandles";
export * from "./typeChecks";
export * from "./utils";
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -46,13 +46,16 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
// casting to any because can't use `in` operator
// (see https://github.com/microsoft/TypeScript/issues/21732)
const { points, fixedSegments, fileId } = updates as any;
const { points, fixedSegments, startBinding, endBinding, fileId } =
updates as any;
if (
isElbowArrow(element) &&
(Object.keys(updates).length === 0 || // normalization case
typeof points !== "undefined" || // repositioning
typeof fixedSegments !== "undefined") // segment fixing
typeof fixedSegments !== "undefined" || // segment fixing
typeof startBinding !== "undefined" ||
typeof endBinding !== "undefined") // manual binding to element
) {
updates = {
...updates,
+23 -23
View File
@@ -21,7 +21,7 @@ import {
getResizedElementAbsoluteCoords,
} from "./bounds";
import { newElementWith } from "./mutateElement";
import { getBoundTextMaxWidth, getInitialTextMetrics } from "./textElement";
import { getBoundTextMaxWidth } from "./textElement";
import { normalizeText, measureText } from "./textMeasurements";
import { wrapText } from "./textWrapping";
@@ -236,30 +236,27 @@ const getTextElementPositionOffsets = (
};
};
export type NewTextElementOptions = {
text: string;
originalText?: string;
fontSize?: number;
fontFamily?: FontFamilyValues;
textAlign?: TextAlign;
verticalAlign?: VerticalAlign;
containerId?: ExcalidrawTextContainer["id"] | null;
lineHeight?: ExcalidrawTextElement["lineHeight"];
autoResize?: ExcalidrawTextElement["autoResize"];
} & ElementConstructorOpts;
export const newTextElement = (
opts: NewTextElementOptions,
opts: {
text: string;
originalText?: string;
fontSize?: number;
fontFamily?: FontFamilyValues;
textAlign?: TextAlign;
verticalAlign?: VerticalAlign;
containerId?: ExcalidrawTextContainer["id"] | null;
lineHeight?: ExcalidrawTextElement["lineHeight"];
autoResize?: ExcalidrawTextElement["autoResize"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawTextElement> => {
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
const fontSize = opts.fontSize || DEFAULT_FONT_SIZE;
const lineHeight = opts.lineHeight || getLineHeight(fontFamily);
const normalizedText = normalizeText(opts.text);
const originalText = opts.originalText ?? normalizedText;
const metrics = getInitialTextMetrics(
{ ...opts, text: normalizedText },
fontFamily,
fontSize,
const text = normalizeText(opts.text);
const metrics = measureText(
text,
getFontString({ fontFamily, fontSize }),
lineHeight,
);
const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN;
const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN;
@@ -270,7 +267,7 @@ export const newTextElement = (
const textElementProps: ExcalidrawTextElement = {
..._newElementBase<ExcalidrawTextElement>("text", opts),
text: normalizedText,
text,
fontSize,
fontFamily,
textAlign,
@@ -280,7 +277,7 @@ export const newTextElement = (
width: metrics.width,
height: metrics.height,
containerId: opts.containerId || null,
originalText,
originalText: opts.originalText ?? text,
autoResize: opts.autoResize ?? true,
lineHeight,
};
@@ -455,6 +452,7 @@ export const newFreeDrawElement = (
points: opts.points || [],
pressures: opts.pressures || [],
simulatePressure: opts.simulatePressure,
lastCommittedPoint: null,
};
};
@@ -468,7 +466,7 @@ export const newLinearElement = (
const element = {
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
points: opts.points || [],
lastCommittedPoint: null,
startBinding: null,
endBinding: null,
startArrowhead: null,
@@ -503,6 +501,7 @@ export const newArrowElement = <T extends boolean>(
return {
..._newElementBase<ExcalidrawElbowArrowElement>(opts.type, opts),
points: opts.points || [],
lastCommittedPoint: null,
startBinding: null,
endBinding: null,
startArrowhead: opts.startArrowhead || null,
@@ -517,6 +516,7 @@ export const newArrowElement = <T extends boolean>(
return {
..._newElementBase<ExcalidrawArrowElement>(opts.type, opts),
points: opts.points || [],
lastCommittedPoint: null,
startBinding: null,
endBinding: null,
startArrowhead: opts.startArrowhead || null,
+17 -10
View File
@@ -98,7 +98,7 @@ const isPendingImageElement = (
const shouldResetImageFilter = (
element: ExcalidrawElement,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
appState: StaticCanvasAppState,
) => {
return (
appState.theme === THEME.DARK &&
@@ -225,7 +225,7 @@ const generateElementCanvas = (
elementsMap: NonDeletedSceneElementsMap,
zoom: Zoom,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
appState: StaticCanvasAppState,
): ExcalidrawElementWithCanvas | null => {
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")!;
@@ -277,7 +277,7 @@ const generateElementCanvas = (
context.filter = IMAGE_INVERT_FILTER;
}
drawElementOnCanvas(element, rc, context, renderConfig);
drawElementOnCanvas(element, rc, context, renderConfig, appState);
context.restore();
@@ -412,6 +412,7 @@ const drawElementOnCanvas = (
rc: RoughCanvas,
context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
) => {
switch (element.type) {
case "rectangle":
@@ -557,7 +558,7 @@ const generateElementWithCanvas = (
element: NonDeletedExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
appState: StaticCanvasAppState,
) => {
const zoom: Zoom = renderConfig
? appState.zoom
@@ -614,7 +615,7 @@ const drawElementFromCanvas = (
elementWithCanvas: ExcalidrawElementWithCanvas,
context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
appState: StaticCanvasAppState,
allElementsMap: NonDeletedSceneElementsMap,
) => {
const element = elementWithCanvas.element;
@@ -732,7 +733,7 @@ export const renderElement = (
rc: RoughCanvas,
context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState | InteractiveCanvasAppState,
appState: StaticCanvasAppState,
) => {
const reduceAlphaForSelection =
appState.openDialog?.name === "elementLinkSelector" &&
@@ -802,7 +803,7 @@ export const renderElement = (
context.translate(cx, cy);
context.rotate(element.angle);
context.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, rc, context, renderConfig);
drawElementOnCanvas(element, rc, context, renderConfig, appState);
context.restore();
} else {
const elementWithCanvas = generateElementWithCanvas(
@@ -895,7 +896,13 @@ export const renderElement = (
tempCanvasContext.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, tempRc, tempCanvasContext, renderConfig);
drawElementOnCanvas(
element,
tempRc,
tempCanvasContext,
renderConfig,
appState,
);
tempCanvasContext.translate(shiftX, shiftY);
@@ -934,7 +941,7 @@ export const renderElement = (
}
context.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, rc, context, renderConfig);
drawElementOnCanvas(element, rc, context, renderConfig, appState);
}
context.restore();
@@ -1115,7 +1122,7 @@ export function getFreedrawOutlinePoints(element: ExcalidrawFreeDrawElement) {
smoothing: 0.5,
streamline: 0.5,
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
last: true,
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
};
return getStroke(inputPoints as number[][], options) as [number, number][];
+9 -84
View File
@@ -20,11 +20,7 @@ import type { PointerDownState } from "@excalidraw/excalidraw/types";
import type { Mutable } from "@excalidraw/common/utility-types";
import {
getArrowLocalFixedPoints,
unbindBindingElement,
updateBoundElements,
} from "./binding";
import { getArrowLocalFixedPoints, updateBoundElements } from "./binding";
import {
getElementAbsoluteCoords,
getCommonBounds,
@@ -50,7 +46,6 @@ import {
import { wrapText } from "./textWrapping";
import {
isArrowElement,
isBindingElement,
isBoundToContainer,
isElbowArrow,
isFrameLikeElement,
@@ -79,9 +74,7 @@ import type {
ExcalidrawImageElement,
ElementsMap,
ExcalidrawElbowArrowElement,
ExcalidrawArrowElement,
} from "./types";
import type { ElementUpdate } from "./mutateElement";
// Returns true when transform (resizing/rotation) happened
export const transformElements = (
@@ -227,25 +220,7 @@ const rotateSingleElement = (
}
const boundTextElementId = getBoundTextElementId(element);
let update: ElementUpdate<NonDeletedExcalidrawElement> = {
angle,
};
if (isBindingElement(element)) {
update = {
...update,
} as ElementUpdate<ExcalidrawArrowElement>;
if (element.startBinding) {
unbindBindingElement(element, "start", scene);
}
if (element.endBinding) {
unbindBindingElement(element, "end", scene);
}
}
scene.mutateElement(element, update);
scene.mutateElement(element, { angle });
if (boundTextElementId) {
const textElement =
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
@@ -419,11 +394,6 @@ const rotateMultipleElements = (
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
}
const rotatedElementsMap = new Map<
ExcalidrawElement["id"],
NonDeletedExcalidrawElement
>(elements.map((element) => [element.id, element]));
for (const element of elements) {
if (!isFrameLikeElement(element)) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
@@ -454,19 +424,6 @@ const rotateMultipleElements = (
simultaneouslyUpdated: elements,
});
if (isBindingElement(element)) {
if (element.startBinding) {
if (!rotatedElementsMap.has(element.startBinding.elementId)) {
unbindBindingElement(element, "start", scene);
}
}
if (element.endBinding) {
if (!rotatedElementsMap.has(element.endBinding.elementId)) {
unbindBindingElement(element, "end", scene);
}
}
}
const boundText = getBoundTextElement(element, elementsMap);
if (boundText && !isArrowElement(element)) {
const { x, y } = computeBoundTextPosition(
@@ -878,32 +835,13 @@ export const resizeSingleElement = (
Number.isFinite(newOrigin.x) &&
Number.isFinite(newOrigin.y)
) {
let updates: ElementUpdate<ExcalidrawElement> = {
const updates = {
...newOrigin,
width: Math.abs(nextWidth),
height: Math.abs(nextHeight),
...rescaledPoints,
};
if (isBindingElement(latestElement)) {
if (latestElement.startBinding) {
updates = {
...updates,
} as ElementUpdate<ExcalidrawArrowElement>;
if (latestElement.startBinding) {
unbindBindingElement(latestElement, "start", scene);
}
}
if (latestElement.endBinding) {
updates = {
...updates,
endBinding: null,
} as ElementUpdate<ExcalidrawArrowElement>;
}
}
scene.mutateElement(latestElement, updates, {
informMutation: shouldInformMutation,
isDragging: false,
@@ -921,7 +859,10 @@ export const resizeSingleElement = (
shouldMaintainAspectRatio,
);
updateBoundElements(latestElement, scene);
updateBoundElements(latestElement, scene, {
// TODO: confirm with MARK if this actually makes sense
newSize: { width: nextWidth, height: nextHeight },
});
}
};
@@ -1455,36 +1396,20 @@ export const resizeMultipleElements = (
}
const elementsToUpdate = elementsAndUpdates.map(({ element }) => element);
const resizedElementsMap = new Map<
ExcalidrawElement["id"],
NonDeletedExcalidrawElement
>(elementsAndUpdates.map(({ element }) => [element.id, element]));
for (const {
element,
update: { boundTextFontSize, ...update },
} of elementsAndUpdates) {
const { angle } = update;
const { width, height, angle } = update;
scene.mutateElement(element, update);
updateBoundElements(element, scene, {
simultaneouslyUpdated: elementsToUpdate,
newSize: { width, height },
});
if (isBindingElement(element)) {
if (element.startBinding) {
if (!resizedElementsMap.has(element.startBinding.elementId)) {
unbindBindingElement(element, "start", scene);
}
}
if (element.endBinding) {
if (!resizedElementsMap.has(element.endBinding.elementId)) {
unbindBindingElement(element, "end", scene);
}
}
}
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement && boundTextFontSize) {
scene.mutateElement(boundTextElement, {
+1 -1
View File
@@ -13,7 +13,6 @@ import {
import type { GlobalPoint, LineSegment, LocalPoint } from "@excalidraw/math";
import type { AppState, Zoom } from "@excalidraw/excalidraw/types";
import type { Bounds } from "@excalidraw/common";
import { getElementAbsoluteCoords } from "./bounds";
import {
@@ -24,6 +23,7 @@ import {
} from "./transformHandles";
import { isImageElement, isLinearElement } from "./typeChecks";
import type { Bounds } from "./bounds";
import type {
TransformHandleType,
TransformHandle,
-26
View File
@@ -8,8 +8,6 @@ import {
getFontString,
isProdEnv,
invariant,
DEFAULT_FONT_FAMILY,
getLineHeight,
} from "@excalidraw/common";
import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
@@ -32,8 +30,6 @@ import {
isTextElement,
} from "./typeChecks";
import type { NewTextElementOptions } from "./newElement";
import type { Scene } from "./Scene";
import type { MaybeTransformHandleType } from "./transformHandles";
@@ -44,7 +40,6 @@ import type {
ExcalidrawTextContainer,
ExcalidrawTextElement,
ExcalidrawTextElementWithContainer,
FontFamilyValues,
NonDeletedExcalidrawElement,
} from "./types";
@@ -533,24 +528,3 @@ export const getTextFromElements = (
.join(separator);
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),
);
};
+2 -5
View File
@@ -11,7 +11,6 @@ import type {
InteractiveCanvasAppState,
Zoom,
} from "@excalidraw/excalidraw/types";
import type { Bounds } from "@excalidraw/common";
import { getElementAbsoluteCoords } from "./bounds";
import {
@@ -21,6 +20,7 @@ import {
isLinearElement,
} from "./typeChecks";
import type { Bounds } from "./bounds";
import type {
ElementsMap,
ExcalidrawElement,
@@ -330,10 +330,7 @@ export const hasBoundingBox = (
appState: InteractiveCanvasAppState,
editorInterface: EditorInterface,
) => {
if (
appState.selectedLinearElement?.isEditing ||
appState.selectedLinearElement?.isDragging
) {
if (appState.selectedLinearElement?.isEditing) {
return false;
}
if (elements.length > 1) {
+22 -1
View File
@@ -6,6 +6,7 @@ import type { ElementOrToolType } from "@excalidraw/excalidraw/types";
import type { MarkNonNullable } from "@excalidraw/common/utility-types";
import type { Bounds } from "./bounds";
import type {
ExcalidrawElement,
ExcalidrawTextElement,
@@ -27,6 +28,8 @@ import type {
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
ExcalidrawLineElement,
PointBinding,
FixedPointBinding,
ExcalidrawFlowchartNodeElement,
ExcalidrawLinearElementSubType,
} from "./types";
@@ -160,7 +163,7 @@ export const isLinearElementType = (
export const isBindingElement = (
element?: ExcalidrawElement | null,
includeLocked = true,
): element is ExcalidrawArrowElement => {
): element is ExcalidrawLinearElement => {
return (
element != null &&
(!element.locked || includeLocked === true) &&
@@ -355,6 +358,24 @@ export const getDefaultRoundnessTypeForElement = (
return null;
};
export const isFixedPointBinding = (
binding: PointBinding | FixedPointBinding,
): binding is FixedPointBinding => {
return (
Object.hasOwn(binding, "fixedPoint") &&
(binding as FixedPointBinding).fixedPoint != 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 = (
element: ExcalidrawLinearElement,
): ExcalidrawLinearElementSubType => {
+20 -17
View File
@@ -279,23 +279,24 @@ export type ExcalidrawTextElementWithContainer = {
export type FixedPoint = [number, number];
export type BindMode = "inside" | "orbit" | "skip";
export type FixedPointBinding = {
export type PointBinding = {
elementId: ExcalidrawBindableElement["id"];
// Represents the fixed point binding information in form of a vertical and
// horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
// gives the user selected fixed point by multiplying the bound element width
// with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
// bound element-local point coordinate.
fixedPoint: FixedPoint;
// Determines whether the arrow remains outside the shape or is allowed to
// go all the way inside the shape up to the exact fixed point.
mode: BindMode;
focus: number;
gap: number;
};
export type FixedPointBinding = Merge<
PointBinding,
{
// Represents the fixed point binding information in form of a vertical and
// horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
// gives the user selected fixed point by multiplying the bound element width
// with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
// bound element-local point coordinate.
fixedPoint: FixedPoint;
}
>;
type Index = number;
export type PointsPositionUpdates = Map<
@@ -321,8 +322,9 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{
type: "line" | "arrow";
points: readonly LocalPoint[];
startBinding: FixedPointBinding | null;
endBinding: FixedPointBinding | null;
lastCommittedPoint: LocalPoint | null;
startBinding: PointBinding | null;
endBinding: PointBinding | null;
startArrowhead: Arrowhead | null;
endArrowhead: Arrowhead | null;
}>;
@@ -349,9 +351,9 @@ export type ExcalidrawElbowArrowElement = Merge<
ExcalidrawArrowElement,
{
elbowed: true;
fixedSegments: readonly FixedSegment[] | null;
startBinding: FixedPointBinding | null;
endBinding: FixedPointBinding | null;
fixedSegments: readonly FixedSegment[] | null;
/**
* Marks that the 3rd point should be used as the 2nd point of the arrow in
* order to temporarily hide the first segment of the arrow without losing
@@ -377,6 +379,7 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
points: readonly LocalPoint[];
pressures: readonly number[];
simulatePressure: boolean;
lastCommittedPoint: LocalPoint | null;
}>;
export type FileId = string & { _brand: "FileId" };
+15 -152
View File
@@ -1,7 +1,6 @@
import {
DEFAULT_ADAPTIVE_RADIUS,
DEFAULT_PROPORTIONAL_RADIUS,
invariant,
LINE_CONFIRM_THRESHOLD,
ROUNDNESS,
} from "@excalidraw/common";
@@ -11,17 +10,10 @@ import {
curveCatmullRomCubicApproxPoints,
curveOffsetPoints,
lineSegment,
lineSegmentIntersectionPoints,
pointDistance,
pointFrom,
pointFromArray,
pointFromVector,
pointRotateRads,
pointTranslate,
rectangle,
vectorFromPoint,
vectorNormalize,
vectorScale,
type GlobalPoint,
} from "@excalidraw/math";
@@ -29,17 +21,11 @@ import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
import { elementCenterPoint, getDiamondPoints } from "./bounds";
import { getDiamondPoints } from "./bounds";
import { generateLinearCollisionShape } from "./shape";
import { isPointInElement } from "./collision";
import { LinearElementEditor } from "./linearElementEditor";
import { isRectangularElement } from "./typeChecks";
import type {
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawFreeDrawElement,
@@ -414,10 +400,20 @@ export function deconstructDiamondElement(
), // TOP
];
const corners = baseCorners.map(
(corner) =>
curveCatmullRomCubicApproxPoints(curveOffsetPoints(corner, offset))!,
);
const corners =
offset > 0
? baseCorners.map(
(corner) =>
curveCatmullRomCubicApproxPoints(
curveOffsetPoints(corner, offset),
)!,
)
: [
[baseCorners[0]],
[baseCorners[1]],
[baseCorners[2]],
[baseCorners[3]],
];
const sides = [
lineSegment<GlobalPoint>(
@@ -485,136 +481,3 @@ export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
return 0;
};
const getDiagonalsForBindableElement = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
) => {
// for rectangles, shrink the diagonals a bit because there's something
// going on with the focus points around the corners. Ask Mark for details.
const OFFSET_PX = element.type === "rectangle" ? 15 : 0;
const shrinkSegment = (seg: LineSegment<GlobalPoint>) => {
const v = vectorNormalize(vectorFromPoint(seg[1], seg[0]));
const offset = vectorScale(v, OFFSET_PX);
return lineSegment<GlobalPoint>(
pointTranslate(seg[0], offset),
pointTranslate(seg[1], vectorScale(offset, -1)),
);
};
const center = elementCenterPoint(element, elementsMap);
const diagonalOne = shrinkSegment(
isRectangularElement(element)
? lineSegment<GlobalPoint>(
pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + element.width,
element.y + element.height,
),
center,
element.angle,
),
)
: lineSegment<GlobalPoint>(
pointRotateRads(
pointFrom<GlobalPoint>(element.x + element.width / 2, element.y),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height,
),
center,
element.angle,
),
),
);
const diagonalTwo = shrinkSegment(
isRectangularElement(element)
? lineSegment<GlobalPoint>(
pointRotateRads(
pointFrom<GlobalPoint>(element.x + element.width, element.y),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y + element.height),
center,
element.angle,
),
)
: lineSegment<GlobalPoint>(
pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y + element.height / 2),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + element.width,
element.y + element.height / 2,
),
center,
element.angle,
),
),
);
return [diagonalOne, diagonalTwo];
};
export const projectFixedPointOntoDiagonal = (
arrow: ExcalidrawArrowElement,
point: GlobalPoint,
element: ExcalidrawElement,
startOrEnd: "start" | "end",
elementsMap: ElementsMap,
): GlobalPoint | null => {
invariant(arrow.points.length >= 2, "Arrow must have at least two points");
if (arrow.width < 3 && arrow.height < 3) {
return null;
}
const [diagonalOne, diagonalTwo] = getDiagonalsForBindableElement(
element,
elementsMap,
);
const a = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
startOrEnd === "start" ? 1 : arrow.points.length - 2,
elementsMap,
);
const b = pointFromVector<GlobalPoint>(
vectorScale(
vectorFromPoint(point, a),
2 * pointDistance(a, point) +
Math.max(
pointDistance(diagonalOne[0], diagonalOne[1]),
pointDistance(diagonalTwo[0], diagonalTwo[1]),
),
),
a,
);
const intersector = lineSegment<GlobalPoint>(point, b);
const p1 = lineSegmentIntersectionPoints(diagonalOne, intersector);
const p2 = lineSegmentIntersectionPoints(diagonalTwo, intersector);
const d1 = p1 && pointDistance(a, p1);
const d2 = p2 && pointDistance(a, p2);
let p = null;
if (d1 != null && d2 != null) {
p = d1 < d2 ? p1 : p2;
} else {
p = p1 || p2 || null;
}
return p && isPointInElement(p, element, elementsMap) ? p : null;
};
+6 -58
View File
@@ -1,25 +1,18 @@
import { arrayToMap, findIndex, findLastIndex } from "@excalidraw/common";
import type { AppState } from "@excalidraw/excalidraw/types";
import type { GlobalPoint } from "@excalidraw/math";
import { isFrameLikeElement, isTextElement } from "./typeChecks";
import { isFrameLikeElement } from "./typeChecks";
import { getElementsInGroup } from "./groups";
import { syncMovedIndices } from "./fractionalIndex";
import { getSelectedElements } from "./selection";
import { getBoundTextElement, getContainerElement } from "./textElement";
import { getHoveredElementForBinding } from "./collision";
import type { Scene } from "./Scene";
import type {
ExcalidrawArrowElement,
ExcalidrawElement,
ExcalidrawFrameLikeElement,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
Ordered,
OrderedExcalidrawElement,
} from "./types";
import type { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./types";
const isOfTargetFrame = (element: ExcalidrawElement, frameId: string) => {
return element.frameId === frameId || element.id === frameId;
@@ -146,51 +139,6 @@ const getContiguousFrameRangeElements = (
return allElements.slice(rangeStart, rangeEnd + 1);
};
/**
* Moves the arrow element above any bindable elements it intersects with or
* hovers over.
*/
export const moveArrowAboveBindable = (
point: GlobalPoint,
arrow: ExcalidrawArrowElement,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
): readonly OrderedExcalidrawElement[] => {
const hoveredElement = getHoveredElementForBinding(
point,
elements,
elementsMap,
);
if (!hoveredElement) {
return elements;
}
const boundTextElement = getBoundTextElement(hoveredElement, elementsMap);
const containerElement = isTextElement(hoveredElement)
? getContainerElement(hoveredElement, elementsMap)
: null;
const bindableIds = [
hoveredElement.id,
boundTextElement?.id,
containerElement?.id,
].filter((id): id is NonDeletedExcalidrawElement["id"] => !!id);
const bindableIdx = elements.findIndex((el) => bindableIds.includes(el.id));
const arrowIdx = elements.findIndex((el) => el.id === arrow.id);
if (arrowIdx !== -1 && bindableIdx !== -1 && arrowIdx < bindableIdx) {
const updatedElements = Array.from(elements);
const arrow = updatedElements.splice(arrowIdx, 1)[0];
updatedElements.splice(bindableIdx, 0, arrow);
scene.replaceAllElements(updatedElements);
}
return elements;
};
/**
* Returns next candidate index that's available to be moved to. Currently that
* is a non-deleted element, and not inside a group (unless we're editing it).
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -135,9 +135,9 @@ describe("getElementBounds", () => {
} as ExcalidrawLinearElement;
const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element]));
expect(x1).toEqual(360.9291017525165);
expect(y1).toEqual(185.24770129343722);
expect(x2).toEqual(481.4815539037601);
expect(y2).toEqual(319.8162855827246);
expect(x1).toEqual(360.3176068760539);
expect(y1).toEqual(185.90654264413516);
expect(x2).toEqual(480.87005902729743);
expect(y2).toEqual(320.4751269334226);
});
});
+15 -8
View File
@@ -144,8 +144,9 @@ describe("duplicating multiple elements", () => {
id: "arrow1",
startBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
mode: "orbit",
},
});
@@ -154,8 +155,9 @@ describe("duplicating multiple elements", () => {
id: "arrow2",
endBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
mode: "orbit",
},
boundElements: [{ id: "text2", type: "text" }],
});
@@ -274,8 +276,9 @@ describe("duplicating multiple elements", () => {
id: "arrow1",
startBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
mode: "orbit",
},
});
@@ -290,13 +293,15 @@ describe("duplicating multiple elements", () => {
id: "arrow2",
startBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
mode: "orbit",
},
endBinding: {
elementId: "rectangle-not-exists",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
mode: "orbit",
},
});
@@ -305,13 +310,15 @@ describe("duplicating multiple elements", () => {
id: "arrow3",
startBinding: {
elementId: "rectangle-not-exists",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
mode: "orbit",
},
endBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
mode: "orbit",
},
});
@@ -814,7 +821,7 @@ describe("duplication z-order", () => {
const arrow = UI.createElement("arrow", {
x: -100,
y: 50,
width: 115,
width: 95,
height: 0,
});
+39 -39
View File
@@ -1,10 +1,13 @@
import { ARROW_TYPE } from "@excalidraw/common";
import { pointFrom } from "@excalidraw/math";
import { Excalidraw } from "@excalidraw/excalidraw";
import { actionSelectAll } from "@excalidraw/excalidraw/actions";
import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions/actionDuplicateSelection";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import {
act,
fireEvent,
@@ -12,11 +15,13 @@ import {
queryByTestId,
render,
} from "@excalidraw/excalidraw/tests/test-utils";
import "@excalidraw/utils/test-utils";
import { bindBindingElement } from "@excalidraw/element";
import type { LocalPoint } from "@excalidraw/math";
import { bindLinearElement } from "../src/binding";
import { Scene } from "../src/Scene";
import type {
@@ -131,11 +136,6 @@ describe("elbow arrow segment move", () => {
});
describe("elbow arrow routing", () => {
beforeEach(async () => {
localStorage.clear();
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("can properly generate orthogonal arrow points", () => {
const scene = new Scene();
const arrow = API.createElement({
@@ -160,8 +160,8 @@ describe("elbow arrow routing", () => {
expect(arrow.width).toEqual(90);
expect(arrow.height).toEqual(200);
});
it("can generate proper points for bound elbow arrow", () => {
const scene = new Scene();
const rectangle1 = API.createElement({
type: "rectangle",
x: -150,
@@ -185,23 +185,25 @@ describe("elbow arrow routing", () => {
height: 200,
points: [pointFrom(0, 0), pointFrom(90, 200)],
}) as ExcalidrawElbowArrowElement;
API.setElements([rectangle1, rectangle2, arrow]);
scene.insertElement(rectangle1);
scene.insertElement(rectangle2);
scene.insertElement(arrow);
bindBindingElement(arrow, rectangle1, "orbit", "start", h.scene);
bindBindingElement(arrow, rectangle2, "orbit", "end", h.scene);
bindLinearElement(arrow, rectangle1, "start", scene);
bindLinearElement(arrow, rectangle2, "end", scene);
expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null);
h.scene.mutateElement(arrow, {
h.app.scene.mutateElement(arrow, {
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
});
expect(arrow.points).toCloselyEqualPoints([
expect(arrow.points).toEqual([
[0, 0],
[39, 0],
[39, 200],
[78, 200],
[45, 0],
[45, 200],
[90, 200],
]);
});
});
@@ -240,9 +242,9 @@ describe("elbow arrow ui", () => {
expect(h.state.currentItemArrowType).toBe(ARROW_TYPE.elbow);
mouse.reset();
mouse.moveTo(-53, -99);
mouse.moveTo(-43, -99);
mouse.click();
mouse.moveTo(53, 99);
mouse.moveTo(43, 99);
mouse.click();
const arrow = h.scene.getSelectedElements(
@@ -251,11 +253,11 @@ describe("elbow arrow ui", () => {
expect(arrow.type).toBe("arrow");
expect(arrow.elbowed).toBe(true);
expect(arrow.points).toCloselyEqualPoints([
expect(arrow.points).toEqual([
[0, 0],
[39, 0],
[39, 200],
[78, 200],
[45, 0],
[45, 200],
[90, 200],
]);
});
@@ -277,9 +279,9 @@ describe("elbow arrow ui", () => {
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.moveTo(-53, -99);
mouse.moveTo(-43, -99);
mouse.click();
mouse.moveTo(53, 99);
mouse.moveTo(43, 99);
mouse.click();
const arrow = h.scene.getSelectedElements(
@@ -295,11 +297,9 @@ describe("elbow arrow ui", () => {
expect(arrow.points.map((point) => point.map(Math.round))).toEqual([
[0, 0],
[36, 0],
[36, 90],
[28, 90],
[28, 164],
[101, 164],
[35, 0],
[35, 165],
[103, 165],
]);
});
@@ -321,9 +321,9 @@ describe("elbow arrow ui", () => {
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.moveTo(-53, -99);
mouse.moveTo(-43, -99);
mouse.click();
mouse.moveTo(53, 99);
mouse.moveTo(43, 99);
mouse.click();
const arrow = h.scene.getSelectedElements(
@@ -351,11 +351,11 @@ describe("elbow arrow ui", () => {
expect(duplicatedArrow.id).not.toBe(originalArrowId);
expect(duplicatedArrow.type).toBe("arrow");
expect(duplicatedArrow.elbowed).toBe(true);
expect(duplicatedArrow.points).toCloselyEqualPoints([
expect(duplicatedArrow.points).toEqual([
[0, 0],
[39, 0],
[39, 200],
[78, 200],
[45, 0],
[45, 200],
[90, 200],
]);
expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null);
@@ -379,9 +379,9 @@ describe("elbow arrow ui", () => {
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.moveTo(-53, -99);
mouse.moveTo(-43, -99);
mouse.click();
mouse.moveTo(53, 99);
mouse.moveTo(43, 99);
mouse.click();
const arrow = h.scene.getSelectedElements(
@@ -405,11 +405,11 @@ describe("elbow arrow ui", () => {
expect(duplicatedArrow.id).not.toBe(originalArrowId);
expect(duplicatedArrow.type).toBe("arrow");
expect(duplicatedArrow.elbowed).toBe(true);
expect(duplicatedArrow.points).toCloselyEqualPoints([
expect(duplicatedArrow.points).toEqual([
[0, 0],
[0, 100],
[78, 100],
[78, 200],
[90, 100],
[90, 200],
]);
});
});
@@ -217,7 +217,7 @@ describe("Test Linear Elements", () => {
// drag line from midpoint
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`8`);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`9`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(line.points.length).toEqual(3);
expect(line.points).toMatchInlineSnapshot(`
@@ -329,7 +329,7 @@ describe("Test Linear Elements", () => {
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
mouse.doubleClick();
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
expect(h.state.selectedLinearElement).toBe(null);
await getTextEditor();
});
@@ -357,7 +357,6 @@ describe("Test Linear Elements", () => {
const originalY = line.y;
enterLineEditingMode(line);
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(line.points.length).toEqual(2);
mouse.clickAt(midpoint[0], midpoint[1]);
@@ -380,7 +379,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
expect(line.points.length).toEqual(3);
expect(line.points).toMatchInlineSnapshot(`
@@ -550,7 +549,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`14`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(line.points.length).toEqual(5);
@@ -601,7 +600,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
line,
@@ -642,7 +641,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
line,
@@ -690,7 +689,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`17`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`10`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
const newMidPoints = LinearElementEditor.getEditorMidPoints(
line,
@@ -748,7 +747,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`14`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(line.points.length).toEqual(5);
expect((h.elements[0] as ExcalidrawLinearElement).points)
@@ -846,7 +845,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
line,
@@ -1304,7 +1303,7 @@ describe("Test Linear Elements", () => {
const arrow = UI.createElement("arrow", {
x: -10,
y: 250,
width: 410,
width: 400,
height: 1,
});
@@ -1317,7 +1316,7 @@ describe("Test Linear Elements", () => {
const textElement = h.elements[2] as ExcalidrawTextElementWithContainer;
expect(arrow.endBinding?.elementId).toBe(rect.id);
expect(arrow.width).toBeCloseTo(399);
expect(arrow.width).toBe(400);
expect(rect.x).toBe(400);
expect(rect.y).toBe(0);
expect(
@@ -1336,7 +1335,7 @@ describe("Test Linear Elements", () => {
mouse.downAt(rect.x, rect.y);
mouse.moveTo(200, 0);
mouse.upAt(200, 0);
expect(arrow.width).toBeCloseTo(199);
expect(arrow.width).toBeCloseTo(200, 0);
expect(rect.x).toBe(200);
expect(rect.y).toBe(0);
expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
+127 -139
View File
@@ -2,7 +2,6 @@ import { pointFrom } from "@excalidraw/math";
import { Excalidraw } from "@excalidraw/excalidraw";
import {
type Bounds,
KEYS,
getSizeFromPoints,
reseed,
@@ -23,6 +22,7 @@ import { resizeSingleElement } from "../src/resizeElements";
import { LinearElementEditor } from "../src/linearElementEditor";
import { getElementPointsCoords } from "../src/bounds";
import type { Bounds } from "../src/bounds";
import type {
ExcalidrawElbowArrowElement,
ExcalidrawFreeDrawElement,
@@ -174,29 +174,29 @@ describe("generic element", () => {
expect(rectangle.angle).toBeCloseTo(0);
});
// it("resizes with bound arrow", async () => {
// const rectangle = UI.createElement("rectangle", {
// width: 200,
// height: 100,
// });
// const arrow = UI.createElement("arrow", {
// x: -30,
// y: 50,
// width: 28,
// height: 5,
// });
it("resizes with bound arrow", async () => {
const rectangle = UI.createElement("rectangle", {
width: 200,
height: 100,
});
const arrow = UI.createElement("arrow", {
x: -30,
y: 50,
width: 28,
height: 5,
});
// expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
// UI.resize(rectangle, "e", [40, 0]);
UI.resize(rectangle, "e", [40, 0]);
// expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0);
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0);
// UI.resize(rectangle, "w", [50, 0]);
UI.resize(rectangle, "w", [50, 0]);
// expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
// expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(80, 0);
// });
expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(80, 0);
});
it("resizes with a label", async () => {
const rectangle = UI.createElement("rectangle", {
@@ -510,12 +510,12 @@ describe("arrow element", () => {
h.state,
)[0] as ExcalidrawElbowArrowElement;
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.06);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize(rectangle, "se", [-200, -150]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.06);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
});
@@ -538,11 +538,11 @@ describe("arrow element", () => {
h.state,
)[0] as ExcalidrawElbowArrowElement;
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.06);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize([rectangle, arrow], "nw", [300, 350]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.06);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
});
});
@@ -595,31 +595,31 @@ describe("text element", () => {
expect(text.fontSize).toBeCloseTo(fontSize * scale);
});
// it("resizes with bound arrow", async () => {
// const text = UI.createElement("text");
// await UI.editText(text, "hello\nworld");
// const boundArrow = UI.createElement("arrow", {
// x: -30,
// y: 25,
// width: 28,
// height: 5,
// });
it("resizes with bound arrow", async () => {
const text = UI.createElement("text");
await UI.editText(text, "hello\nworld");
const boundArrow = UI.createElement("arrow", {
x: -30,
y: 25,
width: 28,
height: 5,
});
// expect(boundArrow.endBinding?.elementId).toEqual(text.id);
expect(boundArrow.endBinding?.elementId).toEqual(text.id);
// UI.resize(text, "ne", [40, 0]);
UI.resize(text, "ne", [40, 0]);
// expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo(30);
expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo(30);
// const textWidth = text.width;
// const scale = 20 / text.height;
// UI.resize(text, "nw", [50, 20]);
const textWidth = text.width;
const scale = 20 / text.height;
UI.resize(text, "nw", [50, 20]);
// expect(boundArrow.endBinding?.elementId).toEqual(text.id);
// expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo(
// 30 + textWidth * scale,
// );
// });
expect(boundArrow.endBinding?.elementId).toEqual(text.id);
expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo(
30 + textWidth * scale,
);
});
it("updates font size via keyboard", async () => {
const text = UI.createElement("text");
@@ -801,36 +801,36 @@ describe("image element", () => {
expect(image.scale).toEqual([1, 1]);
});
// it("resizes with bound arrow", async () => {
// const image = API.createElement({
// type: "image",
// width: 100,
// height: 100,
// });
// API.setElements([image]);
// const arrow = UI.createElement("arrow", {
// x: -30,
// y: 50,
// width: 28,
// height: 5,
// });
it("resizes with bound arrow", async () => {
const image = API.createElement({
type: "image",
width: 100,
height: 100,
});
API.setElements([image]);
const arrow = UI.createElement("arrow", {
x: -30,
y: 50,
width: 28,
height: 5,
});
// expect(arrow.endBinding?.elementId).toEqual(image.id);
expect(arrow.endBinding?.elementId).toEqual(image.id);
// UI.resize(image, "ne", [40, 0]);
UI.resize(image, "ne", [40, 0]);
// expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0);
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0);
// const imageWidth = image.width;
// const scale = 20 / image.height;
// UI.resize(image, "nw", [50, 20]);
const imageWidth = image.width;
const scale = 20 / image.height;
UI.resize(image, "nw", [50, 20]);
// expect(arrow.endBinding?.elementId).toEqual(image.id);
// expect(Math.floor(arrow.width + arrow.endBinding!.gap)).toBeCloseTo(
// 30 + imageWidth * scale,
// 0,
// );
// });
expect(arrow.endBinding?.elementId).toEqual(image.id);
expect(Math.floor(arrow.width + arrow.endBinding!.gap)).toBeCloseTo(
30 + imageWidth * scale,
0,
);
});
});
describe("multiple selection", () => {
@@ -997,80 +997,68 @@ describe("multiple selection", () => {
expect(diagLine.angle).toEqual(0);
});
// it("resizes with bound arrows", async () => {
// const rectangle = UI.createElement("rectangle", {
// position: 0,
// size: 100,
// });
// const leftBoundArrow = UI.createElement("arrow", {
// x: -110,
// y: 50,
// width: 100,
// height: 0,
// });
it("resizes with bound arrows", async () => {
const rectangle = UI.createElement("rectangle", {
position: 0,
size: 100,
});
const leftBoundArrow = UI.createElement("arrow", {
x: -110,
y: 50,
width: 100,
height: 0,
});
// const rightBoundArrow = UI.createElement("arrow", {
// x: 210,
// y: 50,
// width: -100,
// height: 0,
// });
const rightBoundArrow = UI.createElement("arrow", {
x: 210,
y: 50,
width: -100,
height: 0,
});
// const selectionWidth = 210;
// const selectionHeight = 100;
// const move = [40, 40] as [number, number];
// const scale = Math.max(
// 1 - move[0] / selectionWidth,
// 1 - move[1] / selectionHeight,
// );
// const leftArrowBinding: {
// elementId: string;
// gap?: number;
// focus?: number;
// } = {
// ...leftBoundArrow.endBinding,
// } as PointBinding;
// const rightArrowBinding: {
// elementId: string;
// gap?: number;
// focus?: number;
// } = {
// ...rightBoundArrow.endBinding,
// } as PointBinding;
// delete rightArrowBinding.gap;
const selectionWidth = 210;
const selectionHeight = 100;
const move = [40, 40] as [number, number];
const scale = Math.max(
1 - move[0] / selectionWidth,
1 - move[1] / selectionHeight,
);
const leftArrowBinding = { ...leftBoundArrow.endBinding };
const rightArrowBinding = { ...rightBoundArrow.endBinding };
delete rightArrowBinding.gap;
// UI.resize([rectangle, rightBoundArrow], "nw", move, {
// shift: true,
// });
UI.resize([rectangle, rightBoundArrow], "nw", move, {
shift: true,
});
// expect(leftBoundArrow.x).toBeCloseTo(-110);
// expect(leftBoundArrow.y).toBeCloseTo(50);
// expect(leftBoundArrow.width).toBeCloseTo(140, 0);
// expect(leftBoundArrow.height).toBeCloseTo(7, 0);
// expect(leftBoundArrow.angle).toEqual(0);
// expect(leftBoundArrow.startBinding).toBeNull();
// expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(10);
// expect(leftBoundArrow.endBinding?.elementId).toBe(
// leftArrowBinding.elementId,
// );
// expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus);
expect(leftBoundArrow.x).toBeCloseTo(-110);
expect(leftBoundArrow.y).toBeCloseTo(50);
expect(leftBoundArrow.width).toBeCloseTo(140, 0);
expect(leftBoundArrow.height).toBeCloseTo(7, 0);
expect(leftBoundArrow.angle).toEqual(0);
expect(leftBoundArrow.startBinding).toBeNull();
expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(10);
expect(leftBoundArrow.endBinding?.elementId).toBe(
leftArrowBinding.elementId,
);
expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus);
// expect(rightBoundArrow.x).toBeCloseTo(210);
// expect(rightBoundArrow.y).toBeCloseTo(
// (selectionHeight - 50) * (1 - scale) + 50,
// );
// expect(rightBoundArrow.width).toBeCloseTo(100 * scale);
// expect(rightBoundArrow.height).toBeCloseTo(0);
// expect(rightBoundArrow.angle).toEqual(0);
// expect(rightBoundArrow.startBinding).toBeNull();
// expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(8.0952);
// expect(rightBoundArrow.endBinding?.elementId).toBe(
// rightArrowBinding.elementId,
// );
// expect(rightBoundArrow.endBinding?.focus).toBeCloseTo(
// rightArrowBinding.focus!,
// );
// });
expect(rightBoundArrow.x).toBeCloseTo(210);
expect(rightBoundArrow.y).toBeCloseTo(
(selectionHeight - 50) * (1 - scale) + 50,
);
expect(rightBoundArrow.width).toBeCloseTo(100 * scale);
expect(rightBoundArrow.height).toBeCloseTo(0);
expect(rightBoundArrow.angle).toEqual(0);
expect(rightBoundArrow.startBinding).toBeNull();
expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(8.0952);
expect(rightBoundArrow.endBinding?.elementId).toBe(
rightArrowBinding.elementId,
);
expect(rightBoundArrow.endBinding?.focus).toBeCloseTo(
rightArrowBinding.focus!,
);
});
it("resizes with labeled arrows", async () => {
const topArrow = UI.createElement("arrow", {
@@ -1350,8 +1338,8 @@ describe("multiple selection", () => {
expect(boundArrow.x).toBeCloseTo(380 * scaleX);
expect(boundArrow.y).toBeCloseTo(240 * scaleY);
expect(boundArrow.points[1][0]).toBeCloseTo(59.7979);
expect(boundArrow.points[1][1]).toBeCloseTo(-79.7305);
expect(boundArrow.points[1][0]).toBeCloseTo(-60 * scaleX);
expect(boundArrow.points[1][1]).toBeCloseTo(-80 * scaleY);
expect(arrowLabelPos.x + arrowLabel.width / 2).toBeCloseTo(
boundArrow.x + boundArrow.points[1][0] / 2,
+4 -5
View File
@@ -51,7 +51,7 @@ import { register } from "./register";
import type { AppState, Offsets } from "../types";
export const actionChangeViewBackgroundColor = register<Partial<AppState>>({
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
label: "labels.canvasBackground",
trackEvent: false,
@@ -64,7 +64,7 @@ export const actionChangeViewBackgroundColor = register<Partial<AppState>>({
perform: (_, appState, value) => {
return {
appState: { ...appState, ...value },
captureUpdate: !!value?.viewBackgroundColor
captureUpdate: !!value.viewBackgroundColor
? CaptureUpdateAction.IMMEDIATELY
: CaptureUpdateAction.EVENTUALLY,
};
@@ -466,7 +466,7 @@ export const actionZoomToFit = register({
!event[KEYS.CTRL_OR_CMD],
});
export const actionToggleTheme = register<AppState["theme"]>({
export const actionToggleTheme = register({
name: "toggleTheme",
label: (_, appState) => {
return appState.theme === THEME.DARK
@@ -474,8 +474,7 @@ export const actionToggleTheme = register<AppState["theme"]>({
: "buttons.darkMode";
},
keywords: ["toggle", "dark", "light", "mode", "theme"],
icon: (appState, elements) =>
appState.theme === THEME.LIGHT ? MoonIcon : SunIcon,
icon: (appState) => (appState.theme === THEME.LIGHT ? MoonIcon : SunIcon),
viewMode: true,
trackEvent: { category: "canvas" },
perform: (_, appState, value) => {
@@ -20,12 +20,12 @@ import { t } from "../i18n";
import { actionDeleteSelected } from "./actionDeleteSelected";
import { register } from "./register";
export const actionCopy = register<ClipboardEvent | null>({
export const actionCopy = register({
name: "copy",
label: "labels.copy",
icon: DuplicateIcon,
trackEvent: { category: "element" },
perform: async (elements, appState, event, app) => {
perform: async (elements, appState, event: ClipboardEvent | null, app) => {
const elementsToCopy = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
@@ -109,12 +109,12 @@ export const actionPaste = register({
keyTest: undefined,
});
export const actionCut = register<ClipboardEvent | null>({
export const actionCut = register({
name: "cut",
label: "labels.cut",
icon: cutIcon,
trackEvent: { category: "element" },
perform: (elements, appState, event, app) => {
perform: (elements, appState, event: ClipboardEvent | null, app) => {
actionCopy.perform(elements, appState, event, app);
return actionDeleteSelected.perform(elements, appState, null, app);
},
@@ -212,8 +212,12 @@ export const actionDeleteSelected = register({
trackEvent: { category: "element", action: "delete" },
perform: (elements, appState, formData, app) => {
if (appState.selectedLinearElement?.isEditing) {
const { elementId, selectedPointsIndices } =
appState.selectedLinearElement;
const {
elementId,
selectedPointsIndices,
startBindingElement,
endBindingElement,
} = appState.selectedLinearElement;
const elementsMap = app.scene.getNonDeletedElementsMap();
const linearElement = LinearElementEditor.getElement(
elementId,
@@ -250,6 +254,19 @@ export const actionDeleteSelected = register({
};
}
// We cannot do this inside `movePoint` because it is also called
// when deleting the uncommitted point (which hasn't caused any binding)
const binding = {
startBindingElement: selectedPointsIndices?.includes(0)
? null
: startBindingElement,
endBindingElement: selectedPointsIndices?.includes(
linearElement.points.length - 1,
)
? null
: endBindingElement,
};
LinearElementEditor.deletePoints(
linearElement,
app,
@@ -262,6 +279,7 @@ export const actionDeleteSelected = register({
...appState,
selectedLinearElement: {
...appState.selectedLinearElement,
...binding,
selectedPointsIndices:
selectedPointsIndices?.[0] > 0
? [selectedPointsIndices[0] - 1]
@@ -290,7 +308,6 @@ export const actionDeleteSelected = register({
type: app.state.preferredSelectionTool.type,
}),
multiElement: null,
newElement: null,
activeEmbeddable: null,
selectedLinearElement: null,
},
+5 -13
View File
@@ -31,9 +31,7 @@ import "../components/ToolIcon.scss";
import { register } from "./register";
import type { AppState } from "../types";
export const actionChangeProjectName = register<AppState["name"]>({
export const actionChangeProjectName = register({
name: "changeProjectName",
label: "labels.fileTitle",
trackEvent: false,
@@ -53,7 +51,7 @@ export const actionChangeProjectName = register<AppState["name"]>({
),
});
export const actionChangeExportScale = register<AppState["exportScale"]>({
export const actionChangeExportScale = register({
name: "changeExportScale",
label: "imageExportDialog.scale",
trackEvent: { category: "export", action: "scale" },
@@ -103,9 +101,7 @@ export const actionChangeExportScale = register<AppState["exportScale"]>({
},
});
export const actionChangeExportBackground = register<
AppState["exportBackground"]
>({
export const actionChangeExportBackground = register({
name: "changeExportBackground",
label: "imageExportDialog.label.withBackground",
trackEvent: { category: "export", action: "toggleBackground" },
@@ -125,9 +121,7 @@ export const actionChangeExportBackground = register<
),
});
export const actionChangeExportEmbedScene = register<
AppState["exportEmbedScene"]
>({
export const actionChangeExportEmbedScene = register({
name: "changeExportEmbedScene",
label: "imageExportDialog.tooltip.embedScene",
trackEvent: { category: "export", action: "embedScene" },
@@ -294,9 +288,7 @@ export const actionLoadScene = register({
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
});
export const actionExportWithDarkMode = register<
AppState["exportWithDarkMode"]
>({
export const actionExportWithDarkMode = register({
name: "exportWithDarkMode",
label: "imageExportDialog.label.darkMode",
trackEvent: { category: "export", action: "toggleTheme" },
+88 -121
View File
@@ -1,6 +1,10 @@
import { pointFrom } from "@excalidraw/math";
import { bindOrUnbindBindingElement } from "@excalidraw/element/binding";
import {
maybeBindLinearElement,
bindOrUnbindLinearElement,
isBindingEnabled,
} from "@excalidraw/element/binding";
import {
isValidPolygon,
LinearElementEditor,
@@ -17,8 +21,7 @@ import {
import {
KEYS,
arrayToMap,
invariant,
shouldRotateWithDiscreteAngle,
tupleToCoors,
updateActiveTool,
} from "@excalidraw/common";
import { isPathALoop } from "@excalidraw/element";
@@ -27,12 +30,11 @@ import { isInvisiblySmallElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { GlobalPoint, LocalPoint } from "@excalidraw/math";
import type { LocalPoint } from "@excalidraw/math";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
NonDeleted,
PointsPositionUpdates,
} from "@excalidraw/element/types";
import { t } from "../i18n";
@@ -44,37 +46,20 @@ import { register } from "./register";
import type { AppState } from "../types";
type FormData = {
event: PointerEvent;
sceneCoords: { x: number; y: number };
};
export const actionFinalize = register<FormData>({
export const actionFinalize = register({
name: "finalize",
label: "",
trackEvent: false,
perform: (elements, appState, data, app) => {
let newElements = elements;
const { interactiveCanvas, focusContainer, scene } = app;
const { event, sceneCoords } =
(data as {
event?: PointerEvent;
sceneCoords?: { x: number; y: number };
}) ?? {};
const elementsMap = scene.getNonDeletedElementsMap();
if (data && appState.selectedLinearElement) {
const { event, sceneCoords } = data;
const element = LinearElementEditor.getElement(
appState.selectedLinearElement.elementId,
elementsMap,
);
invariant(
element,
"Arrow element should exist if selectedLinearElement is set",
);
invariant(
sceneCoords,
"sceneCoords should be defined if actionFinalize is called with event",
);
if (event && appState.selectedLinearElement) {
const linearElementEditor = LinearElementEditor.handlePointerUp(
event,
appState.selectedLinearElement,
@@ -82,57 +67,19 @@ export const actionFinalize = register<FormData>({
app.scene,
);
const { startBindingElement, endBindingElement } = linearElementEditor;
const element = app.scene.getElement(linearElementEditor.elementId);
if (isBindingElement(element)) {
const newArrow = !!appState.newElement;
const selectedPointsIndices =
newArrow || !appState.selectedLinearElement.selectedPointsIndices
? [element.points.length - 1] // New arrow creation
: appState.selectedLinearElement.selectedPointsIndices;
const draggedPoints: PointsPositionUpdates =
selectedPointsIndices.reduce((map, index) => {
map.set(index, {
point: LinearElementEditor.pointFromAbsoluteCoords(
element,
pointFrom<GlobalPoint>(sceneCoords.x, sceneCoords.y),
elementsMap,
),
});
return map;
}, new Map()) ?? new Map();
bindOrUnbindBindingElement(
bindOrUnbindLinearElement(
element,
draggedPoints,
sceneCoords.x,
sceneCoords.y,
scene,
appState,
{
newArrow,
altKey: event.altKey,
angleLocked: shouldRotateWithDiscreteAngle(event),
},
startBindingElement,
endBindingElement,
app.scene,
);
} else if (isLineElement(element)) {
if (
appState.selectedLinearElement?.isEditing &&
!appState.newElement &&
!isValidPolygon(element.points)
) {
scene.mutateElement(element, {
polygon: false,
});
}
}
if (linearElementEditor !== appState.selectedLinearElement) {
// `handlePointerUp()` updated the linear element instance,
// so filter out this element if it is too small,
// but do an update to all new elements anyway for undo/redo purposes.
let newElements = elements;
if (element && isInvisiblySmallElement(element)) {
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
newElements = newElements.map((el) => {
@@ -144,8 +91,39 @@ export const actionFinalize = register<FormData>({
return el;
});
}
return {
elements: newElements,
appState: {
selectedLinearElement: {
...linearElementEditor,
selectedPointsIndices: null,
},
suggestedBindings: [],
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
}
}
const activeToolLocked = appState.activeTool?.locked;
if (appState.selectedLinearElement?.isEditing) {
const { elementId, startBindingElement, endBindingElement } =
appState.selectedLinearElement;
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (element) {
if (isBindingElement(element)) {
bindOrUnbindLinearElement(
element,
startBindingElement,
endBindingElement,
scene,
);
}
if (isLineElement(element) && !isValidPolygon(element.points)) {
scene.mutateElement(element, {
polygon: false,
});
}
return {
elements:
@@ -156,31 +134,23 @@ export const actionFinalize = register<FormData>({
}
return el;
})
: newElements,
: undefined,
appState: {
...appState,
cursorButton: "up",
selectedLinearElement: activeToolLocked
? null
: {
...linearElementEditor,
selectedPointsIndices: null,
isEditing: false,
initialState: {
...linearElementEditor.initialState,
lastClickedPoint: -1,
},
},
selectionElement: null,
suggestedBinding: null,
newElement: null,
multiElement: null,
selectedLinearElement: new LinearElementEditor(
element,
arrayToMap(elementsMap),
false, // exit editing mode
),
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
}
}
let newElements = elements;
if (window.document.activeElement instanceof HTMLElement) {
focusContainer();
}
@@ -204,14 +174,8 @@ export const actionFinalize = register<FormData>({
if (element) {
// pen and mouse have hover
if (
appState.selectedLinearElement &&
appState.multiElement &&
element.type !== "freedraw" &&
appState.lastPointerDownWith !== "touch"
) {
const { points } = element;
const { lastCommittedPoint } = appState.selectedLinearElement;
if (appState.multiElement && element.type !== "freedraw") {
const { points, lastCommittedPoint } = element;
if (
!lastCommittedPoint ||
points[points.length - 1] !== lastCommittedPoint
@@ -263,6 +227,25 @@ export const actionFinalize = register<FormData>({
polygon: false,
});
}
if (
isBindingElement(element) &&
!isLoop &&
element.points.length > 1 &&
isBindingEnabled(appState)
) {
const coords =
sceneCoords ??
tupleToCoors(
LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
-1,
arrayToMap(elements),
),
);
maybeBindLinearElement(element, appState, coords, scene);
}
}
}
@@ -288,25 +271,6 @@ export const actionFinalize = register<FormData>({
});
}
let selectedLinearElement =
element && isLinearElement(element)
? new LinearElementEditor(element, arrayToMap(newElements)) // To select the linear element when user has finished mutipoint editing
: appState.selectedLinearElement;
selectedLinearElement = selectedLinearElement
? {
...selectedLinearElement,
isEditing: appState.newElement
? false
: selectedLinearElement.isEditing,
initialState: {
...selectedLinearElement.initialState,
lastClickedPoint: -1,
origin: null,
},
}
: selectedLinearElement;
return {
elements: newElements,
appState: {
@@ -324,7 +288,7 @@ export const actionFinalize = register<FormData>({
multiElement: null,
editingTextElement: null,
startBoundElement: null,
suggestedBinding: null,
suggestedBindings: [],
selectedElementIds:
element &&
!appState.activeTool.locked &&
@@ -334,8 +298,11 @@ export const actionFinalize = register<FormData>({
[element.id]: true,
}
: appState.selectedElementIds,
selectedLinearElement,
// To select the linear element when user has finished mutipoint editing
selectedLinearElement:
element && isLinearElement(element)
? new LinearElementEditor(element, arrayToMap(newElements))
: appState.selectedLinearElement,
},
// TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+14 -12
View File
@@ -38,13 +38,15 @@ describe("flipping re-centers selection", () => {
height: 239.9,
startBinding: {
elementId: "rec1",
focus: 0,
gap: 5,
fixedPoint: [0.49, -0.05],
mode: "orbit",
},
endBinding: {
elementId: "rec2",
focus: 0,
gap: 5,
fixedPoint: [-0.05, 0.49],
mode: "orbit",
},
startArrowhead: null,
endArrowhead: "arrow",
@@ -72,11 +74,11 @@ describe("flipping re-centers selection", () => {
const rec1 = h.elements.find((el) => el.id === "rec1")!;
expect(rec1.x).toBeCloseTo(100, 0);
expect(rec1.y).toBeCloseTo(101, 0);
expect(rec1.y).toBeCloseTo(100, 0);
const rec2 = h.elements.find((el) => el.id === "rec2")!;
expect(rec2.x).toBeCloseTo(220, 0);
expect(rec2.y).toBeCloseTo(251, 0);
expect(rec2.y).toBeCloseTo(250, 0);
});
});
@@ -97,8 +99,8 @@ describe("flipping arrowheads", () => {
endArrowhead: null,
endBinding: {
elementId: rect.id,
fixedPoint: [0.5, 0.5],
mode: "orbit",
focus: 0.5,
gap: 5,
},
});
@@ -137,13 +139,13 @@ describe("flipping arrowheads", () => {
endArrowhead: "circle",
startBinding: {
elementId: rect.id,
fixedPoint: [0.5, 0.5],
mode: "orbit",
focus: 0.5,
gap: 5,
},
endBinding: {
elementId: rect2.id,
fixedPoint: [0.5, 0.5],
mode: "orbit",
focus: 0.5,
gap: 5,
},
});
@@ -193,8 +195,8 @@ describe("flipping arrowheads", () => {
endArrowhead: null,
endBinding: {
elementId: rect.id,
fixedPoint: [0.5, 0.5],
mode: "orbit",
focus: 0.5,
gap: 5,
},
});
+16 -5
View File
@@ -1,10 +1,17 @@
import { getNonDeletedElements } from "@excalidraw/element";
import { bindOrUnbindBindingElements } from "@excalidraw/element";
import {
bindOrUnbindLinearElements,
isBindingEnabled,
} from "@excalidraw/element";
import { getCommonBoundingBox } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element";
import { deepCopyElement } from "@excalidraw/element";
import { resizeMultipleElements } from "@excalidraw/element";
import { isArrowElement, isElbowArrow } from "@excalidraw/element";
import {
isArrowElement,
isElbowArrow,
isLinearElement,
} from "@excalidraw/element";
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
@@ -96,6 +103,7 @@ const flipSelectedElements = (
const updatedElements = flipElements(
selectedElements,
elementsMap,
appState,
flipDirection,
app,
);
@@ -110,6 +118,7 @@ const flipSelectedElements = (
const flipElements = (
selectedElements: NonDeleted<ExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
appState: AppState,
flipDirection: "horizontal" | "vertical",
app: AppClassProperties,
): ExcalidrawElement[] => {
@@ -149,10 +158,12 @@ const flipElements = (
},
);
bindOrUnbindBindingElements(
selectedElements.filter(isArrowElement),
bindOrUnbindLinearElements(
selectedElements.filter(isLinearElement),
isBindingEnabled(appState),
[],
app.scene,
app.state,
appState.zoom,
);
// ---------------------------------------------------------------------------
@@ -2,8 +2,6 @@ import clsx from "clsx";
import { CaptureUpdateAction } from "@excalidraw/element";
import { invariant } from "@excalidraw/common";
import { getClientColor } from "../clients";
import { Avatar } from "../components/Avatar";
import {
@@ -18,17 +16,12 @@ import { register } from "./register";
import type { GoToCollaboratorComponentProps } from "../components/UserList";
import type { Collaborator } from "../types";
export const actionGoToCollaborator = register<Collaborator>({
export const actionGoToCollaborator = register({
name: "goToCollaborator",
label: "Go to a collaborator",
viewMode: true,
trackEvent: { category: "collab" },
perform: (_elements, appState, collaborator) => {
invariant(
collaborator,
"actionGoToCollaborator: collaborator should be defined when actionGoToCollaborator is called",
);
perform: (_elements, appState, collaborator: Collaborator) => {
if (
!collaborator.socketId ||
appState.userToFollow?.socketId === collaborator.socketId ||
+108 -183
View File
@@ -1,5 +1,4 @@
import { pointFrom } from "@excalidraw/math";
import { useEffect, useMemo, useRef, useState } from "react";
import {
@@ -21,14 +20,12 @@ import {
getLineHeight,
isTransparent,
reduceToCommonValue,
invariant,
FONT_SIZES,
} from "@excalidraw/common";
import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element";
import {
bindBindingElement,
bindLinearElement,
calculateFixedPointForElbowArrowBinding,
updateBoundElements,
} from "@excalidraw/element";
@@ -62,7 +59,7 @@ import {
import { deriveStylesPanelMode } from "@excalidraw/common";
import type { LocalPoint, Radians } from "@excalidraw/math";
import type { LocalPoint } from "@excalidraw/math";
import type {
Arrowhead,
@@ -309,15 +306,13 @@ const changeFontSize = (
// -----------------------------------------------------------------------------
export const actionChangeStrokeColor = register<
Pick<AppState, "currentItemStrokeColor">
>({
export const actionChangeStrokeColor = register({
name: "changeStrokeColor",
label: "labels.stroke",
trackEvent: false,
perform: (elements, appState, value) => {
return {
...(value?.currentItemStrokeColor && {
...(value.currentItemStrokeColor && {
elements: changeProperty(
elements,
appState,
@@ -335,7 +330,7 @@ export const actionChangeStrokeColor = register<
...appState,
...value,
},
captureUpdate: !!value?.currentItemStrokeColor
captureUpdate: !!value.currentItemStrokeColor
? CaptureUpdateAction.IMMEDIATELY
: CaptureUpdateAction.EVENTUALLY,
};
@@ -371,14 +366,12 @@ export const actionChangeStrokeColor = register<
},
});
export const actionChangeBackgroundColor = register<
Pick<AppState, "currentItemBackgroundColor" | "viewBackgroundColor">
>({
export const actionChangeBackgroundColor = register({
name: "changeBackgroundColor",
label: "labels.changeBackground",
trackEvent: false,
perform: (elements, appState, value, app) => {
if (!value?.currentItemBackgroundColor) {
if (!value.currentItemBackgroundColor) {
return {
appState: {
...appState,
@@ -458,7 +451,7 @@ export const actionChangeBackgroundColor = register<
},
});
export const actionChangeFillStyle = register<ExcalidrawElement["fillStyle"]>({
export const actionChangeFillStyle = register({
name: "changeFillStyle",
label: "labels.fill",
trackEvent: false,
@@ -540,9 +533,7 @@ export const actionChangeFillStyle = register<ExcalidrawElement["fillStyle"]>({
},
});
export const actionChangeStrokeWidth = register<
ExcalidrawElement["strokeWidth"]
>({
export const actionChangeStrokeWidth = register({
name: "changeStrokeWidth",
label: "labels.strokeWidth",
trackEvent: false,
@@ -598,7 +589,7 @@ export const actionChangeStrokeWidth = register<
),
});
export const actionChangeSloppiness = register<ExcalidrawElement["roughness"]>({
export const actionChangeSloppiness = register({
name: "changeSloppiness",
label: "labels.sloppiness",
trackEvent: false,
@@ -652,9 +643,7 @@ export const actionChangeSloppiness = register<ExcalidrawElement["roughness"]>({
),
});
export const actionChangeStrokeStyle = register<
ExcalidrawElement["strokeStyle"]
>({
export const actionChangeStrokeStyle = register({
name: "changeStrokeStyle",
label: "labels.strokeStyle",
trackEvent: false,
@@ -707,7 +696,7 @@ export const actionChangeStrokeStyle = register<
),
});
export const actionChangeOpacity = register<ExcalidrawElement["opacity"]>({
export const actionChangeOpacity = register({
name: "changeOpacity",
label: "labels.opacity",
trackEvent: false,
@@ -731,100 +720,89 @@ export const actionChangeOpacity = register<ExcalidrawElement["opacity"]>({
),
});
export const actionChangeFontSize = register<ExcalidrawTextElement["fontSize"]>(
{
name: "changeFontSize",
label: "labels.fontSize",
trackEvent: false,
perform: (elements, appState, value, app) => {
return changeFontSize(
elements,
appState,
app,
() => {
invariant(value, "actionChangeFontSize: Expected a font size value");
return value;
},
value,
);
},
PanelComponent: ({ elements, appState, updateData, app, data }) => {
const { isCompact } = getStylesPanelInfo(app);
return (
<fieldset>
<legend>{t("labels.fontSize")}</legend>
<div className="buttonList">
<RadioSelection
group="font-size"
options={[
{
value: FONT_SIZES.sm,
text: t("labels.small"),
icon: FontSizeSmallIcon,
testId: "fontSize-small",
},
{
value: FONT_SIZES.md,
text: t("labels.medium"),
icon: FontSizeMediumIcon,
testId: "fontSize-medium",
},
{
value: FONT_SIZES.lg,
text: t("labels.large"),
icon: FontSizeLargeIcon,
testId: "fontSize-large",
},
{
value: FONT_SIZES.xl,
text: t("labels.veryLarge"),
icon: FontSizeExtraLargeIcon,
testId: "fontSize-veryLarge",
},
]}
value={getFormValue(
elements,
app,
(element) => {
if (isTextElement(element)) {
return element.fontSize;
}
const boundTextElement = getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
);
if (boundTextElement) {
return boundTextElement.fontSize;
}
return null;
},
(element) =>
isTextElement(element) ||
getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontSize || DEFAULT_FONT_SIZE,
)}
onChange={(value) => {
withCaretPositionPreservation(
() => updateData(value),
isCompact,
!!appState.editingTextElement,
data?.onPreventClose,
);
}}
/>
</div>
</fieldset>
);
},
export const actionChangeFontSize = register({
name: "changeFontSize",
label: "labels.fontSize",
trackEvent: false,
perform: (elements, appState, value, app) => {
return changeFontSize(elements, appState, app, () => value, value);
},
);
PanelComponent: ({ elements, appState, updateData, app, data }) => {
const { isCompact } = getStylesPanelInfo(app);
return (
<fieldset>
<legend>{t("labels.fontSize")}</legend>
<div className="buttonList">
<RadioSelection
group="font-size"
options={[
{
value: 16,
text: t("labels.small"),
icon: FontSizeSmallIcon,
testId: "fontSize-small",
},
{
value: 20,
text: t("labels.medium"),
icon: FontSizeMediumIcon,
testId: "fontSize-medium",
},
{
value: 28,
text: t("labels.large"),
icon: FontSizeLargeIcon,
testId: "fontSize-large",
},
{
value: 36,
text: t("labels.veryLarge"),
icon: FontSizeExtraLargeIcon,
testId: "fontSize-veryLarge",
},
]}
value={getFormValue(
elements,
app,
(element) => {
if (isTextElement(element)) {
return element.fontSize;
}
const boundTextElement = getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
);
if (boundTextElement) {
return boundTextElement.fontSize;
}
return null;
},
(element) =>
isTextElement(element) ||
getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontSize || DEFAULT_FONT_SIZE,
)}
onChange={(value) => {
withCaretPositionPreservation(
() => updateData(value),
isCompact,
!!appState.editingTextElement,
data?.onPreventClose,
);
}}
/>
</div>
</fieldset>
);
},
});
export const actionDecreaseFontSize = register({
name: "decreaseFontSize",
@@ -884,10 +862,7 @@ type ChangeFontFamilyData = Partial<
resetContainers?: true;
};
export const actionChangeFontFamily = register<{
currentItemFontFamily: any;
currentHoveredFontFamily: any;
}>({
export const actionChangeFontFamily = register({
name: "changeFontFamily",
label: "labels.fontFamily",
trackEvent: false,
@@ -924,8 +899,6 @@ export const actionChangeFontFamily = register<{
};
}
invariant(value, "actionChangeFontFamily: value must be defined");
const { currentItemFontFamily, currentHoveredFontFamily } = value;
let nextCaptureUpdateAction: CaptureUpdateActionType =
@@ -1268,7 +1241,7 @@ export const actionChangeFontFamily = register<{
},
});
export const actionChangeTextAlign = register<TextAlign>({
export const actionChangeTextAlign = register({
name: "changeTextAlign",
label: "Change text alignment",
trackEvent: false,
@@ -1369,7 +1342,7 @@ export const actionChangeTextAlign = register<TextAlign>({
},
});
export const actionChangeVerticalAlign = register<VerticalAlign>({
export const actionChangeVerticalAlign = register({
name: "changeVerticalAlign",
label: "Change vertical alignment",
trackEvent: { category: "element" },
@@ -1469,7 +1442,7 @@ export const actionChangeVerticalAlign = register<VerticalAlign>({
},
});
export const actionChangeRoundness = register<"sharp" | "round">({
export const actionChangeRoundness = register({
name: "changeRoundness",
label: "Change edge roundness",
trackEvent: false,
@@ -1626,16 +1599,15 @@ const getArrowheadOptions = (flip: boolean) => {
] as const;
};
export const actionChangeArrowhead = register<{
position: "start" | "end";
type: Arrowhead;
}>({
export const actionChangeArrowhead = register({
name: "changeArrowhead",
label: "Change arrowheads",
trackEvent: false,
perform: (elements, appState, value) => {
invariant(value, "actionChangeArrowhead: value must be defined");
perform: (
elements,
appState,
value: { position: "start" | "end"; type: Arrowhead },
) => {
return {
elements: changeProperty(elements, appState, (el) => {
if (isLinearElement(el)) {
@@ -1730,7 +1702,7 @@ export const actionChangeArrowProperties = register({
},
});
export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
export const actionChangeArrowType = register({
name: "changeArrowType",
label: "Change arrow types",
trackEvent: false,
@@ -1739,20 +1711,7 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
if (!isArrowElement(el)) {
return el;
}
const elementsMap = app.scene.getNonDeletedElementsMap();
const startPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
el,
0,
elementsMap,
);
const endPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
el,
-1,
elementsMap,
);
let newElement = newElementWith(el, {
x: value === ARROW_TYPE.elbow ? startPoint[0] : el.x,
y: value === ARROW_TYPE.elbow ? startPoint[1] : el.y,
roundness:
value === ARROW_TYPE.round
? {
@@ -1760,31 +1719,9 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
}
: null,
elbowed: value === ARROW_TYPE.elbow,
angle: value === ARROW_TYPE.elbow ? (0 as Radians) : el.angle,
points:
value === ARROW_TYPE.elbow || el.elbowed
? [
LinearElementEditor.pointFromAbsoluteCoords(
{
...el,
x: startPoint[0],
y: startPoint[1],
angle: 0 as Radians,
},
startPoint,
elementsMap,
),
LinearElementEditor.pointFromAbsoluteCoords(
{
...el,
x: startPoint[0],
y: startPoint[1],
angle: 0 as Radians,
},
endPoint,
elementsMap,
),
]
? [el.points[0], el.points[el.points.length - 1]]
: el.points,
});
@@ -1866,13 +1803,7 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
newElement.startBinding.elementId,
) as ExcalidrawBindableElement;
if (startElement) {
bindBindingElement(
newElement,
startElement,
appState.bindMode === "inside" ? "inside" : "orbit",
"start",
app.scene,
);
bindLinearElement(newElement, startElement, "start", app.scene);
}
}
if (newElement.endBinding) {
@@ -1880,13 +1811,7 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
newElement.endBinding.elementId,
) as ExcalidrawBindableElement;
if (endElement) {
bindBindingElement(
newElement,
endElement,
appState.bindMode === "inside" ? "inside" : "orbit",
"end",
app.scene,
);
bindLinearElement(newElement, endElement, "end", app.scene);
}
}
}
+1 -6
View File
@@ -2,12 +2,7 @@ import type { Action } from "./types";
export let actions: readonly Action[] = [];
export const register = <
TData extends any,
T extends Action<TData> = Action<TData>,
>(
action: T,
) => {
export const register = <T extends Action>(action: T) => {
actions = actions.concat(action);
return action as T & {
keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"];
+4 -4
View File
@@ -32,10 +32,10 @@ export type ActionResult =
}
| false;
type ActionFn<TData = any> = (
type ActionFn = (
elements: readonly OrderedExcalidrawElement[],
appState: Readonly<AppState>,
formData: TData | undefined,
formData: any,
app: AppClassProperties,
) => ActionResult | Promise<ActionResult>;
@@ -157,7 +157,7 @@ export type PanelComponentProps = {
) => React.JSX.Element | null;
};
export interface Action<TData = any> {
export interface Action {
name: ActionName;
label:
| string
@@ -174,7 +174,7 @@ export interface Action<TData = any> {
elements: readonly ExcalidrawElement[],
) => React.ReactNode);
PanelComponent?: React.FC<PanelComponentProps>;
perform: ActionFn<TData>;
perform: ActionFn;
keyPriority?: number;
keyTest?: (
event: React.KeyboardEvent | KeyboardEvent,
+2 -4
View File
@@ -100,7 +100,7 @@ export const getDefaultAppState = (): Omit<
panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties,
},
startBoundElement: null,
suggestedBinding: null,
suggestedBindings: [],
frameRendering: { enabled: true, clip: true, name: true, outline: true },
frameToHighlight: null,
editingFrame: null,
@@ -127,7 +127,6 @@ export const getDefaultAppState = (): Omit<
searchMatches: null,
lockedMultiSelections: {},
activeLockedId: null,
bindMode: "orbit",
};
};
@@ -230,7 +229,7 @@ const APP_STATE_STORAGE_CONF = (<
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
stats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false },
suggestedBinding: { browser: false, export: false, server: false },
suggestedBindings: { browser: false, export: false, server: false },
frameRendering: { browser: false, export: false, server: false },
frameToHighlight: { browser: false, export: false, server: false },
editingFrame: { browser: false, export: false, server: false },
@@ -253,7 +252,6 @@ const APP_STATE_STORAGE_CONF = (<
searchMatches: { browser: false, export: false, server: false },
lockedMultiSelections: { browser: true, export: true, server: true },
activeLockedId: { browser: false, export: false, server: false },
bindMode: { browser: true, export: false, server: false },
});
const _clearAppStateForStorage = <
+1 -2
View File
@@ -9,7 +9,6 @@ import {
VERTICAL_ALIGN,
randomId,
isDevEnv,
FONT_SIZES,
} from "@excalidraw/common";
import {
@@ -214,7 +213,7 @@ const chartXLabels = (
y: y + BAR_GAP / 2,
width: BAR_WIDTH,
angle: 5.87 as Radians,
fontSize: FONT_SIZES.sm,
fontSize: 16,
textAlign: "center",
verticalAlign: "top",
});
+1
View File
@@ -113,6 +113,7 @@ export const createPasteEvent = ({
if (typeof value !== "string") {
files = files || [];
files.push(value);
event.clipboardData?.items.add(value);
continue;
}
try {
File diff suppressed because it is too large Load Diff
@@ -1034,7 +1034,7 @@ const CommandItem = ({
size="var(--icon-size, 1rem)"
icon={
typeof command.icon === "function"
? command.icon(appState, [])
? command.icon(appState)
: command.icon
}
/>
@@ -1,5 +1,6 @@
import type { ActionManager } from "../../actions/manager";
import type { Action } from "../../actions/types";
import type { UIAppState } from "../../types";
export type CommandPaletteItem = {
label: string;
@@ -11,7 +12,7 @@ export type CommandPaletteItem = {
* (deburred name + keywords)
*/
haystack?: string;
icon?: Action["icon"];
icon?: React.ReactNode | ((appState: UIAppState) => React.ReactNode);
category: string;
order?: number;
predicate?: boolean | Action["predicate"];
@@ -844,7 +844,7 @@ const convertElementType = <
}),
) as typeof element;
updateBindings(nextElement, app.scene, app.state);
updateBindings(nextElement, app.scene);
return nextElement;
}
+2 -14
View File
@@ -62,20 +62,6 @@ const getHints = ({
shortcut: getTaggedShortcutKey("Alt"),
});
}
const selectedElements = app.scene.getSelectedElements(appState);
// creating or dragging arrow point
if (
appState.selectedLinearElement?.isDragging &&
selectedElements[0]?.type === "arrow"
) {
return t("hints.arrowBindModifiers", {
shortcut_1: getTaggedShortcutKey("Ctrl"),
shortcut_2: getTaggedShortcutKey("Alt"),
});
}
if (activeTool.type === "arrow" || activeTool.type === "line") {
if (multiMode) {
return t("hints.linearElementMulti", {
@@ -103,6 +89,8 @@ const getHints = ({
return t("hints.embeddable");
}
const selectedElements = app.scene.getSelectedElements(appState);
if (
isResizing &&
lastPointerDownWith === "mouse" &&
+8 -2
View File
@@ -646,8 +646,14 @@ const LayerUI = ({
};
const stripIrrelevantAppStateProps = (appState: AppState): UIAppState => {
const { startBoundElement, cursorButton, scrollX, scrollY, ...ret } =
appState;
const {
suggestedBindings,
startBoundElement,
cursorButton,
scrollX,
scrollY,
...ret
} = appState;
return ret;
};
@@ -34,7 +34,6 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
shouldChangeByStepSize,
nextValue,
scene,
app,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const origElement = originalElements[0];
@@ -49,7 +48,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
scene.mutateElement(latestElement, {
angle: nextAngle,
});
updateBindings(latestElement, scene, app.state);
updateBindings(latestElement, scene);
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) {
@@ -75,7 +74,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
scene.mutateElement(latestElement, {
angle: nextAngle,
});
updateBindings(latestElement, scene, app.state);
updateBindings(latestElement, scene);
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) {
@@ -94,7 +94,9 @@ const resizeElementInGroup = (
);
if (boundTextElement) {
const newFontSize = boundTextElement.fontSize * scale;
updateBoundElements(latestElement, scene);
updateBoundElements(latestElement, scene, {
newSize: { width: updates.width, height: updates.height },
});
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
scene.mutateElement(latestBoundTextElement, {
@@ -38,7 +38,6 @@ const moveElements = (
originalElements: readonly ExcalidrawElement[],
originalElementsMap: ElementsMap,
scene: Scene,
appState: AppState,
) => {
for (let i = 0; i < originalElements.length; i++) {
const origElement = originalElements[i];
@@ -64,7 +63,6 @@ const moveElements = (
newTopLeftY,
origElement,
scene,
appState,
originalElementsMap,
false,
);
@@ -77,7 +75,6 @@ const moveGroupTo = (
originalElements: ExcalidrawElement[],
originalElementsMap: ElementsMap,
scene: Scene,
appState: AppState,
) => {
const elementsMap = scene.getNonDeletedElementsMap();
const [x1, y1, ,] = getCommonBounds(originalElements);
@@ -110,7 +107,6 @@ const moveGroupTo = (
topLeftY + offsetY,
origElement,
scene,
appState,
originalElementsMap,
false,
);
@@ -129,7 +125,6 @@ const handlePositionChange: DragInputCallbackType<
property,
scene,
originalAppState,
app,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
@@ -157,7 +152,6 @@ const handlePositionChange: DragInputCallbackType<
elementsInUnit.map((el) => el.original),
originalElementsMap,
scene,
app.state,
);
} else {
const origElement = elementsInUnit[0]?.original;
@@ -184,7 +178,6 @@ const handlePositionChange: DragInputCallbackType<
newTopLeftY,
origElement,
scene,
app.state,
originalElementsMap,
false,
);
@@ -210,7 +203,6 @@ const handlePositionChange: DragInputCallbackType<
originalElements,
originalElementsMap,
scene,
app.state,
);
scene.triggerUpdate();
@@ -34,7 +34,6 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
property,
scene,
originalAppState,
app,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const origElement = originalElements[0];
@@ -132,7 +131,6 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
newTopLeftY,
origElement,
scene,
app.state,
originalElementsMap,
);
return;
@@ -164,7 +162,6 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
newTopLeftY,
origElement,
scene,
app.state,
originalElementsMap,
);
};
+11 -9
View File
@@ -6,7 +6,7 @@ import { useEffect, useMemo, useState, memo } from "react";
import { STATS_PANELS } from "@excalidraw/common";
import { getCommonBounds } from "@excalidraw/element";
import { getUncroppedWidthAndHeight } from "@excalidraw/element";
import { isImageElement } from "@excalidraw/element";
import { isElbowArrow, isImageElement } from "@excalidraw/element";
import { frameAndChildrenSelectedTogether } from "@excalidraw/element";
@@ -333,14 +333,16 @@ export const StatsInner = memo(
appState={appState}
/>
</StatsRow>
<StatsRow>
<Angle
property="angle"
element={singleElement}
scene={scene}
appState={appState}
/>
</StatsRow>
{!isElbowArrow(singleElement) && (
<StatsRow>
<Angle
property="angle"
element={singleElement}
scene={scene}
appState={appState}
/>
</StatsRow>
)}
<StatsRow>
<FontSize
property="fontSize"
@@ -114,7 +114,7 @@ describe("binding with linear elements", () => {
mouse.up(200, 100);
UI.clickTool("arrow");
mouse.down(-5, 0);
mouse.down(5, 0);
mouse.up(300, 50);
elementStats = stats?.querySelector("#elementStats");
@@ -135,7 +135,18 @@ describe("binding with linear elements", () => {
) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null);
expect(inputX).not.toBeNull();
UI.updateInput(inputX, String("186"));
UI.updateInput(inputX, String("204"));
expect(linear.startBinding).not.toBe(null);
});
it("should remain bound to linear element on small angle change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputAngle = UI.queryStatsProperty("A")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null);
UI.updateInput(inputAngle, String("1"));
expect(linear.startBinding).not.toBe(null);
});
@@ -150,6 +161,17 @@ describe("binding with linear elements", () => {
UI.updateInput(inputX, String("254"));
expect(linear.startBinding).toBe(null);
});
it("should remain bound to linear element on small angle change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputAngle = UI.queryStatsProperty("A")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null);
UI.updateInput(inputAngle, String("45"));
expect(linear.startBinding).toBe(null);
});
});
// single element
+3 -24
View File
@@ -1,10 +1,6 @@
import { pointFrom, pointRotateRads } from "@excalidraw/math";
import {
getBoundTextElement,
isBindingElement,
unbindBindingElement,
} from "@excalidraw/element";
import { getBoundTextElement } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element";
import {
@@ -16,7 +12,6 @@ import {
import { getFrameChildren } from "@excalidraw/element";
import { updateBindings } from "@excalidraw/element";
import { DRAGGING_THRESHOLD } from "@excalidraw/common";
import type { Radians } from "@excalidraw/math";
@@ -115,25 +110,9 @@ export const moveElement = (
newTopLeftY: number,
originalElement: ExcalidrawElement,
scene: Scene,
appState: AppState,
originalElementsMap: ElementsMap,
shouldInformMutation = true,
) => {
if (
isBindingElement(originalElement) &&
(originalElement.startBinding || originalElement.endBinding)
) {
if (
Math.abs(newTopLeftX - originalElement.x) < DRAGGING_THRESHOLD &&
Math.abs(newTopLeftY - originalElement.y) < DRAGGING_THRESHOLD
) {
return;
}
unbindBindingElement(originalElement, "start", scene);
unbindBindingElement(originalElement, "end", scene);
}
const elementsMap = scene.getNonDeletedElementsMap();
const latestElement = elementsMap.get(originalElement.id);
if (!latestElement) {
@@ -166,7 +145,7 @@ export const moveElement = (
},
{ informMutation: shouldInformMutation, isDragging: false },
);
updateBindings(latestElement, scene, appState);
updateBindings(latestElement, scene);
const boundTextElement = getBoundTextElement(
originalElement,
@@ -224,7 +203,7 @@ export const moveElement = (
},
{ informMutation: shouldInformMutation, isDragging: false },
);
updateBindings(latestChildElement, scene, appState, {
updateBindings(latestChildElement, scene, {
simultaneouslyUpdated: originalChildren,
});
});
@@ -157,8 +157,6 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
remotePointerUserStates,
selectionColor,
renderScrollbars: props.renderScrollbars,
// NOTE not memoized on so we don't rerender on cursor move
lastViewportPosition: props.app.lastViewportPosition,
},
editorInterface: props.editorInterface,
callback: props.renderInteractiveSceneCallback,
@@ -244,9 +242,8 @@ const getRelevantAppStateProps = (
selectedGroupIds: appState.selectedGroupIds,
selectedLinearElement: appState.selectedLinearElement,
multiElement: appState.multiElement,
newElement: appState.newElement,
isBindingEnabled: appState.isBindingEnabled,
suggestedBinding: appState.suggestedBinding,
suggestedBindings: appState.suggestedBindings,
isRotating: appState.isRotating,
elementsToHighlight: appState.elementsToHighlight,
collaborators: appState.collaborators, // Necessary for collab. sessions
@@ -258,10 +255,6 @@ const getRelevantAppStateProps = (
croppingElementId: appState.croppingElementId,
searchMatches: appState.searchMatches,
activeLockedId: appState.activeLockedId,
hoveredElementIds: appState.hoveredElementIds,
frameRendering: appState.frameRendering,
shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom,
exportScale: appState.exportScale,
});
const areEqual = (
@@ -99,7 +99,6 @@ const getRelevantAppStateProps = (appState: AppState): StaticCanvasAppState => {
editingGroupId: appState.editingGroupId,
currentHoveredFontFamily: appState.currentHoveredFontFamily,
croppingElementId: appState.croppingElementId,
suggestedBinding: appState.suggestedBinding,
};
return relevantAppStateProps;
@@ -6,7 +6,7 @@ import { hitElementBoundingBox } from "@excalidraw/element";
import type { GlobalPoint, Radians } from "@excalidraw/math";
import type { Bounds } from "@excalidraw/common";
import type { Bounds } from "@excalidraw/element";
import type {
ElementsMap,
NonDeletedExcalidrawElement,
@@ -88,11 +88,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"endArrowhead": "arrow",
"endBinding": {
"elementId": "ellipse-1",
"fixedPoint": [
0.04,
0.4633333333333333,
],
"mode": "orbit",
"focus": -0.007519379844961235,
"gap": 11.562288374879595,
},
"fillStyle": "solid",
"frameId": null,
@@ -101,6 +98,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"id": Any<String>,
"index": "a2",
"isDeleted": false,
"lastCommittedPoint": null,
"link": null,
"locked": false,
"opacity": 100,
@@ -120,11 +118,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"startArrowhead": null,
"startBinding": {
"elementId": "id49",
"fixedPoint": [
1,
0.5001,
],
"mode": "orbit",
"focus": -0.0813953488372095,
"gap": 1,
},
"strokeColor": "#1864ab",
"strokeStyle": "solid",
@@ -149,11 +144,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"endArrowhead": "arrow",
"endBinding": {
"elementId": "ellipse-1",
"fixedPoint": [
-0.01,
0.44666666666666666,
],
"mode": "orbit",
"focus": 0.10666666666666667,
"gap": 3.8343264684446097,
},
"fillStyle": "solid",
"frameId": null,
@@ -162,6 +154,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"id": Any<String>,
"index": "a3",
"isDeleted": false,
"lastCommittedPoint": null,
"link": null,
"locked": false,
"opacity": 100,
@@ -181,11 +174,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"startArrowhead": null,
"startBinding": {
"elementId": "diamond-1",
"fixedPoint": [
0.9357142857142857,
0.5001,
],
"mode": "orbit",
"focus": 0,
"gap": 4.535423522449215,
},
"strokeColor": "#e67700",
"strokeStyle": "solid",
@@ -344,11 +334,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"endArrowhead": "arrow",
"endBinding": {
"elementId": "text-2",
"fixedPoint": [
-2.05,
0.5001,
],
"mode": "orbit",
"focus": 0,
"gap": 16,
},
"fillStyle": "solid",
"frameId": null,
@@ -357,6 +344,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"id": Any<String>,
"index": "a2",
"isDeleted": false,
"lastCommittedPoint": null,
"link": null,
"locked": false,
"opacity": 100,
@@ -376,11 +364,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"startArrowhead": null,
"startBinding": {
"elementId": "text-1",
"fixedPoint": [
1,
0.5001,
],
"mode": "orbit",
"focus": 0,
"gap": 1,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -451,11 +436,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id42",
"fixedPoint": [
0,
0.5001,
],
"mode": "orbit",
"focus": -0,
"gap": 1,
},
"fillStyle": "solid",
"frameId": null,
@@ -464,6 +446,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"id": Any<String>,
"index": "a0",
"isDeleted": false,
"lastCommittedPoint": null,
"link": null,
"locked": false,
"opacity": 100,
@@ -483,11 +466,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"startArrowhead": null,
"startBinding": {
"elementId": "id41",
"fixedPoint": [
1,
0.5001,
],
"mode": "orbit",
"focus": 0,
"gap": 1,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -632,11 +612,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id46",
"fixedPoint": [
0,
0.5001,
],
"mode": "orbit",
"focus": -0,
"gap": 1,
},
"fillStyle": "solid",
"frameId": null,
@@ -645,6 +622,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"id": Any<String>,
"index": "a0",
"isDeleted": false,
"lastCommittedPoint": null,
"link": null,
"locked": false,
"opacity": 100,
@@ -664,11 +642,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"startArrowhead": null,
"startBinding": {
"elementId": "id45",
"fixedPoint": [
1,
0.5001,
],
"mode": "orbit",
"focus": 0,
"gap": 1,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -864,6 +839,7 @@ exports[`Test Transform > should transform linear elements 1`] = `
"id": Any<String>,
"index": "a0",
"isDeleted": false,
"lastCommittedPoint": null,
"link": null,
"locked": false,
"opacity": 100,
@@ -911,6 +887,7 @@ exports[`Test Transform > should transform linear elements 2`] = `
"id": Any<String>,
"index": "a1",
"isDeleted": false,
"lastCommittedPoint": null,
"link": null,
"locked": false,
"opacity": 100,
@@ -957,6 +934,7 @@ exports[`Test Transform > should transform linear elements 3`] = `
"id": Any<String>,
"index": "a2",
"isDeleted": false,
"lastCommittedPoint": null,
"link": null,
"locked": false,
"opacity": 100,
@@ -1004,6 +982,7 @@ exports[`Test Transform > should transform linear elements 4`] = `
"id": Any<String>,
"index": "a3",
"isDeleted": false,
"lastCommittedPoint": null,
"link": null,
"locked": false,
"opacity": 100,
@@ -1497,11 +1476,8 @@ exports[`Test Transform > should transform the elements correctly when linear el
"endArrowhead": "arrow",
"endBinding": {
"elementId": "Alice",
"fixedPoint": [
-0.07542628418945944,
0.5001,
],
"mode": "orbit",
"focus": -0,
"gap": 5.299874999999986,
},
"fillStyle": "solid",
"frameId": null,
@@ -1510,6 +1486,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"id": Any<String>,
"index": "a4",
"isDeleted": false,
"lastCommittedPoint": null,
"link": null,
"locked": false,
"opacity": 100,
@@ -1531,11 +1508,8 @@ exports[`Test Transform > should transform the elements correctly when linear el
"startArrowhead": null,
"startBinding": {
"elementId": "Bob",
"fixedPoint": [
1.000004978564514,
0.5001,
],
"mode": "orbit",
"focus": 0,
"gap": 1,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -1565,11 +1539,8 @@ exports[`Test Transform > should transform the elements correctly when linear el
"endArrowhead": "arrow",
"endBinding": {
"elementId": "B",
"fixedPoint": [
0.46387050630528887,
0.48466257668711654,
],
"mode": "orbit",
"focus": 0,
"gap": 32,
},
"fillStyle": "solid",
"frameId": null,
@@ -1578,6 +1549,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"id": Any<String>,
"index": "a5",
"isDeleted": false,
"lastCommittedPoint": null,
"link": null,
"locked": false,
"opacity": 100,
@@ -1595,11 +1567,8 @@ exports[`Test Transform > should transform the elements correctly when linear el
"startArrowhead": null,
"startBinding": {
"elementId": "Bob",
"fixedPoint": [
0.39381496335223337,
1,
],
"mode": "orbit",
"focus": 0,
"gap": 1,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@@ -1889,6 +1858,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"id": Any<String>,
"index": "a0",
"isDeleted": false,
"lastCommittedPoint": null,
"link": null,
"locked": false,
"opacity": 100,
@@ -1941,6 +1911,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"id": Any<String>,
"index": "a1",
"isDeleted": false,
"lastCommittedPoint": null,
"link": null,
"locked": false,
"opacity": 100,
@@ -1993,6 +1964,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"id": Any<String>,
"index": "a2",
"isDeleted": false,
"lastCommittedPoint": null,
"link": null,
"locked": false,
"opacity": 100,
@@ -2045,6 +2017,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"id": Any<String>,
"index": "a3",
"isDeleted": false,
"lastCommittedPoint": null,
"link": null,
"locked": false,
"opacity": 100,
+3 -1
View File
@@ -7,6 +7,8 @@ import {
isPromiseLike,
} from "@excalidraw/common";
import { clearElementsForExport } from "@excalidraw/element";
import type { ValueOf } from "@excalidraw/common/utility-types";
import type { ExcalidrawElement, FileId } from "@excalidraw/element/types";
@@ -157,7 +159,7 @@ export const loadSceneOrLibraryFromBlob = async (
type: MIME_TYPES.excalidraw,
data: restore(
{
elements: data.elements || [],
elements: clearElementsForExport(data.elements || []),
appState: {
theme: localAppState?.theme,
fileHandle: fileHandle || blob.handle || null,
+6 -6
View File
@@ -222,7 +222,7 @@ function dataView(
*
* @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(
VERSION_DATAVIEW_BYTES +
NEXT_CHUNK_SIZE_DATAVIEW_BYTES * buffers.length +
@@ -295,12 +295,12 @@ const splitBuffers = (concatenatedBuffer: Uint8Array) => {
/** @private */
const _encryptAndCompress = async (
data: Uint8Array<ArrayBuffer> | string,
data: Uint8Array | string,
encryptionKey: string,
) => {
const { encryptedBuffer, iv } = await encryptData(
encryptionKey,
deflate(data) as Uint8Array<ArrayBuffer>,
deflate(data),
);
return { iv, buffer: new Uint8Array(encryptedBuffer) };
@@ -330,7 +330,7 @@ export const compressData = async <T extends Record<string, any> = never>(
: {
metadata: T;
}),
): Promise<Uint8Array<ArrayBuffer>> => {
): Promise<Uint8Array> => {
const fileInfo: FileEncodingInfo = {
version: 2,
compression: "pako@1",
@@ -355,8 +355,8 @@ export const compressData = async <T extends Record<string, any> = never>(
/** @private */
const _decryptAndDecompress = async (
iv: Uint8Array<ArrayBuffer>,
decryptedBuffer: Uint8Array<ArrayBuffer>,
iv: Uint8Array,
decryptedBuffer: Uint8Array,
decryptionKey: string,
isCompressed: boolean,
) => {
+7 -7
View File
@@ -4,7 +4,7 @@ import { blobToArrayBuffer } from "./blob";
export const IV_LENGTH_BYTES = 12;
export const createIV = (): Uint8Array<ArrayBuffer> => {
export const createIV = () => {
const arr = new Uint8Array(IV_LENGTH_BYTES);
return window.crypto.getRandomValues(arr);
};
@@ -49,12 +49,12 @@ export const getCryptoKey = (key: string, usage: KeyUsage) =>
export const encryptData = async (
key: string | CryptoKey,
data: Uint8Array<ArrayBuffer> | ArrayBuffer | Blob | File | string,
): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array<ArrayBuffer> }> => {
data: Uint8Array | ArrayBuffer | Blob | File | string,
): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array }> => {
const importedKey =
typeof key === "string" ? await getCryptoKey(key, "encrypt") : key;
const iv = createIV();
const buffer: ArrayBuffer | Uint8Array<ArrayBuffer> =
const buffer: ArrayBuffer | Uint8Array =
typeof data === "string"
? new TextEncoder().encode(data)
: data instanceof Uint8Array
@@ -71,15 +71,15 @@ export const encryptData = async (
iv,
},
importedKey,
buffer,
buffer as ArrayBuffer | Uint8Array,
);
return { encryptedBuffer, iv };
};
export const decryptData = async (
iv: Uint8Array<ArrayBuffer>,
encrypted: Uint8Array<ArrayBuffer> | ArrayBuffer,
iv: Uint8Array,
encrypted: Uint8Array | ArrayBuffer,
privateKey: string,
): Promise<ArrayBuffer> => {
const key = await getCryptoKey(privateKey, "decrypt");
+9 -1
View File
@@ -6,6 +6,11 @@ import {
VERSIONS,
} from "@excalidraw/common";
import {
clearElementsForDatabase,
clearElementsForExport,
} from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
@@ -52,7 +57,10 @@ export const serializeAsJSON = (
type: EXPORT_DATA_TYPES.excalidraw,
version: VERSIONS.excalidraw,
source: getExportSource(),
elements,
elements:
type === "local"
? clearElementsForExport(elements)
: clearElementsForDatabase(elements),
appState:
type === "local"
? cleanAppStateForExport(appState)
+54 -124
View File
@@ -18,13 +18,7 @@ import {
normalizeLink,
getLineHeight,
} from "@excalidraw/common";
import {
calculateFixedPointForNonElbowArrowBinding,
getNonDeletedElements,
isPointInElement,
isValidPolygon,
projectFixedPointOntoDiagonal,
} from "@excalidraw/element";
import { getNonDeletedElements, isValidPolygon } from "@excalidraw/element";
import { normalizeFixedPoint } from "@excalidraw/element";
import {
updateElbowArrowPoints,
@@ -38,6 +32,7 @@ import {
isArrowBoundToElement,
isArrowElement,
isElbowArrow,
isFixedPointBinding,
isLinearElement,
isLineElement,
isTextElement,
@@ -55,12 +50,10 @@ import { isInvisiblySmallElement } from "@excalidraw/element";
import type { LocalPoint, Radians } from "@excalidraw/math";
import type {
ElementsMap,
ElementsMapOrArray,
ExcalidrawArrowElement,
ExcalidrawBindableElement,
ExcalidrawElbowArrowElement,
ExcalidrawElement,
ExcalidrawElementType,
ExcalidrawLinearElement,
ExcalidrawSelectionElement,
ExcalidrawTextElement,
@@ -68,6 +61,7 @@ import type {
FontFamilyValues,
NonDeletedSceneElementsMap,
OrderedExcalidrawElement,
PointBinding,
StrokeRoundness,
} from "@excalidraw/element/types";
@@ -127,86 +121,38 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
return DEFAULT_FONT_FAMILY;
};
const repairBinding = <T extends ExcalidrawArrowElement>(
const repairBinding = <T extends ExcalidrawLinearElement>(
element: T,
binding: FixedPointBinding | null,
targetElementsMap: Readonly<ElementsMap>,
localElementsMap: Readonly<ElementsMap> | null | undefined,
startOrEnd: "start" | "end",
): FixedPointBinding | null => {
binding: PointBinding | FixedPointBinding | null,
): T extends ExcalidrawElbowArrowElement
? FixedPointBinding | null
: PointBinding | FixedPointBinding | null => {
if (!binding) {
return null;
}
const focus = binding.focus || 0;
if (isElbowArrow(element)) {
const fixedPointBinding:
| ExcalidrawElbowArrowElement["startBinding"]
| ExcalidrawElbowArrowElement["endBinding"] = {
...binding,
fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]),
mode: binding.mode || "orbit",
};
| ExcalidrawElbowArrowElement["endBinding"] = isFixedPointBinding(binding)
? {
...binding,
focus,
fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]),
}
: null;
return fixedPointBinding;
}
// Fallback if the bound element is missing but the binding is at least
// looking like a valid one shape-wise
if (binding.mode && binding.fixedPoint && binding.elementId) {
return {
elementId: binding.elementId,
mode: binding.mode,
fixedPoint: normalizeFixedPoint(binding.fixedPoint || [0.5, 0.5]),
} 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(
element,
startOrEnd === "start" ? 0 : element.points.length - 1,
elementsMap,
);
const mode = isPointInElement(p, boundElement, elementsMap)
? "inside"
: "orbit";
const focusPoint =
mode === "inside"
? p
: projectFixedPointOntoDiagonal(
element,
p,
boundElement,
startOrEnd,
elementsMap,
) || p;
const { fixedPoint } = calculateFixedPointForNonElbowArrowBinding(
element,
boundElement,
startOrEnd,
elementsMap,
focusPoint,
);
return {
mode,
elementId: binding.elementId,
fixedPoint,
};
}
console.error(`could not repair binding for element`);
return null;
return {
...binding,
focus,
} as T extends ExcalidrawElbowArrowElement
? FixedPointBinding | null
: PointBinding | FixedPointBinding | null;
};
const restoreElementWithProperties = <
@@ -297,11 +243,7 @@ const restoreElementWithProperties = <
export const restoreElement = (
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
targetElementsMap: Readonly<ElementsMap>,
localElementsMap: Readonly<ElementsMap> | null | undefined,
opts?: {
deleteInvisibleElements?: boolean;
},
opts?: { deleteInvisibleElements?: boolean },
): typeof element | null => {
element = { ...element };
@@ -359,6 +301,7 @@ export const restoreElement = (
case "freedraw": {
return restoreElementWithProperties(element, {
points: element.points,
lastCommittedPoint: null,
simulatePressure: element.simulatePressure,
pressures: element.pressures,
});
@@ -388,9 +331,13 @@ export const restoreElement = (
}
return restoreElementWithProperties(element, {
type: "line",
startBinding: null,
endBinding: null,
type:
(element.type as ExcalidrawElementType | "draw") === "draw"
? "line"
: element.type,
startBinding: repairBinding(element, element.startBinding),
endBinding: repairBinding(element, element.endBinding),
lastCommittedPoint: null,
startArrowhead,
endArrowhead,
points,
@@ -407,29 +354,23 @@ export const restoreElement = (
});
case "arrow": {
const { startArrowhead = null, endArrowhead = "arrow" } = element;
const x: number | undefined = element.x;
const y: number | undefined = element.y;
const points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one
let x: number | undefined = element.x;
let y: number | undefined = element.y;
let points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one
!Array.isArray(element.points) || element.points.length < 2
? [pointFrom(0, 0), pointFrom(element.width, element.height)]
: element.points;
if (points[0][0] !== 0 || points[0][1] !== 0) {
({ points, x, y } =
LinearElementEditor.getNormalizeElementPointsAndCoords(element));
}
const base = {
type: element.type,
startBinding: repairBinding(
element as ExcalidrawArrowElement,
element.startBinding,
targetElementsMap,
localElementsMap,
"start",
),
endBinding: repairBinding(
element as ExcalidrawArrowElement,
element.endBinding,
targetElementsMap,
localElementsMap,
"end",
),
startBinding: repairBinding(element, element.startBinding),
endBinding: repairBinding(element, element.endBinding),
lastCommittedPoint: null,
startArrowhead,
endArrowhead,
points,
@@ -437,13 +378,15 @@ export const restoreElement = (
y,
elbowed: (element as ExcalidrawArrowElement).elbowed,
...getSizeFromPoints(points),
};
} as const;
// TODO: Separate arrow from linear element
const restoredElement = isElbowArrow(element)
return isElbowArrow(element)
? restoreElementWithProperties(element as ExcalidrawElbowArrowElement, {
...base,
elbowed: true,
startBinding: repairBinding(element, element.startBinding),
endBinding: repairBinding(element, element.endBinding),
fixedSegments:
element.fixedSegments?.length && base.points.length >= 4
? element.fixedSegments
@@ -452,13 +395,6 @@ export const restoreElement = (
endIsSpecial: element.endIsSpecial,
})
: restoreElementWithProperties(element as ExcalidrawArrowElement, base);
return {
...restoredElement,
...LinearElementEditor.getNormalizeElementPointsAndCoords(
restoredElement,
),
};
}
// generic elements
@@ -589,9 +525,9 @@ const repairFrameMembership = (
};
export const restoreElements = (
targetElements: ImportedDataState["elements"],
elements: ImportedDataState["elements"],
/** NOTE doesn't serve for reconciliation */
localElements: Readonly<ElementsMapOrArray> | null | undefined,
localElements: readonly ExcalidrawElement[] | null | undefined,
opts?:
| {
refreshDimensions?: boolean;
@@ -602,24 +538,18 @@ export const restoreElements = (
): OrderedExcalidrawElement[] => {
// used to detect duplicate top-level element ids
const existingIds = new Set<string>();
const targetElementsMap = arrayToMap(targetElements || []);
const localElementsMap = localElements ? arrayToMap(localElements) : null;
const restoredElements = syncInvalidIndices(
(targetElements || []).reduce((elements, element) => {
(elements || []).reduce((elements, element) => {
// filtering out selection, which is legacy, no longer kept in elements,
// and causing issues if retained
if (element.type === "selection") {
return elements;
}
let migratedElement: ExcalidrawElement | null = restoreElement(
element,
targetElementsMap,
localElementsMap,
{
deleteInvisibleElements: opts?.deleteInvisibleElements,
},
);
let migratedElement: ExcalidrawElement | null = restoreElement(element, {
deleteInvisibleElements: opts?.deleteInvisibleElements,
});
if (migratedElement) {
const localElement = localElementsMap?.get(element.id);
@@ -1,12 +1,11 @@
import { pointFrom } from "@excalidraw/math";
import { vi } from "vitest";
import {
convertToExcalidrawElements,
type ExcalidrawElementSkeleton,
} from "../transform";
import type { ExcalidrawArrowElement } from "@excalidraw/element/types";
import type { ExcalidrawArrowElement } from "../types";
import { convertToExcalidrawElements } from "./transform";
import type { ExcalidrawElementSkeleton } from "./transform";
const opts = { regenerateIds: false };
@@ -433,9 +432,12 @@ describe("Test Transform", () => {
boundElements: [{ id: text.id, type: "text" }],
startBinding: {
elementId: rectangle.id,
focus: 0,
gap: 1,
},
endBinding: {
elementId: ellipse.id,
focus: -0,
},
});
@@ -515,9 +517,12 @@ describe("Test Transform", () => {
boundElements: [{ id: text1.id, type: "text" }],
startBinding: {
elementId: text2.id,
focus: 0,
gap: 1,
},
endBinding: {
elementId: text3.id,
focus: -0,
},
});
@@ -775,8 +780,8 @@ describe("Test Transform", () => {
const [arrow, rect] = excalidrawElements;
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
elementId: "rect-1",
fixedPoint: [-2.05, 0.5001],
mode: "orbit",
focus: -0,
gap: 25,
});
expect(rect.boundElements).toStrictEqual([
{
@@ -10,13 +10,13 @@ import {
arrayToMap,
assertNever,
cloneJSON,
getFontString,
isDevEnv,
toBrandedType,
getLineHeight,
} from "@excalidraw/common";
import type { MarkOptional } from "@excalidraw/common/utility-types";
import { bindBindingElement } from "./binding";
import { bindLinearElement } from "@excalidraw/element";
import {
newArrowElement,
newElement,
@@ -25,19 +25,21 @@ import {
newLinearElement,
newMagicFrameElement,
newTextElement,
type ElementConstructorOpts,
} from "./newElement";
import { isArrowElement } from "./typeChecks";
} from "@excalidraw/element";
import { measureText, normalizeText } from "@excalidraw/element";
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 {
ExcalidrawArrowElement,
@@ -57,7 +59,9 @@ import type {
NonDeletedSceneElementsMap,
TextAlign,
VerticalAlign,
} from "./types";
} from "@excalidraw/element/types";
import type { MarkOptional } from "@excalidraw/common/utility-types";
export type ValidLinearElement = {
type: "arrow" | "line";
@@ -326,10 +330,9 @@ const bindLinearElementToElement = (
}
}
bindBindingElement(
bindLinearElement(
linearElement,
startBoundElement as ExcalidrawBindableElement,
"orbit",
"start",
scene,
);
@@ -402,10 +405,9 @@ const bindLinearElementToElement = (
}
}
bindBindingElement(
bindLinearElement(
linearElement,
endBoundElement as ExcalidrawBindableElement,
"orbit",
"end",
scene,
);
@@ -576,7 +578,14 @@ export const convertToExcalidrawElements = (
case "text": {
const fontFamily = element?.fontFamily || DEFAULT_FONT_FAMILY;
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({
width: metrics.width,
+2 -2
View File
@@ -28,9 +28,9 @@ import { shouldTestInside } from "@excalidraw/element";
import { hasBoundTextElement, isBoundToContainer } 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";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import { AnimatedTrail } from "../animated-trail";
+1 -2
View File
@@ -4,7 +4,6 @@ import {
CJK_HAND_DRAWN_FALLBACK_FONT,
WINDOWS_EMOJI_FALLBACK_FONT,
getFontFamilyFallbacks,
FONT_SIZES,
} from "@excalidraw/common";
import { getContainerElement } from "@excalidraw/element";
import { charWidth } from "@excalidraw/element";
@@ -241,7 +240,7 @@ export class Fonts {
for (const [index, fontFamily] of fontFamilies.entries()) {
const font = getFontString({
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!
+2 -5
View File
@@ -42,7 +42,7 @@ declare module "png-chunk-text" {
function decode(data: Uint8Array): { keyword: string; text: string };
}
declare module "png-chunks-encode" {
function encode(chunks: TEXtChunk[]): Uint8Array<ArrayBuffer>;
function encode(chunks: TEXtChunk[]): Uint8Array;
export = encode;
}
declare module "png-chunks-extract" {
@@ -101,10 +101,7 @@ declare module "image-blob-reduce" {
interface CustomMatchers {
toBeNonNaNNumber(): void;
toCloselyEqualPoints(
points: readonly [number, number][],
precision?: number,
): void;
toCloselyEqualPoints(points: readonly [number, number][]): void;
}
declare namespace jest {
+1 -1
View File
@@ -18,7 +18,7 @@ export function useOutsideClick<T extends HTMLElement>(
* Returning `undefined` will fallback to the default behavior.
*/
isInside?: (
event: Event & { target: T },
event: Event & { target: HTMLElement },
/** the element of the passed ref */
container: T,
) => boolean | undefined,
+2 -5
View File
@@ -293,11 +293,8 @@ export { TTDDialog } from "./components/TTDDialog/TTDDialog";
export { TTDDialogTrigger } from "./components/TTDDialog/TTDDialogTrigger";
export { zoomToFitBounds } from "./actions/actionCanvas";
export {
getCommonBounds,
getVisibleSceneBounds,
convertToExcalidrawElements,
} from "@excalidraw/element";
export { convertToExcalidrawElements } from "./data/transform";
export { getCommonBounds, getVisibleSceneBounds } from "@excalidraw/element";
export {
elementsOverlappingBBox,
+2 -3
View File
@@ -6,9 +6,8 @@ import {
polygonIncludesPointNonZero,
} from "@excalidraw/math";
import { type Bounds } from "@excalidraw/common";
import {
type Bounds,
computeBoundTextPosition,
doBoundsIntersect,
getBoundTextElement,
@@ -16,7 +15,7 @@ import {
intersectElementWithLineSegment,
} from "@excalidraw/element";
import type { ElementsSegmentsMap, GlobalPoint } from "@excalidraw/math/types";
import type { ElementsSegmentsMap, GlobalPoint } from "@excalidraw/math";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
export const getLassoSelectedElementIds = (input: {
-1
View File
@@ -341,7 +341,6 @@
"canvasPanning": "",
"linearElement": "انقر لبدء نقاط متعددة، اسحب لخط واحد",
"arrowTool": "",
"arrowBindModifiers": "",
"freeDraw": "انقر واسحب، أفرج عند الانتهاء",
"text": "نصيحة: يمكنك أيضًا إضافة نص بالنقر المزدوج في أي مكان بأداة الاختيار",
"embeddable": "اضغط مع السحب لإنشاء موقع ويب مضمّن",
-1
View File
@@ -341,7 +341,6 @@
"canvasPanning": "",
"linearElement": "",
"arrowTool": "",
"arrowBindModifiers": "",
"freeDraw": "",
"text": "",
"embeddable": "",
-1
View File
@@ -341,7 +341,6 @@
"canvasPanning": "",
"linearElement": "Кликнете, за да стартирате няколко точки, плъзнете за една линия",
"arrowTool": "",
"arrowBindModifiers": "",
"freeDraw": "Натиснете и влачете, пуснете като сте готови",
"text": "Подсказка: Можете също да добавите текст като натиснете някъде два път с инструмента за селекция",
"embeddable": "Натисни-премести, за да създадеш вметка за уебсайт",
-1
View File
@@ -341,7 +341,6 @@
"canvasPanning": "",
"linearElement": "একাধিক বিন্দু শুরু করতে ক্লিক করুন, একক লাইনের জন্য টেনে আনুন",
"arrowTool": "",
"arrowBindModifiers": "",
"freeDraw": "ক্লিক করুন এবং টেনে আনুন, আপনার কাজ শেষ হলে ছেড়ে দিন",
"text": "বিশেষ্য: আপনি নির্বাচন টুলের সাথে যে কোনো জায়গায় ডাবল-ক্লিক করে পাঠ্য যোগ করতে পারেন",
"embeddable": "",
-1
View File
@@ -341,7 +341,6 @@
"canvasPanning": "",
"linearElement": "একাধিক বিন্দু শুরু করতে ক্লিক করুন, একক লাইনের জন্য টেনে আনুন",
"arrowTool": "",
"arrowBindModifiers": "",
"freeDraw": "ক্লিক করুন এবং টেনে আনুন, আপনার কাজ শেষ হলে ছেড়ে দিন",
"text": "বিশেষ্য: আপনি নির্বাচন টুলের সাথে যে কোনো জায়গায় ডাবল-ক্লিক করে পাঠ্য যোগ করতে পারেন",
"embeddable": "",
-1
View File
@@ -341,7 +341,6 @@
"canvasPanning": "",
"linearElement": "Feu clic per a dibuixar múltiples punts; arrossegueu per a una sola línia",
"arrowTool": "",
"arrowBindModifiers": "",
"freeDraw": "Feu clic i arrossegueu, deixeu anar per a finalitzar",
"text": "Consell: també podeu afegir text fent doble clic en qualsevol lloc amb l'eina de selecció",
"embeddable": "Feu clic i arrossegueu per crear una Incrustació Web",
-1
View File
@@ -341,7 +341,6 @@
"canvasPanning": "",
"linearElement": "Kliknutím pro více bodů, táhnutím pro jednu čáru",
"arrowTool": "",
"arrowBindModifiers": "",
"freeDraw": "Klikněte a táhněte, pro ukončení pusťte",
"text": "Tip: Text můžete také přidat dvojitým kliknutím kdekoli pomocí nástroje pro výběr",
"embeddable": "",
-1
View File
@@ -341,7 +341,6 @@
"canvasPanning": "",
"linearElement": "",
"arrowTool": "",
"arrowBindModifiers": "",
"freeDraw": "Klik og træk, slip når du er færdig",
"text": "",
"embeddable": "",
-1
View File
@@ -341,7 +341,6 @@
"canvasPanning": "",
"linearElement": "Klicken für Linie mit mehreren Punkten, Ziehen für einzelne Linie",
"arrowTool": "",
"arrowBindModifiers": "",
"freeDraw": "Klicke und ziehe. Lass los, wenn du fertig bist",
"text": "Tipp: Du kannst auch Text hinzufügen, indem du mit dem Auswahlwerkzeug auf eine beliebige Stelle doppelklickst",
"embeddable": "Klicken und ziehen, um eine Webseiten-Einbettung zu erstellen",
-1
View File
@@ -341,7 +341,6 @@
"canvasPanning": "",
"linearElement": "Klicken für Linie mit mehreren Punkten, Ziehen für einzelne Linie",
"arrowTool": "",
"arrowBindModifiers": "",
"freeDraw": "Klicke und ziehe. Lass los, wenn du fertig bist",
"text": "Tipp: Du kannst auch Text hinzufügen, indem du mit dem Auswahlwerkzeug auf eine beliebige Stelle doppelklickst",
"embeddable": "Klicken und ziehen, um eine Webseiten-Einbettung zu erstellen",

Some files were not shown because too many files have changed in this diff Show More