Merge branch 'excalidraw:master' into master

This commit is contained in:
hocino 2024-09-11 12:09:17 +02:00 committed by GitHub
commit a4c185e475
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
141 changed files with 6721 additions and 4149 deletions

View file

@ -17,8 +17,6 @@ VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","a
# put these in your .env.local, or make sure you don't commit!
# must be lowercase `true` when turned on
#
# whether to enable Service Workers in development
VITE_APP_DEV_ENABLE_SW=
# whether to disable live reload / HMR. Usuaully what you want to do when
# debugging Service Workers.
VITE_APP_DEV_DISABLE_LIVE_RELOAD=

View file

@ -8,3 +8,4 @@ public/workbox
packages/excalidraw/types
examples/**/public
dev-dist
coverage

View file

@ -1547,7 +1547,7 @@
"@docusaurus/theme-search-algolia" "2.2.0"
"@docusaurus/types" "2.2.0"
"@docusaurus/react-loadable@5.5.2", "react-loadable@npm:@docusaurus/react-loadable@5.5.2":
"@docusaurus/react-loadable@5.5.2":
version "5.5.2"
resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce"
integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==
@ -2789,7 +2789,14 @@ brace-expansion@^1.1.7:
balanced-match "^1.0.0"
concat-map "0.0.1"
braces@^3.0.2, braces@~3.0.2:
braces@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789"
integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==
dependencies:
fill-range "^7.1.1"
braces@~3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
@ -4011,6 +4018,13 @@ fill-range@^7.0.1:
dependencies:
to-regex-range "^5.0.1"
fill-range@^7.1.1:
version "7.1.1"
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292"
integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==
dependencies:
to-regex-range "^5.0.1"
finalhandler@1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32"
@ -5207,11 +5221,11 @@ methods@~1.1.2:
integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==
micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5:
version "4.0.5"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6"
integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==
version "4.0.8"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
dependencies:
braces "^3.0.2"
braces "^3.0.3"
picomatch "^2.3.1"
mime-db@1.52.0, "mime-db@>= 1.43.0 < 2":
@ -6190,14 +6204,13 @@ react-dev-utils@^12.0.1:
strip-ansi "^6.0.1"
text-table "^0.2.0"
react-dom@^17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"
integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==
react-dom@18.2.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"
integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
scheduler "^0.20.2"
scheduler "^0.23.0"
react-error-overlay@^6.0.11:
version "6.0.11"
@ -6260,6 +6273,14 @@ react-loadable-ssr-addon-v5-slorber@^1.0.1:
dependencies:
"@babel/runtime" "^7.10.3"
"react-loadable@npm:@docusaurus/react-loadable@5.5.2":
version "5.5.2"
resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce"
integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==
dependencies:
"@types/react" "*"
prop-types "^15.6.2"
react-router-config@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988"
@ -6310,13 +6331,12 @@ react-textarea-autosize@^8.3.2:
use-composed-ref "^1.3.0"
use-latest "^1.2.1"
react@^17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==
react@18.2.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
readable-stream@^2.0.1:
version "2.3.7"
@ -6664,13 +6684,12 @@ sax@^1.2.4:
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
scheduler@^0.20.2:
version "0.20.2"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91"
integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==
scheduler@^0.23.0:
version "0.23.2"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3"
integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
schema-utils@2.7.0:
version "2.7.0"

View file

@ -31,6 +31,7 @@ export const AppMainMenu: React.FC<{
/>
)}
<MainMenu.DefaultItems.CommandPalette className="highlighted" />
<MainMenu.DefaultItems.SearchMenu />
<MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator />

View file

@ -1,7 +1,6 @@
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import { type AppState } from "../../packages/excalidraw/types";
import { throttleRAF } from "../../packages/excalidraw/utils";
import type { LineSegment } from "../../packages/utils";
import {
bootstrapCanvas,
getNormalizedCanvasDimensions,
@ -13,12 +12,16 @@ import {
TrashIcon,
} from "../../packages/excalidraw/components/icons";
import { STORAGE_KEYS } from "../app_constants";
import { isLineSegment } from "../../packages/excalidraw/element/typeChecks";
import {
isLineSegment,
type GlobalPoint,
type LineSegment,
} from "../../packages/math";
const renderLine = (
context: CanvasRenderingContext2D,
zoom: number,
segment: LineSegment,
segment: LineSegment<GlobalPoint>,
color: string,
) => {
context.save();
@ -47,10 +50,15 @@ const render = (
context: CanvasRenderingContext2D,
appState: AppState,
) => {
frame.forEach((el) => {
frame.forEach((el: DebugElement) => {
switch (true) {
case isLineSegment(el.data):
renderLine(context, appState.zoom.value, el.data, el.color);
renderLine(
context,
appState.zoom.value,
el.data as LineSegment<GlobalPoint>,
el.color,
);
break;
}
});

View file

@ -20,6 +20,7 @@ import {
get,
} from "idb-keyval";
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
import { SEARCH_SIDEBAR } from "../../packages/excalidraw/constants";
import type { LibraryPersistedData } from "../../packages/excalidraw/data/library";
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
@ -66,13 +67,19 @@ const saveDataStateToLocalStorage = (
appState: AppState,
) => {
try {
const _appState = clearAppStateForLocalStorage(appState);
if (_appState.openSidebar?.name === SEARCH_SIDEBAR.name) {
_appState.openSidebar = null;
}
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
JSON.stringify(clearElementsForLocalStorage(elements)),
);
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
JSON.stringify(clearAppStateForLocalStorage(appState)),
JSON.stringify(_appState),
);
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
} catch (error: any) {

View file

@ -6,6 +6,7 @@
"excalidraw-app",
"packages/excalidraw",
"packages/utils",
"packages/math",
"examples/excalidraw",
"examples/excalidraw/*"
],
@ -20,8 +21,8 @@
"@types/react-dom": "18.2.0",
"@types/socket.io-client": "3.0.0",
"@vitejs/plugin-react": "3.1.0",
"@vitest/coverage-v8": "0.33.0",
"@vitest/ui": "0.32.2",
"@vitest/coverage-v8": "2.0.5",
"@vitest/ui": "2.0.5",
"chai": "4.3.6",
"dotenv": "16.0.1",
"eslint-config-prettier": "8.5.0",
@ -35,13 +36,13 @@
"prettier": "2.6.2",
"rewire": "6.0.0",
"typescript": "4.9.4",
"vite": "5.0.12",
"vite-plugin-checker": "0.6.1",
"vite": "5.4.2",
"vite-plugin-checker": "0.7.2",
"vite-plugin-ejs": "1.7.0",
"vite-plugin-pwa": "0.17.4",
"vite-plugin-svgr": "2.4.0",
"vitest": "1.6.0",
"vitest-canvas-mock": "0.3.2"
"vite-plugin-svgr": "4.2.0",
"vitest": "2.0.5",
"vitest-canvas-mock": "0.3.3"
},
"engines": {
"node": "18.0.0 - 20.x.x"
@ -82,6 +83,7 @@
"clean-install": "yarn rm:node_modules && yarn install"
},
"resolutions": {
"@types/react": "18.2.0"
"@types/react": "18.2.0",
"strip-ansi": "6.0.1"
}
}

View file

@ -38,7 +38,7 @@ import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import type { SceneBounds } from "../element/bounds";
import { setCursor } from "../cursor";
import { StoreAction } from "../store";
import { clamp } from "../math";
import { clamp } from "../../math";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",

View file

@ -42,20 +42,21 @@ export const actionDuplicateSelection = register({
perform: (elements, appState, formData, app) => {
// duplicate selected point(s) if editing a line
if (appState.editingLinearElement) {
const ret = LinearElementEditor.duplicateSelectedPoints(
appState,
app.scene.getNonDeletedElementsMap(),
);
// TODO: Invariants should be checked here instead of duplicateSelectedPoints()
try {
const newAppState = LinearElementEditor.duplicateSelectedPoints(
appState,
app.scene.getNonDeletedElementsMap(),
);
if (!ret) {
return {
elements,
appState: newAppState,
storeAction: StoreAction.CAPTURE,
};
} catch {
return false;
}
return {
elements,
appState: ret.appState,
storeAction: StoreAction.CAPTURE,
};
}
return {

View file

@ -6,7 +6,6 @@ import { done } from "../components/icons";
import { t } from "../i18n";
import { register } from "./register";
import { mutateElement } from "../element/mutateElement";
import { isPathALoop } from "../math";
import { LinearElementEditor } from "../element/linearElementEditor";
import {
maybeBindLinearElement,
@ -16,6 +15,8 @@ import { isBindingElement, isLinearElement } from "../element/typeChecks";
import type { AppState } from "../types";
import { resetCursor } from "../cursor";
import { StoreAction } from "../store";
import { point } from "../../math";
import { isPathALoop } from "../shapes";
export const actionFinalize = register({
name: "finalize",
@ -112,10 +113,10 @@ export const actionFinalize = register({
const linePoints = multiPointElement.points;
const firstPoint = linePoints[0];
mutateElement(multiPointElement, {
points: linePoints.map((point, index) =>
points: linePoints.map((p, index) =>
index === linePoints.length - 1
? ([firstPoint[0], firstPoint[1]] as const)
: point,
? point(firstPoint[0], firstPoint[1])
: p,
),
});
}

View file

@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { AppClassProperties, AppState, Point, Primitive } from "../types";
import type { AppClassProperties, AppState, Primitive } from "../types";
import type { StoreActionType } from "../store";
import {
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
@ -115,6 +115,8 @@ import {
} from "../element/binding";
import { mutateElbowArrow } from "../element/routing";
import { LinearElementEditor } from "../element/linearElementEditor";
import type { LocalPoint } from "../../math";
import { point, vector } from "../../math";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@ -1648,10 +1650,10 @@ export const actionChangeArrowType = register({
newElement,
elementsMap,
[finalStartPoint, finalEndPoint].map(
(point) =>
[point[0] - newElement.x, point[1] - newElement.y] as Point,
(p): LocalPoint =>
point(p[0] - newElement.x, p[1] - newElement.y),
),
[0, 0],
vector(0, 0),
{
...(startElement && newElement.startBinding
? {

View file

@ -0,0 +1,51 @@
import { KEYS } from "../keys";
import { register } from "./register";
import type { AppState } from "../types";
import { searchIcon } from "../components/icons";
import { StoreAction } from "../store";
import { CLASSES, SEARCH_SIDEBAR } from "../constants";
export const actionToggleSearchMenu = register({
name: "searchMenu",
icon: searchIcon,
keywords: ["search", "find"],
label: "search.title",
viewMode: true,
trackEvent: {
category: "search_menu",
action: "toggle",
predicate: (appState) => appState.gridModeEnabled,
},
perform(elements, appState, _, app) {
if (appState.openSidebar?.name === SEARCH_SIDEBAR.name) {
const searchInput =
app.excalidrawContainerValue.container?.querySelector<HTMLInputElement>(
`.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`,
);
if (searchInput?.matches(":focus")) {
return {
appState: { ...appState, openSidebar: null },
storeAction: StoreAction.NONE,
};
}
searchInput?.focus();
return false;
}
return {
appState: {
...appState,
openSidebar: { name: SEARCH_SIDEBAR.name },
openDialog: null,
},
storeAction: StoreAction.NONE,
};
},
checked: (appState: AppState) => appState.gridModeEnabled,
predicate: (element, appState, props) => {
return props.gridModeEnabled === undefined;
},
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F,
});

View file

@ -86,3 +86,5 @@ export { actionUnbindText, actionBindText } from "./actionBoundText";
export { actionLink } from "./actionLink";
export { actionToggleElementLock } from "./actionElementLock";
export { actionToggleLinearEditor } from "./actionLinearEditor";
export { actionToggleSearchMenu } from "./actionToggleSearchMenu";

View file

@ -51,7 +51,8 @@ export type ShortcutName =
>
| "saveScene"
| "imageExport"
| "commandPalette";
| "commandPalette"
| "searchMenu";
const shortcutMap: Record<ShortcutName, string[]> = {
toggleTheme: [getShortcutKey("Shift+Alt+D")],
@ -112,6 +113,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")],
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
toggleShortcuts: [getShortcutKey("?")],
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
};
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {

View file

@ -137,7 +137,8 @@ export type ActionName =
| "wrapTextInContainer"
| "commandPalette"
| "autoResize"
| "elementStats";
| "elementStats"
| "searchMenu";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];
@ -191,7 +192,8 @@ export interface Action {
| "history"
| "menu"
| "collab"
| "hyperlink";
| "hyperlink"
| "search_menu";
action?: string;
predicate?: (
appState: Readonly<AppState>,

View file

@ -116,6 +116,7 @@ export const getDefaultAppState = (): Omit<
objectsSnapModeEnabled: false,
userToFollow: null,
followedBy: new Set(),
searchMatches: [],
};
};
@ -236,6 +237,7 @@ const APP_STATE_STORAGE_CONF = (<
objectsSnapModeEnabled: { browser: true, export: false, server: false },
userToFollow: { browser: false, export: false, server: false },
followedBy: { browser: false, export: false, server: false },
searchMatches: { browser: false, export: false, server: false },
});
const _clearAppStateForStorage = <

View file

@ -1,3 +1,5 @@
import type { Radians } from "../math";
import { point } from "../math";
import {
COLOR_PALETTE,
DEFAULT_CHART_COLOR_INDEX,
@ -203,7 +205,7 @@ const chartXLabels = (
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
y: y + BAR_GAP / 2,
width: BAR_WIDTH,
angle: 5.87,
angle: 5.87 as Radians,
fontSize: 16,
textAlign: "center",
verticalAlign: "top",
@ -258,10 +260,7 @@ const chartLines = (
x,
y,
width: chartWidth,
points: [
[0, 0],
[chartWidth, 0],
],
points: [point(0, 0), point(chartWidth, 0)],
});
const yLine = newLinearElement({
@ -272,10 +271,7 @@ const chartLines = (
x,
y,
height: chartHeight,
points: [
[0, 0],
[0, -chartHeight],
],
points: [point(0, 0), point(0, -chartHeight)],
});
const maxLine = newLinearElement({
@ -288,10 +284,7 @@ const chartLines = (
strokeStyle: "dotted",
width: chartWidth,
opacity: GRID_OPACITY,
points: [
[0, 0],
[chartWidth, 0],
],
points: [point(0, 0), point(chartWidth, 0)],
});
return [xLine, yLine, maxLine];
@ -448,10 +441,7 @@ const chartTypeLine = (
height: cy,
strokeStyle: "dotted",
opacity: GRID_OPACITY,
points: [
[0, 0],
[0, cy],
],
points: [point(0, 0), point(0, cy)],
});
});

View file

@ -210,12 +210,6 @@ import {
isElementCompletelyInViewport,
isElementInViewport,
} from "../element/sizeHelpers";
import {
distance2d,
getCornerRadius,
getGridPoint,
isPathALoop,
} from "../math";
import {
calculateScrollCenter,
getElementsWithinSelection,
@ -230,7 +224,13 @@ import type {
ScrollBars,
} from "../scene/types";
import { getStateForZoom } from "../scene/zoom";
import { findShapeByKey, getBoundTextShape, getElementShape } from "../shapes";
import {
findShapeByKey,
getBoundTextShape,
getCornerRadius,
getElementShape,
isPathALoop,
} from "../shapes";
import { getSelectionBoxShape } from "../../utils/geometry/shape";
import { isPointInShape } from "../../utils/collision";
import type {
@ -386,6 +386,7 @@ import {
getReferenceSnapPoints,
SnapCache,
isGridModeEnabled,
getGridPoint,
} from "../snapping";
import { actionWrapTextInContainer } from "../actions/actionBoundText";
import BraveMeasureTextError from "./BraveMeasureTextError";
@ -439,6 +440,9 @@ import {
FlowChartNavigator,
getLinkDirectionFromKey,
} from "../element/flowchart";
import { searchItemInFocusAtom } from "./SearchMenu";
import type { LocalPoint, Radians } from "../../math";
import { point, pointDistance, vector } from "../../math";
const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
@ -545,6 +549,7 @@ class App extends React.Component<AppProps, AppState> {
public scene: Scene;
public fonts: Fonts;
public renderer: Renderer;
public visibleElements: readonly NonDeletedExcalidrawElement[];
private resizeObserver: ResizeObserver | undefined;
private nearestScrollableContainer: HTMLElement | Document | undefined;
public library: AppClassProperties["library"];
@ -552,7 +557,7 @@ class App extends React.Component<AppProps, AppState> {
public id: string;
private store: Store;
private history: History;
private excalidrawContainerValue: {
public excalidrawContainerValue: {
container: HTMLDivElement | null;
id: string;
};
@ -679,6 +684,7 @@ class App extends React.Component<AppProps, AppState> {
this.canvas = document.createElement("canvas");
this.rc = rough.canvas(this.canvas);
this.renderer = new Renderer(this.scene);
this.visibleElements = [];
this.store = new Store();
this.history = new History();
@ -1477,6 +1483,7 @@ class App extends React.Component<AppProps, AppState> {
newElementId: this.state.newElement?.id,
pendingImageElementId: this.state.pendingImageElementId,
});
this.visibleElements = visibleElements;
const allElementsMap = this.scene.getNonDeletedElementsMap();
@ -2292,6 +2299,9 @@ class App extends React.Component<AppProps, AppState> {
storeAction: StoreAction.UPDATE,
});
// clear the shape and image cache so that any images in initialData
// can be loaded fresh
this.clearImageShapeCache();
// FontFaceSet loadingdone event we listen on may not always
// fire (looking at you Safari), so on init we manually load all
// fonts and rerender scene text elements once done. This also
@ -2357,6 +2367,16 @@ class App extends React.Component<AppProps, AppState> {
return false;
};
private clearImageShapeCache(filesMap?: BinaryFiles) {
const files = filesMap ?? this.files;
this.scene.getNonDeletedElements().forEach((element) => {
if (isInitializedImageElement(element) && files[element.fileId]) {
this.imageCache.delete(element.fileId);
ShapeCache.delete(element);
}
});
}
public async componentDidMount() {
this.unmounted = false;
this.excalidrawContainerValue.container =
@ -3671,15 +3691,7 @@ class App extends React.Component<AppProps, AppState> {
this.files = { ...this.files, ...Object.fromEntries(filesMap) };
this.scene.getNonDeletedElements().forEach((element) => {
if (
isInitializedImageElement(element) &&
filesMap.has(element.fileId)
) {
this.imageCache.delete(element.fileId);
ShapeCache.delete(element);
}
});
this.clearImageShapeCache(Object.fromEntries(filesMap));
this.scene.triggerUpdate();
this.addNewImagesToImageCache();
@ -3793,7 +3805,7 @@ class App extends React.Component<AppProps, AppState> {
},
);
private getEditorUIOffsets = (): {
public getEditorUIOffsets = (): {
top: number;
right: number;
bottom: number;
@ -4844,7 +4856,7 @@ class App extends React.Component<AppProps, AppState> {
this.getElementHitThreshold(),
);
return isPointInShape([x, y], selectionShape);
return isPointInShape(point(x, y), selectionShape);
}
// take bound text element into consideration for hit collision as well
@ -5035,7 +5047,7 @@ class App extends React.Component<AppProps, AppState> {
containerId: shouldBindToContainer ? container?.id : undefined,
groupIds: container?.groupIds ?? [],
lineHeight,
angle: container?.angle ?? 0,
angle: container?.angle ?? (0 as Radians),
frameId: topLayerFrame ? topLayerFrame.id : null,
});
@ -5203,7 +5215,7 @@ class App extends React.Component<AppProps, AppState> {
element,
this.scene.getNonDeletedElementsMap(),
this.state,
[scenePointer.x, scenePointer.y],
point(scenePointer.x, scenePointer.y),
this.device.editor.isMobile,
)
);
@ -5214,11 +5226,12 @@ class App extends React.Component<AppProps, AppState> {
event: React.PointerEvent<HTMLCanvasElement>,
isTouchScreen: boolean,
) => {
const draggedDistance = distance2d(
this.lastPointerDownEvent!.clientX,
this.lastPointerDownEvent!.clientY,
this.lastPointerUpEvent!.clientX,
this.lastPointerUpEvent!.clientY,
const draggedDistance = pointDistance(
point(
this.lastPointerDownEvent!.clientX,
this.lastPointerDownEvent!.clientY,
),
point(this.lastPointerUpEvent!.clientX, this.lastPointerUpEvent!.clientY),
);
if (
!this.hitLinkElement ||
@ -5237,7 +5250,7 @@ class App extends React.Component<AppProps, AppState> {
this.hitLinkElement,
elementsMap,
this.state,
[lastPointerDownCoords.x, lastPointerDownCoords.y],
point(lastPointerDownCoords.x, lastPointerDownCoords.y),
this.device.editor.isMobile,
);
const lastPointerUpCoords = viewportCoordsToSceneCoords(
@ -5248,7 +5261,7 @@ class App extends React.Component<AppProps, AppState> {
this.hitLinkElement,
elementsMap,
this.state,
[lastPointerUpCoords.x, lastPointerUpCoords.y],
point(lastPointerUpCoords.x, lastPointerUpCoords.y),
this.device.editor.isMobile,
);
if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
@ -5497,17 +5510,18 @@ class App extends React.Component<AppProps, AppState> {
// if we haven't yet created a temp point and we're beyond commit-zone
// threshold, add a point
if (
distance2d(
scenePointerX - rx,
scenePointerY - ry,
lastPoint[0],
lastPoint[1],
pointDistance(
point(scenePointerX - rx, scenePointerY - ry),
lastPoint,
) >= LINE_CONFIRM_THRESHOLD
) {
mutateElement(
multiElement,
{
points: [...points, [scenePointerX - rx, scenePointerY - ry]],
points: [
...points,
point<LocalPoint>(scenePointerX - rx, scenePointerY - ry),
],
},
false,
);
@ -5519,11 +5533,9 @@ class App extends React.Component<AppProps, AppState> {
} else if (
points.length > 2 &&
lastCommittedPoint &&
distance2d(
scenePointerX - rx,
scenePointerY - ry,
lastCommittedPoint[0],
lastCommittedPoint[1],
pointDistance(
point(scenePointerX - rx, scenePointerY - ry),
lastCommittedPoint,
) < LINE_CONFIRM_THRESHOLD
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
@ -5570,10 +5582,10 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElementsMap(),
[
...points.slice(0, -1),
[
point<LocalPoint>(
lastCommittedX + dxFromLastCommitted,
lastCommittedY + dyFromLastCommitted,
],
),
],
undefined,
undefined,
@ -5589,10 +5601,10 @@ class App extends React.Component<AppProps, AppState> {
{
points: [
...points.slice(0, -1),
[
point<LocalPoint>(
lastCommittedX + dxFromLastCommitted,
lastCommittedY + dyFromLastCommitted,
],
),
],
},
false,
@ -5817,17 +5829,15 @@ class App extends React.Component<AppProps, AppState> {
}
};
const distance = distance2d(
pointerDownState.lastCoords.x,
pointerDownState.lastCoords.y,
scenePointer.x,
scenePointer.y,
const distance = pointDistance(
point(pointerDownState.lastCoords.x, pointerDownState.lastCoords.y),
point(scenePointer.x, scenePointer.y),
);
const threshold = this.getElementHitThreshold();
const point = { ...pointerDownState.lastCoords };
const p = { ...pointerDownState.lastCoords };
let samplingInterval = 0;
while (samplingInterval <= distance) {
const hitElements = this.getElementsAtPosition(point.x, point.y);
const hitElements = this.getElementsAtPosition(p.x, p.y);
processElements(hitElements);
// Exit since we reached current point
@ -5839,12 +5849,10 @@ class App extends React.Component<AppProps, AppState> {
samplingInterval = Math.min(samplingInterval + threshold, distance);
const distanceRatio = samplingInterval / distance;
const nextX =
(1 - distanceRatio) * point.x + distanceRatio * scenePointer.x;
const nextY =
(1 - distanceRatio) * point.y + distanceRatio * scenePointer.y;
point.x = nextX;
point.y = nextY;
const nextX = (1 - distanceRatio) * p.x + distanceRatio * scenePointer.x;
const nextY = (1 - distanceRatio) * p.y + distanceRatio * scenePointer.y;
p.x = nextX;
p.y = nextY;
}
pointerDownState.lastCoords.x = scenePointer.x;
@ -5970,6 +5978,16 @@ class App extends React.Component<AppProps, AppState> {
this.maybeCleanupAfterMissingPointerUp(event.nativeEvent);
this.maybeUnfollowRemoteUser();
if (this.state.searchMatches) {
this.setState((state) => ({
searchMatches: state.searchMatches.map((searchMatch) => ({
...searchMatch,
focus: false,
})),
}));
jotaiStore.set(searchItemInFocusAtom, null);
}
// since contextMenu options are potentially evaluated on each render,
// and an contextMenu action may depend on selection state, we must
// close the contextMenu before we update the selection on pointerDown
@ -6325,7 +6343,7 @@ class App extends React.Component<AppProps, AppState> {
this.hitLinkElement,
this.scene.getNonDeletedElementsMap(),
this.state,
[scenePointer.x, scenePointer.y],
point(scenePointer.x, scenePointer.y),
)
) {
this.handleEmbeddableCenterClick(this.hitLinkElement);
@ -6398,8 +6416,16 @@ class App extends React.Component<AppProps, AppState> {
}
isPanning = true;
// due to event.preventDefault below, container wouldn't get focus
// automatically
this.focusContainer();
// preventing defualt while text editing messes with cursor/focus
if (!this.state.editingTextElement) {
// preventing defualt while text editing messes with cursor/focus
// necessary to prevent browser from scrolling the page if excalidraw
// not full-page #4489
//
// as such, the above is broken when panning canvas while in wysiwyg
event.preventDefault();
}
@ -7008,7 +7034,7 @@ class App extends React.Component<AppProps, AppState> {
simulatePressure,
locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null,
points: [[0, 0]],
points: [point<LocalPoint>(0, 0)],
pressures: simulatePressure ? [] : [event.pressure],
});
@ -7216,11 +7242,9 @@ class App extends React.Component<AppProps, AppState> {
if (
multiElement.points.length > 1 &&
lastCommittedPoint &&
distance2d(
pointerDownState.origin.x - rx,
pointerDownState.origin.y - ry,
lastCommittedPoint[0],
lastCommittedPoint[1],
pointDistance(
point(pointerDownState.origin.x - rx, pointerDownState.origin.y - ry),
lastCommittedPoint,
) < LINE_CONFIRM_THRESHOLD
) {
this.actionManager.executeAction(actionFinalize);
@ -7321,7 +7345,7 @@ class App extends React.Component<AppProps, AppState> {
};
});
mutateElement(element, {
points: [...element.points, [0, 0]],
points: [...element.points, point<LocalPoint>(0, 0)],
});
const boundElement = getHoveredElementForBinding(
pointerDownState.origin,
@ -7573,11 +7597,9 @@ class App extends React.Component<AppProps, AppState> {
this.state.activeTool.type === "line")
) {
if (
distance2d(
pointerCoords.x,
pointerCoords.y,
pointerDownState.origin.x,
pointerDownState.origin.y,
pointDistance(
point(pointerCoords.x, pointerCoords.y),
point(pointerDownState.origin.x, pointerDownState.origin.y),
) < DRAGGING_THRESHOLD
) {
return;
@ -7926,7 +7948,7 @@ class App extends React.Component<AppProps, AppState> {
mutateElement(
newElement,
{
points: [...points, [dx, dy]],
points: [...points, point<LocalPoint>(dx, dy)],
pressures,
},
false,
@ -7955,7 +7977,7 @@ class App extends React.Component<AppProps, AppState> {
mutateElement(
newElement,
{
points: [...points, [dx, dy]],
points: [...points, point<LocalPoint>(dx, dy)],
},
false,
);
@ -7963,8 +7985,8 @@ class App extends React.Component<AppProps, AppState> {
mutateElbowArrow(
newElement,
elementsMap,
[...points.slice(0, -1), [dx, dy]],
[0, 0],
[...points.slice(0, -1), point<LocalPoint>(dx, dy)],
vector(0, 0),
undefined,
{
isDragging: true,
@ -7975,7 +7997,7 @@ class App extends React.Component<AppProps, AppState> {
mutateElement(
newElement,
{
points: [...points.slice(0, -1), [dx, dy]],
points: [...points.slice(0, -1), point<LocalPoint>(dx, dy)],
},
false,
);
@ -8284,9 +8306,9 @@ class App extends React.Component<AppProps, AppState> {
: [...newElement.pressures, childEvent.pressure];
mutateElement(newElement, {
points: [...points, [dx, dy]],
points: [...points, point<LocalPoint>(dx, dy)],
pressures,
lastCommittedPoint: [dx, dy],
lastCommittedPoint: point<LocalPoint>(dx, dy),
});
this.actionManager.executeAction(actionFinalize);
@ -8333,7 +8355,10 @@ class App extends React.Component<AppProps, AppState> {
mutateElement(newElement, {
points: [
...newElement.points,
[pointerCoords.x - newElement.x, pointerCoords.y - newElement.y],
point<LocalPoint>(
pointerCoords.x - newElement.x,
pointerCoords.y - newElement.y,
),
],
});
this.setState({
@ -8643,11 +8668,9 @@ class App extends React.Component<AppProps, AppState> {
if (isEraserActive(this.state) && pointerStart && pointerEnd) {
this.eraserTrail.endPath();
const draggedDistance = distance2d(
pointerStart.clientX,
pointerStart.clientY,
pointerEnd.clientX,
pointerEnd.clientY,
const draggedDistance = pointDistance(
point(pointerStart.clientX, pointerStart.clientY),
point(pointerEnd.clientX, pointerEnd.clientY),
);
if (draggedDistance === 0) {

View file

@ -106,7 +106,7 @@ const ColorPickerPopupContent = ({
return (
<PropertiesPopover
container={container}
style={{ maxWidth: "208px" }}
style={{ maxWidth: "13rem" }}
onFocusOutside={(event) => {
// refocus due to eye dropper
focusPickerContent();

View file

@ -43,7 +43,11 @@ import { InlineIcon } from "../InlineIcon";
import { SHAPES } from "../../shapes";
import { canChangeBackgroundColor, canChangeStrokeColor } from "../Actions";
import { useStableCallback } from "../../hooks/useStableCallback";
import { actionClearCanvas, actionLink } from "../../actions";
import {
actionClearCanvas,
actionLink,
actionToggleSearchMenu,
} from "../../actions";
import { jotaiStore } from "../../jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import type { CommandPaletteItem } from "./types";
@ -382,6 +386,15 @@ function CommandPaletteInner({
}
},
},
{
label: t("search.title"),
category: DEFAULT_CATEGORIES.app,
icon: searchIcon,
viewMode: true,
perform: () => {
actionManager.executeAction(actionToggleSearchMenu);
},
},
{
label: t("labels.changeStroke"),
keywords: ["color", "outline"],

View file

@ -2,7 +2,6 @@ import clsx from "clsx";
import { DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_TAB } from "../constants";
import { useTunnels } from "../context/tunnels";
import { useUIAppState } from "../context/ui-appState";
import { t } from "../i18n";
import type { MarkOptional, Merge } from "../utility-types";
import { composeEventHandlers } from "../utils";
import { useExcalidrawSetAppState } from "./App";
@ -10,6 +9,8 @@ import { withInternalFallback } from "./hoc/withInternalFallback";
import { LibraryMenu } from "./LibraryMenu";
import type { SidebarProps, SidebarTriggerProps } from "./Sidebar/common";
import { Sidebar } from "./Sidebar/Sidebar";
import "../components/dropdownMenu/DropdownMenu.scss";
import { t } from "../i18n";
const DefaultSidebarTrigger = withInternalFallback(
"DefaultSidebarTrigger",
@ -68,8 +69,7 @@ export const DefaultSidebar = Object.assign(
return (
<Sidebar
{...rest}
name="default"
key="default"
name={"default"}
className={clsx("default-sidebar", className)}
docked={docked ?? appState.defaultSidebarDockedPreference}
onDock={

View file

@ -288,6 +288,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("stats.fullTitle")}
shortcuts={[getShortcutKey("Alt+/")]}
/>
<Shortcut
label={t("search.title")}
shortcuts={[getShortcutFromShortcutName("searchMenu")]}
/>
<Shortcut
label={t("commandPalette.title")}
shortcuts={

View file

@ -13,6 +13,7 @@ import { isEraserActive } from "../appState";
import "./HintViewer.scss";
import { isNodeInFlowchart } from "../element/flowchart";
import { isGridModeEnabled } from "../snapping";
import { SEARCH_SIDEBAR } from "../constants";
interface HintViewerProps {
appState: UIAppState;
@ -30,6 +31,13 @@ const getHints = ({
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null;
if (
appState.openSidebar?.name === SEARCH_SIDEBAR.name &&
appState.searchMatches?.length
) {
return t("hints.dismissSearch");
}
if (appState.openSidebar && !device.editor.canFitSidebar) {
return null;
}

View file

@ -5,6 +5,7 @@ import {
CLASSES,
DEFAULT_SIDEBAR,
LIBRARY_SIDEBAR_WIDTH,
SEARCH_SIDEBAR,
TOOL_TYPE,
} from "../constants";
import { showSelectedShapeActions } from "../element";
@ -63,6 +64,7 @@ import { LaserPointerButton } from "./LaserPointerButton";
import { TTDDialog } from "./TTDDialog/TTDDialog";
import { Stats } from "./Stats";
import { actionToggleStats } from "../actions";
import { SearchSidebar } from "./SearchSidebar";
interface LayerUIProps {
actionManager: ActionManager;
@ -99,6 +101,7 @@ const DefaultMainMenu: React.FC<{
{UIOptions.canvasActions.saveAsImage && (
<MainMenu.DefaultItems.SaveAsImage />
)}
<MainMenu.DefaultItems.SearchMenu />
<MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator />
@ -362,16 +365,21 @@ const LayerUI = ({
const renderSidebars = () => {
return (
<DefaultSidebar
__fallback
onDock={(docked) => {
trackEvent(
"sidebar",
`toggleDock (${docked ? "dock" : "undock"})`,
`(${device.editor.isMobile ? "mobile" : "desktop"})`,
);
}}
/>
<>
{appState.openSidebar?.name === SEARCH_SIDEBAR.name && (
<SearchSidebar />
)}
<DefaultSidebar
__fallback
onDock={(docked) => {
trackEvent(
"sidebar",
`toggleDock (${docked ? "dock" : "undock"})`,
`(${device.editor.isMobile ? "mobile" : "desktop"})`,
);
}}
/>
</>
);
};

View file

@ -0,0 +1,110 @@
@import "open-color/open-color";
.excalidraw {
.layer-ui__search {
flex: 1 0 auto;
display: flex;
flex-direction: column;
padding: 8px 0 0 0;
}
.layer-ui__search-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 0.75rem;
.ExcTextField {
flex: 1 0 auto;
}
.ExcTextField__input {
background-color: #f5f5f9;
@at-root .excalidraw.theme--dark#{&} {
background-color: #31303b;
}
border-radius: var(--border-radius-md);
border: 0;
input::placeholder {
font-size: 0.9rem;
}
}
}
.layer-ui__search-count {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 8px 0 8px;
margin: 0 0.75rem 0.25rem 0.75rem;
font-size: 0.8em;
.result-nav {
display: flex;
.result-nav-btn {
width: 36px;
height: 36px;
--button-border: transparent;
&:active {
background-color: var(--color-surface-high);
}
&:first {
margin-right: 4px;
}
}
}
}
.layer-ui__search-result-container {
overflow-y: auto;
flex: 1 1 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.layer-ui__result-item {
display: flex;
align-items: center;
min-height: 2rem;
flex: 0 0 auto;
padding: 0.25rem 0.75rem;
cursor: pointer;
border: 1px solid transparent;
outline: none;
margin: 0 0.75rem;
border-radius: var(--border-radius-md);
.text-icon {
width: 1rem;
height: 1rem;
margin-right: 0.75rem;
}
.preview-text {
flex: 1;
max-height: 48px;
line-height: 24px;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
}
&:hover {
background-color: var(--color-surface-high);
}
&:active {
border-color: var(--color-primary);
}
&.active {
background-color: var(--color-surface-high);
}
}
}

View file

@ -0,0 +1,701 @@
import { Fragment, memo, useEffect, useRef, useState } from "react";
import { collapseDownIcon, upIcon, searchIcon } from "./icons";
import { TextField } from "./TextField";
import { Button } from "./Button";
import { useApp, useExcalidrawSetAppState } from "./App";
import { debounce } from "lodash";
import type { AppClassProperties } from "../types";
import { isTextElement, newTextElement } from "../element";
import type { ExcalidrawTextElement } from "../element/types";
import { measureText } from "../element/textElement";
import { addEventListener, getFontString } from "../utils";
import { KEYS } from "../keys";
import clsx from "clsx";
import { atom, useAtom } from "jotai";
import { jotaiScope } from "../jotai";
import { t } from "../i18n";
import { isElementCompletelyInViewport } from "../element/sizeHelpers";
import { randomInteger } from "../random";
import { CLASSES, EVENT } from "../constants";
import { useStable } from "../hooks/useStable";
import "./SearchMenu.scss";
const searchQueryAtom = atom<string>("");
export const searchItemInFocusAtom = atom<number | null>(null);
const SEARCH_DEBOUNCE = 350;
type SearchMatchItem = {
textElement: ExcalidrawTextElement;
searchQuery: SearchQuery;
index: number;
preview: {
indexInSearchQuery: number;
previewText: string;
moreBefore: boolean;
moreAfter: boolean;
};
matchedLines: {
offsetX: number;
offsetY: number;
width: number;
height: number;
}[];
};
type SearchMatches = {
nonce: number | null;
items: SearchMatchItem[];
};
type SearchQuery = string & { _brand: "SearchQuery" };
export const SearchMenu = () => {
const app = useApp();
const setAppState = useExcalidrawSetAppState();
const searchInputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useAtom(searchQueryAtom, jotaiScope);
const searchQuery = inputValue.trim() as SearchQuery;
const [isSearching, setIsSearching] = useState(false);
const [searchMatches, setSearchMatches] = useState<SearchMatches>({
nonce: null,
items: [],
});
const searchedQueryRef = useRef<SearchQuery | null>(null);
const lastSceneNonceRef = useRef<number | undefined>(undefined);
const [focusIndex, setFocusIndex] = useAtom(
searchItemInFocusAtom,
jotaiScope,
);
const elementsMap = app.scene.getNonDeletedElementsMap();
useEffect(() => {
if (isSearching) {
return;
}
if (
searchQuery !== searchedQueryRef.current ||
app.scene.getSceneNonce() !== lastSceneNonceRef.current
) {
searchedQueryRef.current = null;
handleSearch(searchQuery, app, (matchItems, index) => {
setSearchMatches({
nonce: randomInteger(),
items: matchItems,
});
searchedQueryRef.current = searchQuery;
lastSceneNonceRef.current = app.scene.getSceneNonce();
setAppState({
searchMatches: matchItems.map((searchMatch) => ({
id: searchMatch.textElement.id,
focus: false,
matchedLines: searchMatch.matchedLines,
})),
});
});
}
}, [
isSearching,
searchQuery,
elementsMap,
app,
setAppState,
setFocusIndex,
lastSceneNonceRef,
]);
const goToNextItem = () => {
if (searchMatches.items.length > 0) {
setFocusIndex((focusIndex) => {
if (focusIndex === null) {
return 0;
}
return (focusIndex + 1) % searchMatches.items.length;
});
}
};
const goToPreviousItem = () => {
if (searchMatches.items.length > 0) {
setFocusIndex((focusIndex) => {
if (focusIndex === null) {
return 0;
}
return focusIndex - 1 < 0
? searchMatches.items.length - 1
: focusIndex - 1;
});
}
};
useEffect(() => {
setAppState((state) => {
return {
searchMatches: state.searchMatches.map((match, index) => {
if (index === focusIndex) {
return { ...match, focus: true };
}
return { ...match, focus: false };
}),
};
});
}, [focusIndex, setAppState]);
useEffect(() => {
if (searchMatches.items.length > 0 && focusIndex !== null) {
const match = searchMatches.items[focusIndex];
if (match) {
const matchAsElement = newTextElement({
text: match.searchQuery,
x: match.textElement.x + (match.matchedLines[0]?.offsetX ?? 0),
y: match.textElement.y + (match.matchedLines[0]?.offsetY ?? 0),
width: match.matchedLines[0]?.width,
height: match.matchedLines[0]?.height,
});
const isTextTiny =
match.textElement.fontSize * app.state.zoom.value < 12;
if (
!isElementCompletelyInViewport(
[matchAsElement],
app.canvas.width / window.devicePixelRatio,
app.canvas.height / window.devicePixelRatio,
{
offsetLeft: app.state.offsetLeft,
offsetTop: app.state.offsetTop,
scrollX: app.state.scrollX,
scrollY: app.state.scrollY,
zoom: app.state.zoom,
},
app.scene.getNonDeletedElementsMap(),
app.getEditorUIOffsets(),
) ||
isTextTiny
) {
let zoomOptions: Parameters<AppClassProperties["scrollToContent"]>[1];
if (isTextTiny && app.state.zoom.value >= 1) {
zoomOptions = { fitToViewport: true };
} else if (isTextTiny || app.state.zoom.value > 1) {
zoomOptions = { fitToContent: true };
}
app.scrollToContent(matchAsElement, {
animate: true,
duration: 300,
...zoomOptions,
});
}
}
}
}, [focusIndex, searchMatches, app]);
useEffect(() => {
return () => {
setFocusIndex(null);
searchedQueryRef.current = null;
lastSceneNonceRef.current = undefined;
setAppState({
searchMatches: [],
});
setIsSearching(false);
};
}, [setAppState, setFocusIndex]);
const stableState = useStable({
goToNextItem,
goToPreviousItem,
searchMatches,
});
useEffect(() => {
const eventHandler = (event: KeyboardEvent) => {
if (
event.key === KEYS.ESCAPE &&
!app.state.openDialog &&
!app.state.openPopup
) {
event.preventDefault();
event.stopPropagation();
setAppState({
openSidebar: null,
});
return;
}
if (event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F) {
event.preventDefault();
event.stopPropagation();
if (!searchInputRef.current?.matches(":focus")) {
if (app.state.openDialog) {
setAppState({
openDialog: null,
});
}
searchInputRef.current?.focus();
searchInputRef.current?.select();
} else {
setAppState({
openSidebar: null,
});
}
}
if (
event.target instanceof HTMLElement &&
event.target.closest(".layer-ui__search")
) {
if (stableState.searchMatches.items.length) {
if (event.key === KEYS.ENTER) {
event.stopPropagation();
stableState.goToNextItem();
}
if (event.key === KEYS.ARROW_UP) {
event.stopPropagation();
stableState.goToPreviousItem();
} else if (event.key === KEYS.ARROW_DOWN) {
event.stopPropagation();
stableState.goToNextItem();
}
}
}
};
// `capture` needed to prevent firing on initial open from App.tsx,
// as well as to handle events before App ones
return addEventListener(window, EVENT.KEYDOWN, eventHandler, {
capture: true,
});
}, [setAppState, stableState, app]);
const matchCount = `${searchMatches.items.length} ${
searchMatches.items.length === 1
? t("search.singleResult")
: t("search.multipleResults")
}`;
return (
<div className="layer-ui__search">
<div className="layer-ui__search-header">
<TextField
className={CLASSES.SEARCH_MENU_INPUT_WRAPPER}
value={inputValue}
ref={searchInputRef}
placeholder={t("search.placeholder")}
icon={searchIcon}
onChange={(value) => {
setInputValue(value);
setIsSearching(true);
const searchQuery = value.trim() as SearchQuery;
handleSearch(searchQuery, app, (matchItems, index) => {
setSearchMatches({
nonce: randomInteger(),
items: matchItems,
});
setFocusIndex(index);
searchedQueryRef.current = searchQuery;
lastSceneNonceRef.current = app.scene.getSceneNonce();
setAppState({
searchMatches: matchItems.map((searchMatch) => ({
id: searchMatch.textElement.id,
focus: false,
matchedLines: searchMatch.matchedLines,
})),
});
setIsSearching(false);
});
}}
selectOnRender
/>
</div>
<div className="layer-ui__search-count">
{searchMatches.items.length > 0 && (
<>
{focusIndex !== null && focusIndex > -1 ? (
<div>
{focusIndex + 1} / {matchCount}
</div>
) : (
<div>{matchCount}</div>
)}
<div className="result-nav">
<Button
onSelect={() => {
goToNextItem();
}}
className="result-nav-btn"
>
{collapseDownIcon}
</Button>
<Button
onSelect={() => {
goToPreviousItem();
}}
className="result-nav-btn"
>
{upIcon}
</Button>
</div>
</>
)}
{searchMatches.items.length === 0 &&
searchQuery &&
searchedQueryRef.current && (
<div style={{ margin: "1rem auto" }}>{t("search.noMatch")}</div>
)}
</div>
<MatchList
matches={searchMatches}
onItemClick={setFocusIndex}
focusIndex={focusIndex}
searchQuery={searchQuery}
/>
</div>
);
};
const ListItem = (props: {
preview: SearchMatchItem["preview"];
searchQuery: SearchQuery;
highlighted: boolean;
onClick?: () => void;
}) => {
const preview = [
props.preview.moreBefore ? "..." : "",
props.preview.previewText.slice(0, props.preview.indexInSearchQuery),
props.preview.previewText.slice(
props.preview.indexInSearchQuery,
props.preview.indexInSearchQuery + props.searchQuery.length,
),
props.preview.previewText.slice(
props.preview.indexInSearchQuery + props.searchQuery.length,
),
props.preview.moreAfter ? "..." : "",
];
return (
<div
tabIndex={-1}
className={clsx("layer-ui__result-item", {
active: props.highlighted,
})}
onClick={props.onClick}
ref={(ref) => {
if (props.highlighted) {
ref?.scrollIntoView({ behavior: "auto", block: "nearest" });
}
}}
>
<div className="preview-text">
{preview.flatMap((text, idx) => (
<Fragment key={idx}>{idx === 2 ? <b>{text}</b> : text}</Fragment>
))}
</div>
</div>
);
};
interface MatchListProps {
matches: SearchMatches;
onItemClick: (index: number) => void;
focusIndex: number | null;
searchQuery: SearchQuery;
}
const MatchListBase = (props: MatchListProps) => {
return (
<div className="layer-ui__search-result-container">
{props.matches.items.map((searchMatch, index) => (
<ListItem
key={searchMatch.textElement.id + searchMatch.index}
searchQuery={props.searchQuery}
preview={searchMatch.preview}
highlighted={index === props.focusIndex}
onClick={() => props.onItemClick(index)}
/>
))}
</div>
);
};
const areEqual = (prevProps: MatchListProps, nextProps: MatchListProps) => {
return (
prevProps.matches.nonce === nextProps.matches.nonce &&
prevProps.focusIndex === nextProps.focusIndex
);
};
const MatchList = memo(MatchListBase, areEqual);
const getMatchPreview = (
text: string,
index: number,
searchQuery: SearchQuery,
) => {
const WORDS_BEFORE = 2;
const WORDS_AFTER = 5;
const substrBeforeQuery = text.slice(0, index);
const wordsBeforeQuery = substrBeforeQuery.split(/\s+/);
// text = "small", query = "mall", not complete before
// text = "small", query = "smal", complete before
const isQueryCompleteBefore = substrBeforeQuery.endsWith(" ");
const startWordIndex =
wordsBeforeQuery.length -
WORDS_BEFORE -
1 -
(isQueryCompleteBefore ? 0 : 1);
let wordsBeforeAsString =
wordsBeforeQuery.slice(startWordIndex <= 0 ? 0 : startWordIndex).join(" ") +
(isQueryCompleteBefore ? " " : "");
const MAX_ALLOWED_CHARS = 20;
wordsBeforeAsString =
wordsBeforeAsString.length > MAX_ALLOWED_CHARS
? wordsBeforeAsString.slice(-MAX_ALLOWED_CHARS)
: wordsBeforeAsString;
const substrAfterQuery = text.slice(index + searchQuery.length);
const wordsAfter = substrAfterQuery.split(/\s+/);
// text = "small", query = "mall", complete after
// text = "small", query = "smal", not complete after
const isQueryCompleteAfter = !substrAfterQuery.startsWith(" ");
const numberOfWordsToTake = isQueryCompleteAfter
? WORDS_AFTER + 1
: WORDS_AFTER;
const wordsAfterAsString =
(isQueryCompleteAfter ? "" : " ") +
wordsAfter.slice(0, numberOfWordsToTake).join(" ");
return {
indexInSearchQuery: wordsBeforeAsString.length,
previewText: wordsBeforeAsString + searchQuery + wordsAfterAsString,
moreBefore: startWordIndex > 0,
moreAfter: wordsAfter.length > numberOfWordsToTake,
};
};
const normalizeWrappedText = (
wrappedText: string,
originalText: string,
): string => {
const wrappedLines = wrappedText.split("\n");
const normalizedLines: string[] = [];
let originalIndex = 0;
for (let i = 0; i < wrappedLines.length; i++) {
let currentLine = wrappedLines[i];
const nextLine = wrappedLines[i + 1];
if (nextLine) {
const nextLineIndexInOriginal = originalText.indexOf(
nextLine,
originalIndex,
);
if (nextLineIndexInOriginal > currentLine.length + originalIndex) {
let j = nextLineIndexInOriginal - (currentLine.length + originalIndex);
while (j > 0) {
currentLine += " ";
j--;
}
}
}
normalizedLines.push(currentLine);
originalIndex = originalIndex + currentLine.length;
}
return normalizedLines.join("\n");
};
const getMatchedLines = (
textElement: ExcalidrawTextElement,
searchQuery: SearchQuery,
index: number,
) => {
const normalizedText = normalizeWrappedText(
textElement.text,
textElement.originalText,
);
const lines = normalizedText.split("\n");
const lineIndexRanges = [];
let currentIndex = 0;
let lineNumber = 0;
for (const line of lines) {
const startIndex = currentIndex;
const endIndex = startIndex + line.length - 1;
lineIndexRanges.push({
line,
startIndex,
endIndex,
lineNumber,
});
// Move to the next line's start index
currentIndex = endIndex + 1;
lineNumber++;
}
let startIndex = index;
let remainingQuery = textElement.originalText.slice(
index,
index + searchQuery.length,
);
const matchedLines: {
offsetX: number;
offsetY: number;
width: number;
height: number;
}[] = [];
for (const lineIndexRange of lineIndexRanges) {
if (remainingQuery === "") {
break;
}
if (
startIndex >= lineIndexRange.startIndex &&
startIndex <= lineIndexRange.endIndex
) {
const matchCapacity = lineIndexRange.endIndex + 1 - startIndex;
const textToStart = lineIndexRange.line.slice(
0,
startIndex - lineIndexRange.startIndex,
);
const matchedWord = remainingQuery.slice(0, matchCapacity);
remainingQuery = remainingQuery.slice(matchCapacity);
const offset = measureText(
textToStart,
getFontString(textElement),
textElement.lineHeight,
true,
);
// measureText returns a non-zero width for the empty string
// which is not what we're after here, hence the check and the correction
if (textToStart === "") {
offset.width = 0;
}
if (textElement.textAlign !== "left" && lineIndexRange.line.length > 0) {
const lineLength = measureText(
lineIndexRange.line,
getFontString(textElement),
textElement.lineHeight,
true,
);
const spaceToStart =
textElement.textAlign === "center"
? (textElement.width - lineLength.width) / 2
: textElement.width - lineLength.width;
offset.width += spaceToStart;
}
const { width, height } = measureText(
matchedWord,
getFontString(textElement),
textElement.lineHeight,
);
const offsetX = offset.width;
const offsetY = lineIndexRange.lineNumber * offset.height;
matchedLines.push({
offsetX,
offsetY,
width,
height,
});
startIndex += matchCapacity;
}
}
return matchedLines;
};
const escapeSpecialCharacters = (string: string) => {
return string.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
};
const handleSearch = debounce(
(
searchQuery: SearchQuery,
app: AppClassProperties,
cb: (matchItems: SearchMatchItem[], focusIndex: number | null) => void,
) => {
if (!searchQuery || searchQuery === "") {
cb([], null);
return;
}
const elements = app.scene.getNonDeletedElements();
const texts = elements.filter((el) =>
isTextElement(el),
) as ExcalidrawTextElement[];
texts.sort((a, b) => a.y - b.y);
const matchItems: SearchMatchItem[] = [];
const regex = new RegExp(escapeSpecialCharacters(searchQuery), "gi");
for (const textEl of texts) {
let match = null;
const text = textEl.originalText;
while ((match = regex.exec(text)) !== null) {
const preview = getMatchPreview(text, match.index, searchQuery);
const matchedLines = getMatchedLines(textEl, searchQuery, match.index);
if (matchedLines.length > 0) {
matchItems.push({
textElement: textEl,
searchQuery,
preview,
index: match.index,
matchedLines,
});
}
}
}
const visibleIds = new Set(
app.visibleElements.map((visibleElement) => visibleElement.id),
);
const focusIndex =
matchItems.findIndex((matchItem) =>
visibleIds.has(matchItem.textElement.id),
) ?? null;
cb(matchItems, focusIndex);
},
SEARCH_DEBOUNCE,
);

View file

@ -0,0 +1,29 @@
import { SEARCH_SIDEBAR } from "../constants";
import { t } from "../i18n";
import { SearchMenu } from "./SearchMenu";
import { Sidebar } from "./Sidebar/Sidebar";
export const SearchSidebar = () => {
return (
<Sidebar name={SEARCH_SIDEBAR.name} docked>
<Sidebar.Tabs>
<Sidebar.Header>
<div
style={{
color: "var(--color-primary)",
fontSize: "1.2em",
fontWeight: "bold",
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
paddingRight: "1em",
}}
>
{t("search.title")}
</div>
</Sidebar.Header>
<SearchMenu />
</Sidebar.Tabs>
</Sidebar>
);
};

View file

@ -2,13 +2,14 @@ import { mutateElement } from "../../element/mutateElement";
import { getBoundTextElement } from "../../element/textElement";
import { isArrowElement, isElbowArrow } from "../../element/typeChecks";
import type { ExcalidrawElement } from "../../element/types";
import { degreeToRadian, radianToDegree } from "../../math";
import { angleIcon } from "../icons";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
import type { Degrees } from "../../../math";
import { degreesToRadians, radiansToDegrees } from "../../../math";
interface AngleProps {
element: ExcalidrawElement;
@ -36,7 +37,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
}
if (nextValue !== undefined) {
const nextAngle = degreeToRadian(nextValue);
const nextAngle = degreesToRadians(nextValue as Degrees);
mutateElement(latestElement, {
angle: nextAngle,
});
@ -51,7 +52,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
}
const originalAngleInDegrees =
Math.round(radianToDegree(origElement.angle) * 100) / 100;
Math.round(radiansToDegrees(origElement.angle) * 100) / 100;
const changeInDegrees = Math.round(accumulatedChange);
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
if (shouldChangeByStepSize) {
@ -61,7 +62,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
nextAngleInDegrees =
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
const nextAngle = degreeToRadian(nextAngleInDegrees);
const nextAngle = degreesToRadians(nextAngleInDegrees as Degrees);
mutateElement(latestElement, {
angle: nextAngle,
@ -80,7 +81,7 @@ const Angle = ({ element, scene, appState, property }: AngleProps) => {
<DragInput
label="A"
icon={angleIcon}
value={Math.round((radianToDegree(element.angle) % 360) * 100) / 100}
value={Math.round((radiansToDegrees(element.angle) % 360) * 100) / 100}
elements={[element]}
dragInputCallback={handleDegreeChange}
editable={isPropertyEditable(element, "angle")}

View file

@ -3,13 +3,14 @@ import { getBoundTextElement } from "../../element/textElement";
import { isArrowElement } from "../../element/typeChecks";
import type { ExcalidrawElement } from "../../element/types";
import { isInGroup } from "../../groups";
import { degreeToRadian, radianToDegree } from "../../math";
import type Scene from "../../scene/Scene";
import { angleIcon } from "../icons";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, isPropertyEditable } from "./utils";
import type { AppState } from "../../types";
import type { Degrees } from "../../../math";
import { degreesToRadians, radiansToDegrees } from "../../../math";
interface MultiAngleProps {
elements: readonly ExcalidrawElement[];
@ -39,7 +40,7 @@ const handleDegreeChange: DragInputCallbackType<
);
if (nextValue !== undefined) {
const nextAngle = degreeToRadian(nextValue);
const nextAngle = degreesToRadians(nextValue as Degrees);
for (const element of editableLatestIndividualElements) {
if (!element) {
@ -71,7 +72,7 @@ const handleDegreeChange: DragInputCallbackType<
}
const originalElement = editableOriginalIndividualElements[i];
const originalAngleInDegrees =
Math.round(radianToDegree(originalElement.angle) * 100) / 100;
Math.round(radiansToDegrees(originalElement.angle) * 100) / 100;
const changeInDegrees = Math.round(accumulatedChange);
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
if (shouldChangeByStepSize) {
@ -81,7 +82,7 @@ const handleDegreeChange: DragInputCallbackType<
nextAngleInDegrees =
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
const nextAngle = degreeToRadian(nextAngleInDegrees);
const nextAngle = degreesToRadians(nextAngleInDegrees as Degrees);
mutateElement(
latestElement,
@ -109,7 +110,7 @@ const MultiAngle = ({
(el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
);
const angles = editableLatestIndividualElements.map(
(el) => Math.round((radianToDegree(el.angle) % 360) * 100) / 100,
(el) => Math.round((radiansToDegrees(el.angle) % 360) * 100) / 100,
);
const value = new Set(angles).size === 1 ? angles[0] : "Mixed";

View file

@ -13,13 +13,14 @@ import type {
NonDeletedSceneElementsMap,
} from "../../element/types";
import type Scene from "../../scene/Scene";
import type { AppState, Point } from "../../types";
import type { AppState } from "../../types";
import DragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
import { getElementsInAtomicUnit, resizeElement } from "./utils";
import type { AtomicUnit } from "./utils";
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
import { point, type GlobalPoint } from "../../../math";
interface MultiDimensionProps {
property: "width" | "height";
@ -104,7 +105,7 @@ const resizeGroup = (
nextHeight: number,
initialHeight: number,
aspectRatio: number,
anchor: Point,
anchor: GlobalPoint,
property: MultiDimensionProps["property"],
latestElements: ExcalidrawElement[],
originalElements: ExcalidrawElement[],
@ -181,7 +182,7 @@ const handleDimensionChange: DragInputCallbackType<
nextHeight,
initialHeight,
aspectRatio,
[x1, y1],
point(x1, y1),
property,
latestElements,
originalElements,
@ -286,7 +287,7 @@ const handleDimensionChange: DragInputCallbackType<
nextHeight,
initialHeight,
aspectRatio,
[x1, y1],
point(x1, y1),
property,
latestElements,
originalElements,

View file

@ -4,7 +4,6 @@ import type {
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
} from "../../element/types";
import { rotate } from "../../math";
import type Scene from "../../scene/Scene";
import StatsDragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
@ -14,6 +13,7 @@ import { useMemo } from "react";
import { getElementsInAtomicUnit, moveElement } from "./utils";
import type { AtomicUnit } from "./utils";
import type { AppState } from "../../types";
import { point, pointRotateRads } from "../../../math";
interface MultiPositionProps {
property: "x" | "y";
@ -43,11 +43,9 @@ const moveElements = (
origElement.x + origElement.width / 2,
origElement.y + origElement.height / 2,
];
const [topLeftX, topLeftY] = rotate(
origElement.x,
origElement.y,
cx,
cy,
const [topLeftX, topLeftY] = pointRotateRads(
point(origElement.x, origElement.y),
point(cx, cy),
origElement.angle,
);
@ -98,11 +96,9 @@ const moveGroupTo = (
latestElement.y + latestElement.height / 2,
];
const [topLeftX, topLeftY] = rotate(
latestElement.x,
latestElement.y,
cx,
cy,
const [topLeftX, topLeftY] = pointRotateRads(
point(latestElement.x, latestElement.y),
point(cx, cy),
latestElement.angle,
);
@ -174,11 +170,9 @@ const handlePositionChange: DragInputCallbackType<
origElement.x + origElement.width / 2,
origElement.y + origElement.height / 2,
];
const [topLeftX, topLeftY] = rotate(
origElement.x,
origElement.y,
cx,
cy,
const [topLeftX, topLeftY] = pointRotateRads(
point(origElement.x, origElement.y),
point(cx, cy),
origElement.angle,
);
@ -246,7 +240,11 @@ const MultiPosition = ({
const [el] = elementsInUnit;
const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
const [topLeftX, topLeftY] = rotate(el.x, el.y, cx, cy, el.angle);
const [topLeftX, topLeftY] = pointRotateRads(
point(el.x, el.y),
point(cx, cy),
el.angle,
);
return Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
}),

View file

@ -1,10 +1,10 @@
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
import { rotate } from "../../math";
import StatsDragInput from "./DragInput";
import type { DragInputCallbackType } from "./DragInput";
import { getStepSizedValue, moveElement } from "./utils";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
import { point, pointRotateRads } from "../../../math";
interface PositionProps {
property: "x" | "y";
@ -32,11 +32,9 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
origElement.x + origElement.width / 2,
origElement.y + origElement.height / 2,
];
const [topLeftX, topLeftY] = rotate(
origElement.x,
origElement.y,
cx,
cy,
const [topLeftX, topLeftY] = pointRotateRads(
point(origElement.x, origElement.y),
point(cx, cy),
origElement.angle,
);
@ -94,11 +92,9 @@ const Position = ({
scene,
appState,
}: PositionProps) => {
const [topLeftX, topLeftY] = rotate(
element.x,
element.y,
element.x + element.width / 2,
element.y + element.height / 2,
const [topLeftX, topLeftY] = pointRotateRads(
point(element.x, element.y),
point(element.x + element.width / 2, element.y + element.height / 2),
element.angle,
);
const value =

View file

@ -19,12 +19,13 @@ import type {
ExcalidrawLinearElement,
ExcalidrawTextElement,
} from "../../element/types";
import { degreeToRadian, rotate } from "../../math";
import { getTextEditor, updateTextEditor } from "../../tests/queries/dom";
import { getCommonBounds, isTextElement } from "../../element";
import { API } from "../../tests/helpers/api";
import { actionGroup } from "../../actions";
import { isInGroup } from "../../groups";
import type { Degrees } from "../../../math";
import { degreesToRadians, point, pointRotateRads } from "../../../math";
const { h } = window;
const mouse = new Pointer("mouse");
@ -46,7 +47,9 @@ const testInputProperty = (
expect(input.value).toBe(initialValue.toString());
UI.updateInput(input, String(nextValue));
if (property === "angle") {
expect(element[property]).toBe(degreeToRadian(Number(nextValue)));
expect(element[property]).toBe(
degreesToRadians(Number(nextValue) as Degrees),
);
} else if (property === "fontSize" && isTextElement(element)) {
expect(element[property]).toBe(Number(nextValue));
} else if (property !== "fontSize") {
@ -260,11 +263,9 @@ describe("stats for a generic element", () => {
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
];
const [topLeftX, topLeftY] = rotate(
rectangle.x,
rectangle.y,
cx,
cy,
const [topLeftX, topLeftY] = pointRotateRads(
point(rectangle.x, rectangle.y),
point(cx, cy),
rectangle.angle,
);
@ -281,11 +282,9 @@ describe("stats for a generic element", () => {
testInputProperty(rectangle, "angle", "A", 0, 45);
let [newTopLeftX, newTopLeftY] = rotate(
rectangle.x,
rectangle.y,
cx,
cy,
let [newTopLeftX, newTopLeftY] = pointRotateRads(
point(rectangle.x, rectangle.y),
point(cx, cy),
rectangle.angle,
);
@ -294,11 +293,9 @@ describe("stats for a generic element", () => {
testInputProperty(rectangle, "angle", "A", 45, 66);
[newTopLeftX, newTopLeftY] = rotate(
rectangle.x,
rectangle.y,
cx,
cy,
[newTopLeftX, newTopLeftY] = pointRotateRads(
point(rectangle.x, rectangle.y),
point(cx, cy),
rectangle.angle,
);
expect(newTopLeftX.toString()).not.toEqual(xInput.value);
@ -313,11 +310,9 @@ describe("stats for a generic element", () => {
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
];
const [topLeftX, topLeftY] = rotate(
rectangle.x,
rectangle.y,
cx,
cy,
const [topLeftX, topLeftY] = pointRotateRads(
point(rectangle.x, rectangle.y),
point(cx, cy),
rectangle.angle,
);
testInputProperty(rectangle, "width", "W", rectangle.width, 400);
@ -325,11 +320,9 @@ describe("stats for a generic element", () => {
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
];
let [currentTopLeftX, currentTopLeftY] = rotate(
rectangle.x,
rectangle.y,
cx,
cy,
let [currentTopLeftX, currentTopLeftY] = pointRotateRads(
point(rectangle.x, rectangle.y),
point(cx, cy),
rectangle.angle,
);
expect(currentTopLeftX).toBeCloseTo(topLeftX, 4);
@ -340,11 +333,9 @@ describe("stats for a generic element", () => {
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
];
[currentTopLeftX, currentTopLeftY] = rotate(
rectangle.x,
rectangle.y,
cx,
cy,
[currentTopLeftX, currentTopLeftY] = pointRotateRads(
point(rectangle.x, rectangle.y),
point(cx, cy),
rectangle.angle,
);
@ -642,7 +633,7 @@ describe("stats for multiple elements", () => {
UI.updateInput(angle, "40");
const angleInRadian = degreeToRadian(40);
const angleInRadian = degreesToRadians(40 as Degrees);
expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4);
expect(text?.angle).toBeCloseTo(angleInRadian, 4);
expect(frame.angle).toBe(0);

View file

@ -1,3 +1,5 @@
import type { Radians } from "../../../math";
import { point, pointRotateRads } from "../../../math";
import {
bindOrUnbindLinearElements,
updateBoundElements,
@ -30,7 +32,6 @@ import {
getElementsInGroup,
isInGroup,
} from "../../groups";
import { rotate } from "../../math";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
import { getFontString } from "../../utils";
@ -229,23 +230,19 @@ export const moveElement = (
originalElement.x + originalElement.width / 2,
originalElement.y + originalElement.height / 2,
];
const [topLeftX, topLeftY] = rotate(
originalElement.x,
originalElement.y,
cx,
cy,
const [topLeftX, topLeftY] = pointRotateRads(
point(originalElement.x, originalElement.y),
point(cx, cy),
originalElement.angle,
);
const changeInX = newTopLeftX - topLeftX;
const changeInY = newTopLeftY - topLeftY;
const [x, y] = rotate(
newTopLeftX,
newTopLeftY,
cx + changeInX,
cy + changeInY,
-originalElement.angle,
const [x, y] = pointRotateRads(
point(newTopLeftX, newTopLeftY),
point(cx + changeInX, cy + changeInY),
-originalElement.angle as Radians,
);
mutateElement(

View file

@ -25,11 +25,11 @@ import type { BinaryFiles } from "../../types";
import { ArrowRightIcon } from "../icons";
import "./TTDDialog.scss";
import { isFiniteNumber } from "../../utils";
import { atom, useAtom } from "jotai";
import { trackEvent } from "../../analytics";
import { InlineIcon } from "../InlineIcon";
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
import { isFiniteNumber } from "../../../math";
const MIN_PROMPT_LENGTH = 3;
const MAX_PROMPT_LENGTH = 1000;

View file

@ -3,16 +3,29 @@
.excalidraw {
--ExcTextField--color: var(--color-on-surface);
--ExcTextField--label-color: var(--color-on-surface);
--ExcTextField--background: transparent;
--ExcTextField--background: var(--color-surface-low);
--ExcTextField--readonly--background: var(--color-surface-high);
--ExcTextField--readonly--color: var(--color-on-surface);
--ExcTextField--border: var(--color-border-outline);
--ExcTextField--border: var(--color-gray-20);
--ExcTextField--readonly--border: var(--color-border-outline-variant);
--ExcTextField--border-hover: var(--color-brand-hover);
--ExcTextField--border-active: var(--color-brand-active);
--ExcTextField--placeholder: var(--color-border-outline-variant);
.ExcTextField {
position: relative;
svg {
position: absolute;
top: 50%; // 50% is not exactly in the center of the input
transform: translateY(-50%);
left: 0.75rem;
width: 1.25rem;
height: 1.25rem;
color: var(--color-gray-40);
z-index: 1;
}
&--fullWidth {
width: 100%;
flex-grow: 1;
@ -37,7 +50,6 @@
display: flex;
flex-direction: row;
align-items: center;
padding: 0 1rem;
height: 3rem;
@ -45,6 +57,8 @@
border: 1px solid var(--ExcTextField--border);
border-radius: 0.5rem;
padding: 0 0.75rem;
&:not(&--readonly) {
&:hover {
border-color: var(--ExcTextField--border-hover);
@ -80,10 +94,6 @@
width: 100%;
&::placeholder {
color: var(--ExcTextField--placeholder);
}
&:not(:focus) {
&:hover {
background-color: initial;
@ -105,5 +115,9 @@
}
}
}
&--hasIcon .ExcTextField__input {
padding-left: 2.5rem;
}
}
}

View file

@ -21,7 +21,9 @@ type TextFieldProps = {
fullWidth?: boolean;
selectOnRender?: boolean;
icon?: React.ReactNode;
label?: string;
className?: string;
placeholder?: string;
isRedacted?: boolean;
} & ({ value: string } | { defaultValue: string });
@ -37,6 +39,8 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
selectOnRender,
onKeyDown,
isRedacted = false,
icon,
className,
...rest
},
ref,
@ -47,6 +51,8 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
useLayoutEffect(() => {
if (selectOnRender) {
// focusing first is needed because vitest/jsdom
innerRef.current?.focus();
innerRef.current?.select();
}
}, [selectOnRender]);
@ -56,14 +62,16 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
return (
<div
className={clsx("ExcTextField", {
className={clsx("ExcTextField", className, {
"ExcTextField--fullWidth": fullWidth,
"ExcTextField--hasIcon": !!icon,
})}
onClick={() => {
innerRef.current?.focus();
}}
>
<div className="ExcTextField__label">{label}</div>
{icon}
{label && <div className="ExcTextField__label">{label}</div>}
<div
className={clsx("ExcTextField__input", {
"ExcTextField__input--readonly": readonly,

View file

@ -203,6 +203,7 @@ const getRelevantAppStateProps = (
snapLines: appState.snapLines,
zenModeEnabled: appState.zenModeEnabled,
editingTextElement: appState.editingTextElement,
searchMatches: appState.searchMatches,
});
const areEqual = (

View file

@ -1,4 +1,4 @@
import type { AppState, ExcalidrawProps, Point, UIAppState } from "../../types";
import type { AppState, ExcalidrawProps, UIAppState } from "../../types";
import {
sceneCoordsToViewportCoords,
viewportCoordsToSceneCoords,
@ -36,6 +36,7 @@ import { trackEvent } from "../../analytics";
import { useAppProps, useExcalidrawAppState } from "../App";
import { isEmbeddableElement } from "../../element/typeChecks";
import { getLinkHandleFromCoords } from "./helpers";
import { point, type GlobalPoint } from "../../../math";
const CONTAINER_WIDTH = 320;
const SPACE_BOTTOM = 85;
@ -176,10 +177,12 @@ export const Hyperlink = ({
if (timeoutId) {
clearTimeout(timeoutId);
}
const shouldHide = shouldHideLinkPopup(element, elementsMap, appState, [
event.clientX,
event.clientY,
]) as boolean;
const shouldHide = shouldHideLinkPopup(
element,
elementsMap,
appState,
point(event.clientX, event.clientY),
) as boolean;
if (shouldHide) {
timeoutId = window.setTimeout(() => {
setAppState({ showHyperlinkPopup: false });
@ -416,7 +419,7 @@ const shouldHideLinkPopup = (
element: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
appState: AppState,
[clientX, clientY]: Point,
[clientX, clientY]: GlobalPoint,
): Boolean => {
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
{ clientX, clientY },

View file

@ -1,3 +1,5 @@
import type { GlobalPoint, Radians } from "../../../math";
import { point, pointRotateRads } from "../../../math";
import { MIME_TYPES } from "../../constants";
import type { Bounds } from "../../element/bounds";
import { getElementAbsoluteCoords } from "../../element/bounds";
@ -6,9 +8,8 @@ import type {
ElementsMap,
NonDeletedExcalidrawElement,
} from "../../element/types";
import { rotate } from "../../math";
import { DEFAULT_LINK_SIZE } from "../../renderer/renderElement";
import type { AppState, Point, UIAppState } from "../../types";
import type { AppState, UIAppState } from "../../types";
export const EXTERNAL_LINK_IMG = document.createElement("img");
EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
@ -17,7 +18,7 @@ EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
export const getLinkHandleFromCoords = (
[x1, y1, x2, y2]: Bounds,
angle: number,
angle: Radians,
appState: Pick<UIAppState, "zoom">,
): Bounds => {
const size = DEFAULT_LINK_SIZE;
@ -33,11 +34,9 @@ export const getLinkHandleFromCoords = (
const x = x2 + dashedLineMargin - centeringOffset;
const y = y1 - dashedLineMargin - linkMarginY + centeringOffset;
const [rotatedX, rotatedY] = rotate(
x + linkWidth / 2,
y + linkHeight / 2,
centerX,
centerY,
const [rotatedX, rotatedY] = pointRotateRads(
point(x + linkWidth / 2, y + linkHeight / 2),
point(centerX, centerY),
angle,
);
return [
@ -52,7 +51,7 @@ export const isPointHittingLinkIcon = (
element: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
appState: AppState,
[x, y]: Point,
[x, y]: GlobalPoint,
) => {
const threshold = 4 / appState.zoom.value;
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
@ -73,7 +72,7 @@ export const isPointHittingLink = (
element: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
appState: AppState,
[x, y]: Point,
[x, y]: GlobalPoint,
isMobile: boolean,
) => {
if (!element.link || appState.selectedElementIds[element.id]) {
@ -86,5 +85,5 @@ export const isPointHittingLink = (
) {
return true;
}
return isPointHittingLinkIcon(element, elementsMap, appState, [x, y]);
return isPointHittingLinkIcon(element, elementsMap, appState, point(x, y));
};

View file

@ -2139,3 +2139,11 @@ export const collapseUpIcon = createIcon(
</g>,
tablerIconProps,
);
export const upIcon = createIcon(
<g>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M6 15l6 -6l6 6" />
</g>,
tablerIconProps,
);

View file

@ -15,6 +15,7 @@ import {
LoadIcon,
MoonIcon,
save,
searchIcon,
SunIcon,
TrashIcon,
usersIcon,
@ -27,6 +28,7 @@ import {
actionLoadScene,
actionSaveToActiveFile,
actionShortcuts,
actionToggleSearchMenu,
actionToggleTheme,
} from "../../actions";
import clsx from "clsx";
@ -40,7 +42,6 @@ import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemConten
import { THEME } from "../../constants";
import type { Theme } from "../../element/types";
import { trackEvent } from "../../analytics";
import "./DefaultItems.scss";
export const LoadScene = () => {
@ -145,6 +146,27 @@ export const CommandPalette = (opts?: { className?: string }) => {
};
CommandPalette.displayName = "CommandPalette";
export const SearchMenu = (opts?: { className?: string }) => {
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
return (
<DropdownMenuItem
icon={searchIcon}
data-testid="search-menu-button"
onSelect={() => {
actionManager.executeAction(actionToggleSearchMenu);
}}
shortcut={getShortcutFromShortcutName("searchMenu")}
aria-label={t("search.title")}
className={opts?.className}
>
{t("search.title")}
</DropdownMenuItem>
);
};
SearchMenu.displayName = "SearchMenu";
export const Help = () => {
const { t } = useI18n();

View file

@ -113,6 +113,7 @@ export const ENV = {
export const CLASSES = {
SHAPE_ACTIONS_MENU: "App-menu__left",
ZOOM_ACTIONS: "zoom-actions",
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
};
/**
@ -382,6 +383,10 @@ export const DEFAULT_SIDEBAR = {
defaultTab: LIBRARY_SIDEBAR_TAB,
} as const;
export const SEARCH_SIDEBAR = {
name: "search",
};
export const LIBRARY_DISABLED_TYPES = new Set([
"iframe",
"embeddable",

View file

@ -387,7 +387,7 @@ body.excalidraw-cursor-resize * {
.App-menu__left {
overflow-y: auto;
padding: 0.75rem;
width: 200px;
width: 12.5rem;
box-sizing: border-box;
position: absolute;
}

View file

@ -144,9 +144,9 @@
--border-radius-md: 0.375rem;
--border-radius-lg: 0.5rem;
--color-surface-high: hsl(244, 100%, 97%);
--color-surface-mid: hsl(240 25% 96%);
--color-surface-low: hsl(240 25% 94%);
--color-surface-high: #f1f0ff;
--color-surface-mid: #f2f2f7;
--color-surface-low: #ececf4;
--color-surface-lowest: #ffffff;
--color-on-surface: #1b1b1f;
--color-brand-hover: #5753d0;

View file

@ -40,11 +40,7 @@ import {
import { getDefaultAppState } from "../appState";
import { LinearElementEditor } from "../element/linearElementEditor";
import { bumpVersion } from "../element/mutateElement";
import {
getUpdatedTimestamp,
isFiniteNumber,
updateActiveTool,
} from "../utils";
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
import { arrayToMap } from "../utils";
import type { MarkOptional, Mutable } from "../utility-types";
import { detectLineHeight, getContainerElement } from "../element/textElement";
@ -58,6 +54,8 @@ import {
getNormalizedGridStep,
getNormalizedZoom,
} from "../scene";
import type { LocalPoint, Radians } from "../../math";
import { isFiniteNumber, point } from "../../math";
type RestoredAppState = Omit<
AppState,
@ -152,7 +150,7 @@ const restoreElementWithProperties = <
roughness: element.roughness ?? DEFAULT_ELEMENT_PROPS.roughness,
opacity:
element.opacity == null ? DEFAULT_ELEMENT_PROPS.opacity : element.opacity,
angle: element.angle || 0,
angle: element.angle || (0 as Radians),
x: extra.x ?? element.x ?? 0,
y: extra.y ?? element.y ?? 0,
strokeColor: element.strokeColor || DEFAULT_ELEMENT_PROPS.strokeColor,
@ -266,10 +264,7 @@ const restoreElement = (
let y = element.y;
let points = // migrate old arrow model to new one
!Array.isArray(element.points) || element.points.length < 2
? [
[0, 0],
[element.width, element.height],
]
? [point(0, 0), point(element.width, element.height)]
: element.points;
if (points[0][0] !== 0 || points[0][1] !== 0) {
@ -293,14 +288,11 @@ const restoreElement = (
});
case "arrow": {
const { startArrowhead = null, endArrowhead = "arrow" } = element;
let x = element.x;
let y = element.y;
let points = // 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
? [
[0, 0],
[element.width, element.height],
]
? [point(0, 0), point(element.width, element.height)]
: element.points;
if (points[0][0] !== 0 || points[0][1] !== 0) {

View file

@ -2,6 +2,7 @@ import { vi } from "vitest";
import type { ExcalidrawElementSkeleton } from "./transform";
import { convertToExcalidrawElements } from "./transform";
import type { ExcalidrawArrowElement } from "../element/types";
import { point } from "../../math";
const opts = { regenerateIds: false };
@ -911,10 +912,7 @@ describe("Test Transform", () => {
x: 111.262,
y: 57,
strokeWidth: 2,
points: [
[0, 0],
[272.985, 0],
],
points: [point(0, 0), point(272.985, 0)],
label: {
text: "How are you?",
fontSize: 20,
@ -937,7 +935,7 @@ describe("Test Transform", () => {
x: 77.017,
y: 79,
strokeWidth: 2,
points: [[0, 0]],
points: [point(0, 0)],
label: {
text: "Friendship",
fontSize: 20,

View file

@ -53,6 +53,7 @@ import { randomId } from "../random";
import { syncInvalidIndices } from "../fractionalIndex";
import { getLineHeight } from "../fonts";
import { isArrowElement } from "../element/typeChecks";
import { point, type LocalPoint } from "../../math";
export type ValidLinearElement = {
type: "arrow" | "line";
@ -417,7 +418,7 @@ const bindLinearElementToElement = (
const endPointIndex = linearElement.points.length - 1;
const delta = 0.5;
const newPoints = cloneJSON(linearElement.points) as [number, number][];
const newPoints = cloneJSON<readonly LocalPoint[]>(linearElement.points);
// left to right so shift the arrow towards right
if (
@ -535,10 +536,7 @@ export const convertToExcalidrawElements = (
excalidrawElement = newLinearElement({
width,
height,
points: [
[0, 0],
[width, height],
],
points: [point(0, 0), point(width, height)],
...element,
});
@ -551,10 +549,7 @@ export const convertToExcalidrawElements = (
width,
height,
endArrowhead: "arrow",
points: [
[0, 0],
[width, height],
],
points: [point(0, 0), point(width, height)],
...element,
type: "arrow",
});

View file

@ -1,8 +1,8 @@
import * as GA from "../ga";
import * as GAPoint from "../gapoints";
import * as GADirection from "../gadirections";
import * as GALine from "../galines";
import * as GATransform from "../gatransforms";
import * as GA from "../../math/ga/ga";
import * as GAPoint from "../../math/ga/gapoints";
import * as GADirection from "../../math/ga/gadirections";
import * as GALine from "../../math/ga/galines";
import * as GATransform from "../../math/ga/gatransforms";
import type {
ExcalidrawBindableElement,
@ -10,7 +10,6 @@ import type {
ExcalidrawRectangleElement,
ExcalidrawDiamondElement,
ExcalidrawEllipseElement,
ExcalidrawFreeDrawElement,
ExcalidrawImageElement,
ExcalidrawFrameLikeElement,
ExcalidrawIframeLikeElement,
@ -26,11 +25,12 @@ import type {
ExcalidrawElbowArrowElement,
FixedPoint,
SceneElementsMap,
ExcalidrawRectanguloidElement,
} from "./types";
import type { Bounds } from "./bounds";
import { getElementAbsoluteCoords } from "./bounds";
import type { AppState, Point } from "../types";
import { getCenterForBounds, getElementAbsoluteCoords } from "./bounds";
import type { AppState } from "../types";
import { isPointOnShape } from "../../utils/collision";
import { getElementAtPosition } from "../scene";
import {
@ -51,17 +51,7 @@ import { LinearElementEditor } from "./linearElementEditor";
import { arrayToMap, tupleToCoors } from "../utils";
import { KEYS } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { getElementShape } from "../shapes";
import {
aabbForElement,
clamp,
distanceSq2d,
getCenterForBounds,
getCenterForElement,
pointInsideBounds,
pointToVector,
rotatePoint,
} from "../math";
import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes";
import {
compareHeading,
HEADING_DOWN,
@ -72,7 +62,18 @@ import {
vectorToHeading,
type Heading,
} from "./heading";
import { segmentIntersectRectangleElement } from "../../utils/geometry/geometry";
import type { LocalPoint, Radians } from "../../math";
import {
lineSegment,
point,
pointRotateRads,
type GlobalPoint,
vectorFromPoint,
pointFromPair,
pointDistanceSq,
clamp,
} from "../../math";
import { segmentIntersectRectangleElement } from "../../utils/geometry/shape";
export type SuggestedBinding =
| NonDeleted<ExcalidrawBindableElement>
@ -649,7 +650,7 @@ export const updateBoundElements = (
update,
): update is NonNullable<{
index: number;
point: Point;
point: LocalPoint;
isDragging?: boolean;
}> => update !== null,
);
@ -695,14 +696,14 @@ const getSimultaneouslyUpdatedElementIds = (
};
export const getHeadingForElbowArrowSnap = (
point: Readonly<Point>,
otherPoint: Readonly<Point>,
p: Readonly<GlobalPoint>,
otherPoint: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement | undefined | null,
aabb: Bounds | undefined | null,
elementsMap: ElementsMap,
origPoint: Point,
origPoint: GlobalPoint,
): Heading => {
const otherPointHeading = vectorToHeading(pointToVector(otherPoint, point));
const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p));
if (!bindableElement || !aabb) {
return otherPointHeading;
@ -716,17 +717,23 @@ export const getHeadingForElbowArrowSnap = (
if (!distance) {
return vectorToHeading(
pointToVector(point, getCenterForElement(bindableElement)),
vectorFromPoint(
p,
point<GlobalPoint>(
bindableElement.x + bindableElement.width / 2,
bindableElement.y + bindableElement.height / 2,
),
),
);
}
const pointHeading = headingForPointFromElement(bindableElement, aabb, point);
const pointHeading = headingForPointFromElement(bindableElement, aabb, p);
return pointHeading;
};
const getDistanceForBinding = (
point: Readonly<Point>,
point: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
) => {
@ -745,89 +752,87 @@ const getDistanceForBinding = (
};
export const bindPointToSnapToElementOutline = (
point: Readonly<Point>,
otherPoint: Readonly<Point>,
p: Readonly<GlobalPoint>,
otherPoint: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement | undefined,
elementsMap: ElementsMap,
): Point => {
): GlobalPoint => {
const aabb = bindableElement && aabbForElement(bindableElement);
if (bindableElement && aabb) {
// TODO: Dirty hacks until tangents are properly calculated
const heading = headingForPointFromElement(bindableElement, aabb, point);
const heading = headingForPointFromElement(bindableElement, aabb, p);
const intersections = [
...intersectElementWithLine(
...(intersectElementWithLine(
bindableElement,
[point[0], point[1] - 2 * bindableElement.height],
[point[0], point[1] + 2 * bindableElement.height],
point(p[0], p[1] - 2 * bindableElement.height),
point(p[0], p[1] + 2 * bindableElement.height),
FIXED_BINDING_DISTANCE,
elementsMap,
),
...intersectElementWithLine(
) ?? []),
...(intersectElementWithLine(
bindableElement,
[point[0] - 2 * bindableElement.width, point[1]],
[point[0] + 2 * bindableElement.width, point[1]],
point(p[0] - 2 * bindableElement.width, p[1]),
point(p[0] + 2 * bindableElement.width, p[1]),
FIXED_BINDING_DISTANCE,
elementsMap,
),
) ?? []),
];
const isVertical =
compareHeading(heading, HEADING_LEFT) ||
compareHeading(heading, HEADING_RIGHT);
const dist = Math.abs(
distanceToBindableElement(bindableElement, point, elementsMap),
distanceToBindableElement(bindableElement, p, elementsMap),
);
const isInner = isVertical
? dist < bindableElement.width * -0.1
: dist < bindableElement.height * -0.1;
intersections.sort(
(a, b) => distanceSq2d(a, point) - distanceSq2d(b, point),
);
intersections.sort((a, b) => pointDistanceSq(a, p) - pointDistanceSq(b, p));
return isInner
? headingToMidBindPoint(otherPoint, bindableElement, aabb)
: intersections.filter((i) =>
isVertical
? Math.abs(point[1] - i[1]) < 0.1
: Math.abs(point[0] - i[0]) < 0.1,
? Math.abs(p[1] - i[1]) < 0.1
: Math.abs(p[0] - i[0]) < 0.1,
)[0] ?? point;
}
return point;
return p;
};
const headingToMidBindPoint = (
point: Point,
p: GlobalPoint,
bindableElement: ExcalidrawBindableElement,
aabb: Bounds,
): Point => {
): GlobalPoint => {
const center = getCenterForBounds(aabb);
const heading = vectorToHeading(pointToVector(point, center));
const heading = vectorToHeading(vectorFromPoint(p, center));
switch (true) {
case compareHeading(heading, HEADING_UP):
return rotatePoint(
[(aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]],
return pointRotateRads(
point((aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]),
center,
bindableElement.angle,
);
case compareHeading(heading, HEADING_RIGHT):
return rotatePoint(
[aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1],
return pointRotateRads(
point(aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1),
center,
bindableElement.angle,
);
case compareHeading(heading, HEADING_DOWN):
return rotatePoint(
[(aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]],
return pointRotateRads(
point((aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]),
center,
bindableElement.angle,
);
default:
return rotatePoint(
[aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1],
return pointRotateRads(
point(aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1),
center,
bindableElement.angle,
);
@ -836,22 +841,25 @@ const headingToMidBindPoint = (
export const avoidRectangularCorner = (
element: ExcalidrawBindableElement,
p: Point,
): Point => {
const center = getCenterForElement(element);
const nonRotatedPoint = rotatePoint(p, center, -element.angle);
p: GlobalPoint,
): GlobalPoint => {
const center = point<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height / 2,
);
const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
// Top left
if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) {
return rotatePoint(
[element.x - FIXED_BINDING_DISTANCE, element.y],
return pointRotateRads<GlobalPoint>(
point(element.x - FIXED_BINDING_DISTANCE, element.y),
center,
element.angle,
);
}
return rotatePoint(
[element.x, element.y - FIXED_BINDING_DISTANCE],
return pointRotateRads(
point(element.x, element.y - FIXED_BINDING_DISTANCE),
center,
element.angle,
);
@ -861,14 +869,14 @@ export const avoidRectangularCorner = (
) {
// Bottom left
if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) {
return rotatePoint(
[element.x, element.y + element.height + FIXED_BINDING_DISTANCE],
return pointRotateRads(
point(element.x, element.y + element.height + FIXED_BINDING_DISTANCE),
center,
element.angle,
);
}
return rotatePoint(
[element.x - FIXED_BINDING_DISTANCE, element.y + element.height],
return pointRotateRads(
point(element.x - FIXED_BINDING_DISTANCE, element.y + element.height),
center,
element.angle,
);
@ -881,20 +889,20 @@ export const avoidRectangularCorner = (
nonRotatedPoint[0] - element.x <
element.width + FIXED_BINDING_DISTANCE
) {
return rotatePoint(
[
return pointRotateRads(
point(
element.x + element.width,
element.y + element.height + FIXED_BINDING_DISTANCE,
],
),
center,
element.angle,
);
}
return rotatePoint(
[
return pointRotateRads(
point(
element.x + element.width + FIXED_BINDING_DISTANCE,
element.y + element.height,
],
),
center,
element.angle,
);
@ -907,14 +915,14 @@ export const avoidRectangularCorner = (
nonRotatedPoint[0] - element.x <
element.width + FIXED_BINDING_DISTANCE
) {
return rotatePoint(
[element.x + element.width, element.y - FIXED_BINDING_DISTANCE],
return pointRotateRads(
point(element.x + element.width, element.y - FIXED_BINDING_DISTANCE),
center,
element.angle,
);
}
return rotatePoint(
[element.x + element.width + FIXED_BINDING_DISTANCE, element.y],
return pointRotateRads(
point(element.x + element.width + FIXED_BINDING_DISTANCE, element.y),
center,
element.angle,
);
@ -925,12 +933,12 @@ export const avoidRectangularCorner = (
export const snapToMid = (
element: ExcalidrawBindableElement,
p: Point,
p: GlobalPoint,
tolerance: number = 0.05,
): Point => {
): GlobalPoint => {
const { x, y, width, height, angle } = element;
const center = [x + width / 2 - 0.1, y + height / 2 - 0.1] as Point;
const nonRotated = rotatePoint(p, center, -angle);
const center = point<GlobalPoint>(x + width / 2 - 0.1, y + height / 2 - 0.1);
const nonRotated = pointRotateRads(p, center, -angle as Radians);
// snap-to-center point is adaptive to element size, but we don't want to go
// above and below certain px distance
@ -943,22 +951,30 @@ export const snapToMid = (
nonRotated[1] < center[1] + verticalThrehsold
) {
// LEFT
return rotatePoint([x - FIXED_BINDING_DISTANCE, center[1]], center, angle);
return pointRotateRads(
point(x - FIXED_BINDING_DISTANCE, center[1]),
center,
angle,
);
} else if (
nonRotated[1] <= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThrehsold &&
nonRotated[0] < center[0] + horizontalThrehsold
) {
// TOP
return rotatePoint([center[0], y - FIXED_BINDING_DISTANCE], center, angle);
return pointRotateRads(
point(center[0], y - FIXED_BINDING_DISTANCE),
center,
angle,
);
} else if (
nonRotated[0] >= x + width / 2 &&
nonRotated[1] > center[1] - verticalThrehsold &&
nonRotated[1] < center[1] + verticalThrehsold
) {
// RIGHT
return rotatePoint(
[x + width + FIXED_BINDING_DISTANCE, center[1]],
return pointRotateRads(
point(x + width + FIXED_BINDING_DISTANCE, center[1]),
center,
angle,
);
@ -968,8 +984,8 @@ export const snapToMid = (
nonRotated[0] < center[0] + horizontalThrehsold
) {
// DOWN
return rotatePoint(
[center[0], y + height + FIXED_BINDING_DISTANCE],
return pointRotateRads(
point(center[0], y + height + FIXED_BINDING_DISTANCE),
center,
angle,
);
@ -984,7 +1000,7 @@ const updateBoundPoint = (
binding: PointBinding | null | undefined,
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
): Point | null => {
): LocalPoint | null => {
if (
binding == null ||
// We only need to update the other end if this is a 2 point line element
@ -1006,15 +1022,15 @@ const updateBoundPoint = (
startOrEnd === "startBinding" ? "start" : "end",
elementsMap,
).fixedPoint;
const globalMidPoint = [
const globalMidPoint = point<GlobalPoint>(
bindableElement.x + bindableElement.width / 2,
bindableElement.y + bindableElement.height / 2,
] as Point;
const global = [
);
const global = point<GlobalPoint>(
bindableElement.x + fixedPoint[0] * bindableElement.width,
bindableElement.y + fixedPoint[1] * bindableElement.height,
] as Point;
const rotatedGlobal = rotatePoint(
);
const rotatedGlobal = pointRotateRads(
global,
globalMidPoint,
bindableElement.angle,
@ -1040,7 +1056,7 @@ const updateBoundPoint = (
elementsMap,
);
let newEdgePoint: Point;
let newEdgePoint: GlobalPoint;
// The linear element was not originally pointing inside the bound shape,
// we can point directly at the focus point
@ -1054,7 +1070,7 @@ const updateBoundPoint = (
binding.gap,
elementsMap,
);
if (intersections.length === 0) {
if (!intersections || intersections.length === 0) {
// This should never happen, since focusPoint should always be
// inside the element, but just in case, bail out
newEdgePoint = focusPointAbsolute;
@ -1101,15 +1117,15 @@ export const calculateFixedPointForElbowArrowBinding = (
hoveredElement,
elementsMap,
);
const globalMidPoint = [
const globalMidPoint = point(
bounds[0] + (bounds[2] - bounds[0]) / 2,
bounds[1] + (bounds[3] - bounds[1]) / 2,
] as Point;
const nonRotatedSnappedGlobalPoint = rotatePoint(
);
const nonRotatedSnappedGlobalPoint = pointRotateRads(
snappedPoint,
globalMidPoint,
-hoveredElement.angle,
) as Point;
-hoveredElement.angle as Radians,
);
return {
fixedPoint: normalizeFixedPoint([
@ -1320,8 +1336,9 @@ export const bindingBorderTest = (
const threshold = maxBindingGap(element, element.width, element.height);
const shape = getElementShape(element, elementsMap);
return (
isPointOnShape([x, y], shape, threshold) ||
(fullShape === true && pointInsideBounds([x, y], aabbForElement(element)))
isPointOnShape(point(x, y), shape, threshold) ||
(fullShape === true &&
pointInsideBounds(point(x, y), aabbForElement(element)))
);
};
@ -1339,7 +1356,7 @@ export const maxBindingGap = (
export const distanceToBindableElement = (
element: ExcalidrawBindableElement,
point: Point,
point: GlobalPoint,
elementsMap: ElementsMap,
): number => {
switch (element.type) {
@ -1359,19 +1376,13 @@ export const distanceToBindableElement = (
};
const distanceToRectangle = (
element:
| ExcalidrawRectangleElement
| ExcalidrawTextElement
| ExcalidrawFreeDrawElement
| ExcalidrawImageElement
| ExcalidrawIframeLikeElement
| ExcalidrawFrameLikeElement,
point: Point,
element: ExcalidrawRectanguloidElement,
p: GlobalPoint,
elementsMap: ElementsMap,
): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(
element,
point,
p,
elementsMap,
);
return Math.max(
@ -1382,7 +1393,7 @@ const distanceToRectangle = (
const distanceToDiamond = (
element: ExcalidrawDiamondElement,
point: Point,
point: GlobalPoint,
elementsMap: ElementsMap,
): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(
@ -1396,7 +1407,7 @@ const distanceToDiamond = (
const distanceToEllipse = (
element: ExcalidrawEllipseElement,
point: Point,
point: GlobalPoint,
elementsMap: ElementsMap,
): number => {
const [pointRel, tangent] = ellipseParamsForTest(element, point, elementsMap);
@ -1405,7 +1416,7 @@ const distanceToEllipse = (
const ellipseParamsForTest = (
element: ExcalidrawEllipseElement,
point: Point,
point: GlobalPoint,
elementsMap: ElementsMap,
): [GA.Point, GA.Line] => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(
@ -1467,7 +1478,7 @@ const ellipseParamsForTest = (
// so we only need to perform hit tests for the positive quadrant.
const pointRelativeToElement = (
element: ExcalidrawElement,
pointTuple: Point,
pointTuple: GlobalPoint,
elementsMap: ElementsMap,
): [GA.Point, GA.Point, number, number] => {
const point = GAPoint.from(pointTuple);
@ -1516,9 +1527,9 @@ const coordsCenter = (
const determineFocusDistance = (
element: ExcalidrawBindableElement,
// Point on the line, in absolute coordinates
a: Point,
a: GlobalPoint,
// Another point on the line, in absolute coordinates (closer to element)
b: Point,
b: GlobalPoint,
elementsMap: ElementsMap,
): number => {
const relateToCenter = relativizationToElementCenter(element, elementsMap);
@ -1559,13 +1570,13 @@ const determineFocusPoint = (
// The oriented, relative distance from the center of `element` of the
// returned focusPoint
focus: number,
adjecentPoint: Point,
adjecentPoint: GlobalPoint,
elementsMap: ElementsMap,
): Point => {
): GlobalPoint => {
if (focus === 0) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const center = coordsCenter(x1, y1, x2, y2);
return GAPoint.toTuple(center);
return pointFromPair(GAPoint.toTuple(center));
}
const relateToCenter = relativizationToElementCenter(element, elementsMap);
const adjecentPointRel = GATransform.apply(
@ -1589,7 +1600,9 @@ const determineFocusPoint = (
point = findFocusPointForEllipse(element, focus, adjecentPointRel);
break;
}
return GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point));
return pointFromPair(
GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)),
);
};
// Returns 2 or 0 intersection points between line going through `a` and `b`
@ -1597,15 +1610,15 @@ const determineFocusPoint = (
const intersectElementWithLine = (
element: ExcalidrawBindableElement,
// Point on the line, in absolute coordinates
a: Point,
a: GlobalPoint,
// Another point on the line, in absolute coordinates
b: Point,
b: GlobalPoint,
// If given, the element is inflated by this value
gap: number = 0,
elementsMap: ElementsMap,
): Point[] => {
): GlobalPoint[] | undefined => {
if (isRectangularElement(element)) {
return segmentIntersectRectangleElement(element, [a, b], gap);
return segmentIntersectRectangleElement(element, lineSegment(a, b), gap);
}
const relateToCenter = relativizationToElementCenter(element, elementsMap);
@ -1619,8 +1632,14 @@ const intersectElementWithLine = (
aRel,
gap,
);
return intersections.map((point) =>
GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)),
return intersections.map(
(point) =>
pointFromPair(
GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)),
),
// pointFromArray(
// ,
// ),
);
};
@ -2173,12 +2192,18 @@ export class BindableElement {
export const getGlobalFixedPointForBindableElement = (
fixedPointRatio: [number, number],
element: ExcalidrawBindableElement,
) => {
): GlobalPoint => {
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
return rotatePoint(
[element.x + element.width * fixedX, element.y + element.height * fixedY],
getCenterForElement(element),
return pointRotateRads(
point(
element.x + element.width * fixedX,
element.y + element.height * fixedY,
),
point<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height / 2,
),
element.angle,
);
};
@ -2186,7 +2211,7 @@ export const getGlobalFixedPointForBindableElement = (
const getGlobalFixedPoints = (
arrow: ExcalidrawElbowArrowElement,
elementsMap: ElementsMap,
) => {
): [GlobalPoint, GlobalPoint] => {
const startElement =
arrow.startBinding &&
(elementsMap.get(arrow.startBinding.elementId) as
@ -2197,23 +2222,26 @@ const getGlobalFixedPoints = (
(elementsMap.get(arrow.endBinding.elementId) as
| ExcalidrawBindableElement
| undefined);
const startPoint: Point =
const startPoint =
startElement && arrow.startBinding
? getGlobalFixedPointForBindableElement(
arrow.startBinding.fixedPoint,
startElement as ExcalidrawBindableElement,
)
: [arrow.x + arrow.points[0][0], arrow.y + arrow.points[0][1]];
const endPoint: Point =
: point<GlobalPoint>(
arrow.x + arrow.points[0][0],
arrow.y + arrow.points[0][1],
);
const endPoint =
endElement && arrow.endBinding
? getGlobalFixedPointForBindableElement(
arrow.endBinding.fixedPoint,
endElement as ExcalidrawBindableElement,
)
: [
: point<GlobalPoint>(
arrow.x + arrow.points[arrow.points.length - 1][0],
arrow.y + arrow.points[arrow.points.length - 1][1],
];
);
return [startPoint, endPoint];
};

View file

@ -1,3 +1,5 @@
import type { LocalPoint } from "../../math";
import { point } from "../../math";
import { ROUNDNESS } from "../constants";
import { arrayToMap } from "../utils";
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
@ -123,9 +125,9 @@ describe("getElementBounds", () => {
a: 0.6447741904932416,
}),
points: [
[0, 0] as [number, number],
[67.33984375, 92.48828125] as [number, number],
[-102.7890625, 52.15625] as [number, number],
point<LocalPoint>(0, 0),
point<LocalPoint>(67.33984375, 92.48828125),
point<LocalPoint>(-102.7890625, 52.15625),
],
} as ExcalidrawLinearElement;

View file

@ -7,10 +7,10 @@ import type {
ExcalidrawTextElementWithContainer,
ElementsMap,
} from "./types";
import { distance2d, rotate, rotatePoint } from "../math";
import rough from "roughjs/bin/rough";
import type { Point as RoughPoint } from "roughjs/bin/geometry";
import type { Drawable, Op } from "roughjs/bin/core";
import type { AppState, Point } from "../types";
import type { AppState } from "../types";
import { generateRoughOptions } from "../scene/Shape";
import {
isArrowElement,
@ -22,9 +22,24 @@ import {
import { rescalePoints } from "../points";
import { getBoundTextElement, getContainerElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import type { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache";
import { arrayToMap } from "../utils";
import { arrayToMap, invariant } from "../utils";
import type {
Degrees,
GlobalPoint,
LineSegment,
LocalPoint,
Radians,
} from "../../math";
import {
degreesToRadians,
lineSegment,
point,
pointDistance,
pointFromArray,
pointRotateRads,
} from "../../math";
import type { Mutable } from "../utility-types";
export type RectangleBox = {
x: number;
@ -97,7 +112,11 @@ export class ElementBounds {
if (isFreeDrawElement(element)) {
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
element.points.map(([x, y]) =>
rotate(x, y, cx - element.x, cy - element.y, element.angle),
pointRotateRads(
point(x, y),
point(cx - element.x, cy - element.y),
element.angle,
),
),
);
@ -110,10 +129,26 @@ export class ElementBounds {
} else if (isLinearElement(element)) {
bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap);
} else if (element.type === "diamond") {
const [x11, y11] = rotate(cx, y1, cx, cy, element.angle);
const [x12, y12] = rotate(cx, y2, cx, cy, element.angle);
const [x22, y22] = rotate(x1, cy, cx, cy, element.angle);
const [x21, y21] = rotate(x2, cy, cx, cy, element.angle);
const [x11, y11] = pointRotateRads(
point(cx, y1),
point(cx, cy),
element.angle,
);
const [x12, y12] = pointRotateRads(
point(cx, y2),
point(cx, cy),
element.angle,
);
const [x22, y22] = pointRotateRads(
point(x1, cy),
point(cx, cy),
element.angle,
);
const [x21, y21] = pointRotateRads(
point(x2, cy),
point(cx, cy),
element.angle,
);
const minX = Math.min(x11, x12, x22, x21);
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
@ -128,10 +163,26 @@ export class ElementBounds {
const hh = Math.hypot(h * cos, w * sin);
bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
} else {
const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
const [x11, y11] = pointRotateRads(
point(x1, y1),
point(cx, cy),
element.angle,
);
const [x12, y12] = pointRotateRads(
point(x1, y2),
point(cx, cy),
element.angle,
);
const [x22, y22] = pointRotateRads(
point(x2, y2),
point(cx, cy),
element.angle,
);
const [x21, y21] = pointRotateRads(
point(x2, y1),
point(cx, cy),
element.angle,
);
const minX = Math.min(x11, x12, x22, x21);
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
@ -165,18 +216,18 @@ export const getElementAbsoluteCoords = (
? getContainerElement(element, elementsMap)
: null;
if (isArrowElement(container)) {
const coords = LinearElementEditor.getBoundTextElementPosition(
const { x, y } = LinearElementEditor.getBoundTextElementPosition(
container,
element as ExcalidrawTextElementWithContainer,
elementsMap,
);
return [
coords.x,
coords.y,
coords.x + element.width,
coords.y + element.height,
coords.x + element.width / 2,
coords.y + element.height / 2,
x,
y,
x + element.width,
y + element.height,
x + element.width / 2,
y + element.height / 2,
];
}
}
@ -198,38 +249,40 @@ export const getElementAbsoluteCoords = (
export const getElementLineSegments = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
): [Point, Point][] => {
): LineSegment<GlobalPoint>[] => {
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
);
const center: Point = [cx, cy];
const center: GlobalPoint = point(cx, cy);
if (isLinearElement(element) || isFreeDrawElement(element)) {
const segments: [Point, Point][] = [];
const segments: LineSegment<GlobalPoint>[] = [];
let i = 0;
while (i < element.points.length - 1) {
segments.push([
rotatePoint(
[
element.points[i][0] + element.x,
element.points[i][1] + element.y,
] as Point,
center,
element.angle,
segments.push(
lineSegment(
pointRotateRads(
point(
element.points[i][0] + element.x,
element.points[i][1] + element.y,
),
center,
element.angle,
),
pointRotateRads(
point(
element.points[i + 1][0] + element.x,
element.points[i + 1][1] + element.y,
),
center,
element.angle,
),
),
rotatePoint(
[
element.points[i + 1][0] + element.x,
element.points[i + 1][1] + element.y,
] as Point,
center,
element.angle,
),
]);
);
i++;
}
@ -246,40 +299,40 @@ export const getElementLineSegments = (
[cx, y2],
[x1, cy],
[x2, cy],
] as Point[]
).map((point) => rotatePoint(point, center, element.angle));
] as GlobalPoint[]
).map((point) => pointRotateRads(point, center, element.angle));
if (element.type === "diamond") {
return [
[n, w],
[n, e],
[s, w],
[s, e],
lineSegment(n, w),
lineSegment(n, e),
lineSegment(s, w),
lineSegment(s, e),
];
}
if (element.type === "ellipse") {
return [
[n, w],
[n, e],
[s, w],
[s, e],
[n, w],
[n, e],
[s, w],
[s, e],
lineSegment(n, w),
lineSegment(n, e),
lineSegment(s, w),
lineSegment(s, e),
lineSegment(n, w),
lineSegment(n, e),
lineSegment(s, w),
lineSegment(s, e),
];
}
return [
[nw, ne],
[sw, se],
[nw, sw],
[ne, se],
[nw, e],
[sw, e],
[ne, w],
[se, w],
lineSegment(nw, ne),
lineSegment(sw, se),
lineSegment(nw, sw),
lineSegment(ne, se),
lineSegment(nw, e),
lineSegment(sw, e),
lineSegment(ne, w),
lineSegment(se, w),
];
};
@ -386,10 +439,10 @@ const solveQuadratic = (
};
const getCubicBezierCurveBound = (
p0: Point,
p1: Point,
p2: Point,
p3: Point,
p0: GlobalPoint,
p1: GlobalPoint,
p2: GlobalPoint,
p3: GlobalPoint,
): Bounds => {
const solX = solveQuadratic(p0[0], p1[0], p2[0], p3[0]);
const solY = solveQuadratic(p0[1], p1[1], p2[1], p3[1]);
@ -415,9 +468,9 @@ const getCubicBezierCurveBound = (
export const getMinMaxXYFromCurvePathOps = (
ops: Op[],
transformXY?: (x: number, y: number) => [number, number],
transformXY?: (p: GlobalPoint) => GlobalPoint,
): Bounds => {
let currentP: Point = [0, 0];
let currentP: GlobalPoint = point(0, 0);
const { minX, minY, maxX, maxY } = ops.reduce(
(limits, { op, data }) => {
@ -425,19 +478,21 @@ export const getMinMaxXYFromCurvePathOps = (
// move, bcurveTo, lineTo, and curveTo
if (op === "move") {
// change starting point
currentP = data as unknown as Point;
const p: GlobalPoint | undefined = pointFromArray(data);
invariant(p != null, "Op data is not a point");
currentP = p;
// move operation does not draw anything; so, it always
// returns false
} else if (op === "bcurveTo") {
const _p1 = [data[0], data[1]] as Point;
const _p2 = [data[2], data[3]] as Point;
const _p3 = [data[4], data[5]] as Point;
const _p1 = point<GlobalPoint>(data[0], data[1]);
const _p2 = point<GlobalPoint>(data[2], data[3]);
const _p3 = point<GlobalPoint>(data[4], data[5]);
const p1 = transformXY ? transformXY(..._p1) : _p1;
const p2 = transformXY ? transformXY(..._p2) : _p2;
const p3 = transformXY ? transformXY(..._p3) : _p3;
const p1 = transformXY ? transformXY(_p1) : _p1;
const p2 = transformXY ? transformXY(_p2) : _p2;
const p3 = transformXY ? transformXY(_p3) : _p3;
const p0 = transformXY ? transformXY(...currentP) : currentP;
const p0 = transformXY ? transformXY(currentP) : currentP;
currentP = _p3;
const [minX, minY, maxX, maxY] = getCubicBezierCurveBound(
@ -507,14 +562,14 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
};
/** @returns number in degrees */
export const getArrowheadAngle = (arrowhead: Arrowhead): number => {
export const getArrowheadAngle = (arrowhead: Arrowhead): Degrees => {
switch (arrowhead) {
case "bar":
return 90;
return 90 as Degrees;
case "arrow":
return 20;
return 20 as Degrees;
default:
return 25;
return 25 as Degrees;
}
};
@ -533,19 +588,24 @@ export const getArrowheadPoints = (
const index = position === "start" ? 1 : ops.length - 1;
const data = ops[index].data;
const p3 = [data[4], data[5]] as Point;
const p2 = [data[2], data[3]] as Point;
const p1 = [data[0], data[1]] as Point;
invariant(data.length === 6, "Op data length is not 6");
const p3 = point(data[4], data[5]);
const p2 = point(data[2], data[3]);
const p1 = point(data[0], data[1]);
// We need to find p0 of the bezier curve.
// It is typically the last point of the previous
// curve; it can also be the position of moveTo operation.
const prevOp = ops[index - 1];
let p0: Point = [0, 0];
let p0 = point(0, 0);
if (prevOp.op === "move") {
p0 = prevOp.data as unknown as Point;
const p = pointFromArray(prevOp.data);
invariant(p != null, "Op data is not a point");
p0 = p;
} else if (prevOp.op === "bcurveTo") {
p0 = [prevOp.data[4], prevOp.data[5]];
p0 = point(prevOp.data[4], prevOp.data[5]);
}
// B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
@ -610,8 +670,16 @@ export const getArrowheadPoints = (
const angle = getArrowheadAngle(arrowhead);
// Return points
const [x3, y3] = rotate(xs, ys, x2, y2, (-angle * Math.PI) / 180);
const [x4, y4] = rotate(xs, ys, x2, y2, (angle * Math.PI) / 180);
const [x3, y3] = pointRotateRads(
point(xs, ys),
point(x2, y2),
((-angle * Math.PI) / 180) as Radians,
);
const [x4, y4] = pointRotateRads(
point(xs, ys),
point(x2, y2),
degreesToRadians(angle),
);
if (arrowhead === "diamond" || arrowhead === "diamond_outline") {
// point opposite to the arrowhead point
@ -621,12 +689,10 @@ export const getArrowheadPoints = (
if (position === "start") {
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
[ox, oy] = rotate(
x2 + minSize * 2,
y2,
x2,
y2,
Math.atan2(py - y2, px - x2),
[ox, oy] = pointRotateRads(
point(x2 + minSize * 2, y2),
point(x2, y2),
Math.atan2(py - y2, px - x2) as Radians,
);
} else {
const [px, py] =
@ -634,12 +700,10 @@ export const getArrowheadPoints = (
? element.points[element.points.length - 2]
: [0, 0];
[ox, oy] = rotate(
x2 - minSize * 2,
y2,
x2,
y2,
Math.atan2(y2 - py, x2 - px),
[ox, oy] = pointRotateRads(
point(x2 - minSize * 2, y2),
point(x2, y2),
Math.atan2(y2 - py, x2 - px) as Radians,
);
}
@ -665,7 +729,10 @@ const generateLinearElementShape = (
return "linearPath";
})();
return generator[method](element.points as Mutable<Point>[], options);
return generator[method](
element.points as Mutable<LocalPoint>[] as RoughPoint[],
options,
);
};
const getLinearElementRotatedBounds = (
@ -678,11 +745,9 @@ const getLinearElementRotatedBounds = (
if (element.points.length < 2) {
const [pointX, pointY] = element.points[0];
const [x, y] = rotate(
element.x + pointX,
element.y + pointY,
cx,
cy,
const [x, y] = pointRotateRads(
point(element.x + pointX, element.y + pointY),
point(cx, cy),
element.angle,
);
@ -708,8 +773,12 @@ const getLinearElementRotatedBounds = (
const cachedShape = ShapeCache.get(element)?.[0];
const shape = cachedShape ?? generateLinearElementShape(element);
const ops = getCurvePathOps(shape);
const transformXY = (x: number, y: number) =>
rotate(element.x + x, element.y + y, cx, cy, element.angle);
const transformXY = ([x, y]: GlobalPoint) =>
pointRotateRads<GlobalPoint>(
point(element.x + x, element.y + y),
point(cx, cy),
element.angle,
);
const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
let coords: Bounds = [res[0], res[1], res[2], res[3]];
if (boundTextElement) {
@ -861,7 +930,10 @@ export const getClosestElementBounds = (
const elementsMap = arrayToMap(elements);
elements.forEach((element) => {
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
const distance = distance2d((x1 + x2) / 2, (y1 + y2) / 2, from.x, from.y);
const distance = pointDistance(
point((x1 + x2) / 2, (y1 + y2) / 2),
point(from.x, from.y),
);
if (distance < minDistance) {
minDistance = distance;
@ -916,3 +988,9 @@ export const getVisibleSceneBounds = ({
-scrollY + height / zoom.value,
];
};
export const getCenterForBounds = (bounds: Bounds): GlobalPoint =>
point(
bounds[0] + (bounds[2] - bounds[0]) / 2,
bounds[1] + (bounds[3] - bounds[1]) / 2,
);

View file

@ -1,14 +1,11 @@
import { isPathALoop, isPointWithinBounds } from "../math";
import type {
ElementsMap,
ExcalidrawElement,
ExcalidrawRectangleElement,
} from "./types";
import { getElementBounds } from "./bounds";
import type { FrameNameBounds } from "../types";
import type { Polygon, GeometricShape } from "../../utils/geometry/shape";
import type { GeometricShape } from "../../utils/geometry/shape";
import { getPolygonShape } from "../../utils/geometry/shape";
import { isPointInShape, isPointOnShape } from "../../utils/collision";
import { isTransparent } from "../utils";
@ -18,7 +15,9 @@ import {
isImageElement,
isTextElement,
} from "./typeChecks";
import { getBoundTextShape } from "../shapes";
import { getBoundTextShape, isPathALoop } from "../shapes";
import type { GlobalPoint, LocalPoint, Polygon } from "../../math";
import { isPointWithinBounds, point } from "../../math";
export const shouldTestInside = (element: ExcalidrawElement) => {
if (element.type === "arrow") {
@ -42,35 +41,36 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
return isDraggableFromInside || isImageElement(element);
};
export type HitTestArgs = {
export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = {
x: number;
y: number;
element: ExcalidrawElement;
shape: GeometricShape;
shape: GeometricShape<Point>;
threshold?: number;
frameNameBound?: FrameNameBounds | null;
};
export const hitElementItself = ({
export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
x,
y,
element,
shape,
threshold = 10,
frameNameBound = null,
}: HitTestArgs) => {
}: HitTestArgs<Point>) => {
let hit = shouldTestInside(element)
? // Since `inShape` tests STRICTLY againt the insides of a shape
// we would need `onShape` as well to include the "borders"
isPointInShape([x, y], shape) || isPointOnShape([x, y], shape, threshold)
: isPointOnShape([x, y], shape, threshold);
isPointInShape(point(x, y), shape) ||
isPointOnShape(point(x, y), shape, threshold)
: isPointOnShape(point(x, y), shape, threshold);
// hit test against a frame's name
if (!hit && frameNameBound) {
hit = isPointInShape([x, y], {
hit = isPointInShape(point(x, y), {
type: "polygon",
data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement)
.data as Polygon,
.data as Polygon<Point>,
});
}
@ -89,11 +89,13 @@ export const hitElementBoundingBox = (
y1 -= tolerance;
x2 += tolerance;
y2 += tolerance;
return isPointWithinBounds([x1, y1], [x, y], [x2, y2]);
return isPointWithinBounds(point(x1, y1), point(x, y), point(x2, y2));
};
export const hitElementBoundingBoxOnly = (
hitArgs: HitTestArgs,
export const hitElementBoundingBoxOnly = <
Point extends GlobalPoint | LocalPoint,
>(
hitArgs: HitTestArgs<Point>,
elementsMap: ElementsMap,
) => {
return (
@ -108,10 +110,10 @@ export const hitElementBoundingBoxOnly = (
);
};
export const hitElementBoundText = (
export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
x: number,
y: number,
textShape: GeometricShape | null,
textShape: GeometricShape<Point> | null,
): boolean => {
return !!textShape && isPointInShape([x, y], textShape);
return !!textShape && isPointInShape(point(x, y), textShape);
};

View file

@ -11,7 +11,6 @@ import type {
PointerDownState,
} from "../types";
import { getBoundTextElement, getMinTextElementWidth } from "./textElement";
import { getGridPoint } from "../math";
import type Scene from "../scene/Scene";
import {
isArrowElement,
@ -21,6 +20,7 @@ import {
} from "./typeChecks";
import { getFontString } from "../utils";
import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
import { getGridPoint } from "../snapping";
export const dragSelectedElements = (
pointerDownState: PointerDownState,

View file

@ -10,7 +10,6 @@ import {
import { bindLinearElement } from "./binding";
import { LinearElementEditor } from "./linearElementEditor";
import { newArrowElement, newElement } from "./newElement";
import { aabbForElement } from "../math";
import type {
ElementsMap,
ExcalidrawBindableElement,
@ -20,7 +19,7 @@ import type {
OrderedExcalidrawElement,
} from "./types";
import { KEYS } from "../keys";
import type { AppState, PendingExcalidrawElements, Point } from "../types";
import type { AppState, PendingExcalidrawElements } from "../types";
import { mutateElement } from "./mutateElement";
import { elementOverlapsWithFrame, elementsAreInFrameBounds } from "../frame";
import {
@ -30,6 +29,8 @@ import {
isFlowchartNodeElement,
} from "./typeChecks";
import { invariant } from "../utils";
import { point, type LocalPoint } from "../../math";
import { aabbForElement } from "../shapes";
type LinkDirection = "up" | "right" | "down" | "left";
@ -81,13 +82,14 @@ const getNodeRelatives = (
"not an ExcalidrawBindableElement",
);
const edgePoint: Point =
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0];
const edgePoint = (
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
) as Readonly<LocalPoint>;
const heading = headingForPointFromElement(node, aabbForElement(node), [
edgePoint[0] + el.x,
edgePoint[1] + el.y,
]);
] as Readonly<LocalPoint>);
acc.push({
relative,
@ -419,10 +421,7 @@ const createBindingArrow = (
strokeColor: appState.currentItemStrokeColor,
strokeStyle: appState.currentItemStrokeStyle,
strokeWidth: appState.currentItemStrokeWidth,
points: [
[0, 0],
[endX, endY],
],
points: [point(0, 0), point(endX, endY)],
elbowed: true,
});

View file

@ -1,12 +1,18 @@
import { lineAngle } from "../../utils/geometry/geometry";
import type { Point, Vector } from "../../utils/geometry/shape";
import type {
LocalPoint,
GlobalPoint,
Triangle,
Vector,
Radians,
} from "../../math";
import {
getCenterForBounds,
PointInTriangle,
rotatePoint,
scalePointFromOrigin,
} from "../math";
import type { Bounds } from "./bounds";
point,
pointRotateRads,
pointScaleFromOrigin,
radiansToDegrees,
triangleIncludesPoint,
} from "../../math";
import { getCenterForBounds, type Bounds } from "./bounds";
import type { ExcalidrawBindableElement } from "./types";
export const HEADING_RIGHT = [1, 0] as Heading;
@ -15,8 +21,13 @@ export const HEADING_LEFT = [-1, 0] as Heading;
export const HEADING_UP = [0, -1] as Heading;
export type Heading = [1, 0] | [0, 1] | [-1, 0] | [0, -1];
export const headingForDiamond = (a: Point, b: Point) => {
const angle = lineAngle([a, b]);
export const headingForDiamond = <Point extends GlobalPoint | LocalPoint>(
a: Point,
b: Point,
) => {
const angle = radiansToDegrees(
Math.atan2(b[1] - a[1], b[0] - a[0]) as Radians,
);
if (angle >= 315 || angle < 45) {
return HEADING_UP;
} else if (angle >= 45 && angle < 135) {
@ -47,56 +58,58 @@ export const compareHeading = (a: Heading, b: Heading) =>
// Gets the heading for the point by creating a bounding box around the rotated
// close fitting bounding box, then creating 4 search cones around the center of
// the external bbox.
export const headingForPointFromElement = (
export const headingForPointFromElement = <
Point extends GlobalPoint | LocalPoint,
>(
element: Readonly<ExcalidrawBindableElement>,
aabb: Readonly<Bounds>,
point: Readonly<Point>,
p: Readonly<LocalPoint | GlobalPoint>,
): Heading => {
const SEARCH_CONE_MULTIPLIER = 2;
const midPoint = getCenterForBounds(aabb);
if (element.type === "diamond") {
if (point[0] < element.x) {
if (p[0] < element.x) {
return HEADING_LEFT;
} else if (point[1] < element.y) {
} else if (p[1] < element.y) {
return HEADING_UP;
} else if (point[0] > element.x + element.width) {
} else if (p[0] > element.x + element.width) {
return HEADING_RIGHT;
} else if (point[1] > element.y + element.height) {
} else if (p[1] > element.y + element.height) {
return HEADING_DOWN;
}
const top = rotatePoint(
scalePointFromOrigin(
[element.x + element.width / 2, element.y],
const top = pointRotateRads(
pointScaleFromOrigin(
point(element.x + element.width / 2, element.y),
midPoint,
SEARCH_CONE_MULTIPLIER,
),
midPoint,
element.angle,
);
const right = rotatePoint(
scalePointFromOrigin(
[element.x + element.width, element.y + element.height / 2],
const right = pointRotateRads(
pointScaleFromOrigin(
point(element.x + element.width, element.y + element.height / 2),
midPoint,
SEARCH_CONE_MULTIPLIER,
),
midPoint,
element.angle,
);
const bottom = rotatePoint(
scalePointFromOrigin(
[element.x + element.width / 2, element.y + element.height],
const bottom = pointRotateRads(
pointScaleFromOrigin(
point(element.x + element.width / 2, element.y + element.height),
midPoint,
SEARCH_CONE_MULTIPLIER,
),
midPoint,
element.angle,
);
const left = rotatePoint(
scalePointFromOrigin(
[element.x, element.y + element.height / 2],
const left = pointRotateRads(
pointScaleFromOrigin(
point(element.x, element.y + element.height / 2),
midPoint,
SEARCH_CONE_MULTIPLIER,
),
@ -104,43 +117,62 @@ export const headingForPointFromElement = (
element.angle,
);
if (PointInTriangle(point, top, right, midPoint)) {
if (triangleIncludesPoint([top, right, midPoint] as Triangle<Point>, p)) {
return headingForDiamond(top, right);
} else if (PointInTriangle(point, right, bottom, midPoint)) {
} else if (
triangleIncludesPoint([right, bottom, midPoint] as Triangle<Point>, p)
) {
return headingForDiamond(right, bottom);
} else if (PointInTriangle(point, bottom, left, midPoint)) {
} else if (
triangleIncludesPoint([bottom, left, midPoint] as Triangle<Point>, p)
) {
return headingForDiamond(bottom, left);
}
return headingForDiamond(left, top);
}
const topLeft = scalePointFromOrigin(
[aabb[0], aabb[1]],
const topLeft = pointScaleFromOrigin(
point(aabb[0], aabb[1]),
midPoint,
SEARCH_CONE_MULTIPLIER,
);
const topRight = scalePointFromOrigin(
[aabb[2], aabb[1]],
) as Point;
const topRight = pointScaleFromOrigin(
point(aabb[2], aabb[1]),
midPoint,
SEARCH_CONE_MULTIPLIER,
);
const bottomLeft = scalePointFromOrigin(
[aabb[0], aabb[3]],
) as Point;
const bottomLeft = pointScaleFromOrigin(
point(aabb[0], aabb[3]),
midPoint,
SEARCH_CONE_MULTIPLIER,
);
const bottomRight = scalePointFromOrigin(
[aabb[2], aabb[3]],
) as Point;
const bottomRight = pointScaleFromOrigin(
point(aabb[2], aabb[3]),
midPoint,
SEARCH_CONE_MULTIPLIER,
);
) as Point;
return PointInTriangle(point, topLeft, topRight, midPoint)
return triangleIncludesPoint(
[topLeft, topRight, midPoint] as Triangle<Point>,
p,
)
? HEADING_UP
: PointInTriangle(point, topRight, bottomRight, midPoint)
: triangleIncludesPoint(
[topRight, bottomRight, midPoint] as Triangle<Point>,
p,
)
? HEADING_RIGHT
: PointInTriangle(point, bottomRight, bottomLeft, midPoint)
: triangleIncludesPoint(
[bottomRight, bottomLeft, midPoint] as Triangle<Point>,
p,
)
? HEADING_DOWN
: HEADING_LEFT;
};
export const flipHeading = (h: Heading): Heading =>
[
h[0] === 0 ? 0 : h[0] > 0 ? -1 : 1,
h[1] === 0 ? 0 : h[1] > 0 ? -1 : 1,
] as Heading;

View file

@ -11,19 +11,6 @@ import type {
FixedPointBinding,
SceneElementsMap,
} from "./types";
import {
distance2d,
rotate,
isPathALoop,
getGridPoint,
rotatePoint,
centerPoint,
getControlPointsForBezierCurve,
getBezierXY,
getBezierCurveLength,
mapIntervalToBezierT,
arePointsEqual,
} from "../math";
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
import type { Bounds } from "./bounds";
import {
@ -32,7 +19,6 @@ import {
getMinMaxXYFromCurvePathOps,
} from "./bounds";
import type {
Point,
AppState,
PointerCoords,
InteractiveCanvasAppState,
@ -46,7 +32,7 @@ import {
getHoveredElementForBinding,
isBindingEnabled,
} from "./binding";
import { toBrandedType, tupleToCoors } from "../utils";
import { invariant, toBrandedType, tupleToCoors } from "../utils";
import {
isBindingElement,
isElbowArrow,
@ -60,10 +46,29 @@ import { ShapeCache } from "../scene/ShapeCache";
import type { Store } from "../store";
import { mutateElbowArrow } from "./routing";
import type Scene from "../scene/Scene";
import type { Radians } from "../../math";
import {
pointCenter,
point,
pointRotateRads,
pointsEqual,
vector,
type GlobalPoint,
type LocalPoint,
pointDistance,
} from "../../math";
import {
getBezierCurveLength,
getBezierXY,
getControlPointsForBezierCurve,
isPathALoop,
mapIntervalToBezierT,
} from "../shapes";
import { getGridPoint } from "../snapping";
const editorMidPointsCache: {
version: number | null;
points: (Point | null)[];
points: (GlobalPoint | null)[];
zoom: number | null;
} = { version: null, points: [], zoom: null };
export class LinearElementEditor {
@ -80,7 +85,7 @@ export class LinearElementEditor {
lastClickedIsEndPoint: boolean;
origin: Readonly<{ x: number; y: number }> | null;
segmentMidpoint: {
value: Point | null;
value: GlobalPoint | null;
index: number | null;
added: boolean;
};
@ -88,7 +93,7 @@ export class LinearElementEditor {
/** whether you're dragging a point */
public readonly isDragging: boolean;
public readonly lastUncommittedPoint: Point | null;
public readonly lastUncommittedPoint: LocalPoint | null;
public readonly pointerOffset: Readonly<{ x: number; y: number }>;
public readonly startBindingElement:
| ExcalidrawBindableElement
@ -96,13 +101,13 @@ export class LinearElementEditor {
| "keep";
public readonly endBindingElement: ExcalidrawBindableElement | null | "keep";
public readonly hoverPointIndex: number;
public readonly segmentMidPointHoveredCoords: Point | null;
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
constructor(element: NonDeleted<ExcalidrawLinearElement>) {
this.elementId = element.id as string & {
_brand: "excalidrawLinearElementId";
};
if (!arePointsEqual(element.points[0], [0, 0])) {
if (!pointsEqual(element.points[0], point(0, 0))) {
console.error("Linear element is not normalized", Error().stack);
}
@ -280,7 +285,7 @@ export class LinearElementEditor {
element,
elementsMap,
referencePoint,
[scenePointerX, scenePointerY],
point(scenePointerX, scenePointerY),
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
);
@ -289,7 +294,10 @@ export class LinearElementEditor {
[
{
index: selectedIndex,
point: [width + referencePoint[0], height + referencePoint[1]],
point: point(
width + referencePoint[0],
height + referencePoint[1],
),
isDragging: selectedIndex === lastClickedPoint,
},
],
@ -310,7 +318,7 @@ export class LinearElementEditor {
LinearElementEditor.movePoints(
element,
selectedPointsIndices.map((pointIndex) => {
const newPointPosition =
const newPointPosition: LocalPoint =
pointIndex === lastClickedPoint
? LinearElementEditor.createPointAt(
element,
@ -319,10 +327,10 @@ export class LinearElementEditor {
scenePointerY - linearElementEditor.pointerOffset.y,
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
)
: ([
: point(
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
] as const);
);
return {
index: pointIndex,
point: newPointPosition,
@ -515,7 +523,7 @@ export class LinearElementEditor {
);
let index = 0;
const midpoints: (Point | null)[] = [];
const midpoints: (GlobalPoint | null)[] = [];
while (index < points.length - 1) {
if (
LinearElementEditor.isSegmentTooShort(
@ -549,7 +557,7 @@ export class LinearElementEditor {
scenePointer: { x: number; y: number },
appState: AppState,
elementsMap: ElementsMap,
) => {
): GlobalPoint | null => {
const { elementId } = linearElementEditor;
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) {
@ -579,11 +587,12 @@ export class LinearElementEditor {
const existingSegmentMidpointHitCoords =
linearElementEditor.segmentMidPointHoveredCoords;
if (existingSegmentMidpointHitCoords) {
const distance = distance2d(
existingSegmentMidpointHitCoords[0],
existingSegmentMidpointHitCoords[1],
scenePointer.x,
scenePointer.y,
const distance = pointDistance(
point(
existingSegmentMidpointHitCoords[0],
existingSegmentMidpointHitCoords[1],
),
point(scenePointer.x, scenePointer.y),
);
if (distance <= threshold) {
return existingSegmentMidpointHitCoords;
@ -594,11 +603,9 @@ export class LinearElementEditor {
LinearElementEditor.getEditorMidPoints(element, elementsMap, appState);
while (index < midPoints.length) {
if (midPoints[index] !== null) {
const distance = distance2d(
midPoints[index]![0],
midPoints[index]![1],
scenePointer.x,
scenePointer.y,
const distance = pointDistance(
point(midPoints[index]![0], midPoints[index]![1]),
point(scenePointer.x, scenePointer.y),
);
if (distance <= threshold) {
return midPoints[index];
@ -612,15 +619,13 @@ export class LinearElementEditor {
static isSegmentTooShort(
element: NonDeleted<ExcalidrawLinearElement>,
startPoint: Point,
endPoint: Point,
startPoint: GlobalPoint | LocalPoint,
endPoint: GlobalPoint | LocalPoint,
zoom: AppState["zoom"],
) {
let distance = distance2d(
startPoint[0],
startPoint[1],
endPoint[0],
endPoint[1],
let distance = pointDistance(
point(startPoint[0], startPoint[1]),
point(endPoint[0], endPoint[1]),
);
if (element.points.length > 2 && element.roundness) {
distance = getBezierCurveLength(element, endPoint);
@ -631,12 +636,12 @@ export class LinearElementEditor {
static getSegmentMidPoint(
element: NonDeleted<ExcalidrawLinearElement>,
startPoint: Point,
endPoint: Point,
startPoint: GlobalPoint,
endPoint: GlobalPoint,
endPointIndex: number,
elementsMap: ElementsMap,
) {
let segmentMidPoint = centerPoint(startPoint, endPoint);
): GlobalPoint {
let segmentMidPoint = pointCenter(startPoint, endPoint);
if (element.points.length > 2 && element.roundness) {
const controlPoints = getControlPointsForBezierCurve(
element,
@ -649,16 +654,15 @@ export class LinearElementEditor {
0.5,
);
const [tx, ty] = getBezierXY(
controlPoints[0],
controlPoints[1],
controlPoints[2],
controlPoints[3],
t,
);
segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates(
element,
[tx, ty],
getBezierXY(
controlPoints[0],
controlPoints[1],
controlPoints[2],
controlPoints[3],
t,
),
elementsMap,
);
}
@ -670,7 +674,7 @@ export class LinearElementEditor {
static getSegmentMidPointIndex(
linearElementEditor: LinearElementEditor,
appState: AppState,
midPoint: Point,
midPoint: GlobalPoint,
elementsMap: ElementsMap,
) {
const element = LinearElementEditor.getElement(
@ -822,11 +826,12 @@ export class LinearElementEditor {
const cy = (y1 + y2) / 2;
const targetPoint =
clickedPointIndex > -1 &&
rotate(
element.x + element.points[clickedPointIndex][0],
element.y + element.points[clickedPointIndex][1],
cx,
cy,
pointRotateRads(
point(
element.x + element.points[clickedPointIndex][0],
element.y + element.points[clickedPointIndex][1],
),
point(cx, cy),
element.angle,
);
@ -865,14 +870,17 @@ export class LinearElementEditor {
return ret;
}
static arePointsEqual(point1: Point | null, point2: Point | null) {
static arePointsEqual<Point extends LocalPoint | GlobalPoint>(
point1: Point | null,
point2: Point | null,
) {
if (!point1 && !point2) {
return true;
}
if (!point1 || !point2) {
return false;
}
return arePointsEqual(point1, point2);
return pointsEqual(point1, point2);
}
static handlePointerMove(
@ -909,7 +917,7 @@ export class LinearElementEditor {
};
}
let newPoint: Point;
let newPoint: LocalPoint;
if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) {
const lastCommittedPoint = points[points.length - 2];
@ -918,14 +926,14 @@ export class LinearElementEditor {
element,
elementsMap,
lastCommittedPoint,
[scenePointerX, scenePointerY],
point(scenePointerX, scenePointerY),
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
);
newPoint = [
newPoint = point(
width + lastCommittedPoint[0],
height + lastCommittedPoint[1],
];
);
} else {
newPoint = LinearElementEditor.createPointAt(
element,
@ -965,30 +973,36 @@ export class LinearElementEditor {
/** scene coords */
static getPointGlobalCoordinates(
element: NonDeleted<ExcalidrawLinearElement>,
point: Point,
p: LocalPoint,
elementsMap: ElementsMap,
) {
): GlobalPoint {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
let { x, y } = element;
[x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle);
return [x, y] as const;
const { x, y } = element;
return pointRotateRads(
point(x + p[0], y + p[1]),
point(cx, cy),
element.angle,
);
}
/** scene coords */
static getPointsGlobalCoordinates(
element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap,
): Point[] {
): GlobalPoint[] {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
return element.points.map((point) => {
let { x, y } = element;
[x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle);
return [x, y] as const;
return element.points.map((p) => {
const { x, y } = element;
return pointRotateRads(
point(x + p[0], y + p[1]),
point(cx, cy),
element.angle,
);
});
}
@ -997,7 +1011,7 @@ export class LinearElementEditor {
indexMaybeFromEnd: number, // -1 for last element
elementsMap: ElementsMap,
): Point {
): GlobalPoint {
const index =
indexMaybeFromEnd < 0
? element.points.length + indexMaybeFromEnd
@ -1005,35 +1019,36 @@ export class LinearElementEditor {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const point = element.points[index];
const p = element.points[index];
const { x, y } = element;
return point
? rotate(x + point[0], y + point[1], cx, cy, element.angle)
: rotate(x, y, cx, cy, element.angle);
return p
? pointRotateRads(point(x + p[0], y + p[1]), point(cx, cy), element.angle)
: pointRotateRads(point(x, y), point(cx, cy), element.angle);
}
static pointFromAbsoluteCoords(
element: NonDeleted<ExcalidrawLinearElement>,
absoluteCoords: Point,
absoluteCoords: GlobalPoint,
elementsMap: ElementsMap,
): Point {
): LocalPoint {
if (isElbowArrow(element)) {
// No rotation for elbow arrows
return [absoluteCoords[0] - element.x, absoluteCoords[1] - element.y];
return point(
absoluteCoords[0] - element.x,
absoluteCoords[1] - element.y,
);
}
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const [x, y] = rotate(
absoluteCoords[0],
absoluteCoords[1],
cx,
cy,
-element.angle,
const [x, y] = pointRotateRads(
point(absoluteCoords[0], absoluteCoords[1]),
point(cx, cy),
-element.angle as Radians,
);
return [x - element.x, y - element.y];
return point(x - element.x, y - element.y);
}
static getPointIndexUnderCursor(
@ -1052,9 +1067,9 @@ export class LinearElementEditor {
// points on the left, thus should take precedence when clicking, if they
// overlap
while (--idx > -1) {
const point = pointHandles[idx];
const p = pointHandles[idx];
if (
distance2d(x, y, point[0], point[1]) * zoom.value <
pointDistance(point(x, y), point(p[0], p[1])) * zoom.value <
// +1px to account for outline stroke
LinearElementEditor.POINT_HANDLE_SIZE + 1
) {
@ -1070,20 +1085,18 @@ export class LinearElementEditor {
scenePointerX: number,
scenePointerY: number,
gridSize: NullableGridSize,
): Point {
): LocalPoint {
const pointerOnGrid = getGridPoint(scenePointerX, scenePointerY, gridSize);
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const [rotatedX, rotatedY] = rotate(
pointerOnGrid[0],
pointerOnGrid[1],
cx,
cy,
-element.angle,
const [rotatedX, rotatedY] = pointRotateRads(
point(pointerOnGrid[0], pointerOnGrid[1]),
point(cx, cy),
-element.angle as Radians,
);
return [rotatedX - element.x, rotatedY - element.y];
return point(rotatedX - element.x, rotatedY - element.y);
}
/**
@ -1091,15 +1104,19 @@ export class LinearElementEditor {
* expected in various parts of the codebase. Also returns new x/y to account
* for the potential normalization.
*/
static getNormalizedPoints(element: ExcalidrawLinearElement) {
static getNormalizedPoints(element: ExcalidrawLinearElement): {
points: LocalPoint[];
x: number;
y: number;
} {
const { points } = element;
const offsetX = points[0][0];
const offsetY = points[0][1];
return {
points: points.map((point) => {
return [point[0] - offsetX, point[1] - offsetY] as const;
points: points.map((p) => {
return point(p[0] - offsetX, p[1] - offsetY);
}),
x: element.x + offsetX,
y: element.y + offsetY,
@ -1116,17 +1133,23 @@ export class LinearElementEditor {
static duplicateSelectedPoints(
appState: AppState,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
) {
if (!appState.editingLinearElement) {
return false;
}
): AppState {
invariant(
appState.editingLinearElement,
"Not currently editing a linear element",
);
const { selectedPointsIndices, elementId } = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element || selectedPointsIndices === null) {
return false;
}
invariant(
element,
"The linear element does not exist in the provided Scene",
);
invariant(
selectedPointsIndices != null,
"There are no selected points to duplicate",
);
const { points } = element;
@ -1134,9 +1157,9 @@ export class LinearElementEditor {
let pointAddedToEnd = false;
let indexCursor = -1;
const nextPoints = points.reduce((acc: Point[], point, index) => {
const nextPoints = points.reduce((acc: LocalPoint[], p, index) => {
++indexCursor;
acc.push(point);
acc.push(p);
const isSelected = selectedPointsIndices.includes(index);
if (isSelected) {
@ -1147,8 +1170,8 @@ export class LinearElementEditor {
}
acc.push(
nextPoint
? [(point[0] + nextPoint[0]) / 2, (point[1] + nextPoint[1]) / 2]
: [point[0], point[1]],
? point((p[0] + nextPoint[0]) / 2, (p[1] + nextPoint[1]) / 2)
: point(p[0], p[1]),
);
nextSelectedIndices.push(indexCursor + 1);
@ -1169,7 +1192,7 @@ export class LinearElementEditor {
[
{
index: element.points.length - 1,
point: [lastPoint[0] + 30, lastPoint[1] + 30],
point: point(lastPoint[0] + 30, lastPoint[1] + 30),
},
],
elementsMap,
@ -1177,12 +1200,10 @@ export class LinearElementEditor {
}
return {
appState: {
...appState,
editingLinearElement: {
...appState.editingLinearElement,
selectedPointsIndices: nextSelectedIndices,
},
...appState,
editingLinearElement: {
...appState.editingLinearElement,
selectedPointsIndices: nextSelectedIndices,
},
};
}
@ -1209,10 +1230,10 @@ export class LinearElementEditor {
}
}
const nextPoints = element.points.reduce((acc: Point[], point, idx) => {
const nextPoints = element.points.reduce((acc: LocalPoint[], p, idx) => {
if (!pointIndices.includes(idx)) {
acc.push(
!acc.length ? [0, 0] : [point[0] - offsetX, point[1] - offsetY],
!acc.length ? point(0, 0) : point(p[0] - offsetX, p[1] - offsetY),
);
}
return acc;
@ -1229,7 +1250,7 @@ export class LinearElementEditor {
static addPoints(
element: NonDeleted<ExcalidrawLinearElement>,
targetPoints: { point: Point }[],
targetPoints: { point: LocalPoint }[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
) {
const offsetX = 0;
@ -1247,7 +1268,7 @@ export class LinearElementEditor {
static movePoints(
element: NonDeleted<ExcalidrawLinearElement>,
targetPoints: { index: number; point: Point; isDragging?: boolean }[],
targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
otherUpdates?: {
startBinding?: PointBinding | null;
@ -1277,11 +1298,11 @@ export class LinearElementEditor {
selectedOriginPoint.point[1] + points[selectedOriginPoint.index][1];
}
const nextPoints = points.map((point, idx) => {
const selectedPointData = targetPoints.find((p) => p.index === idx);
const nextPoints: LocalPoint[] = points.map((p, idx) => {
const selectedPointData = targetPoints.find((t) => t.index === idx);
if (selectedPointData) {
if (selectedPointData.index === 0) {
return point;
return p;
}
const deltaX =
@ -1289,14 +1310,9 @@ export class LinearElementEditor {
const deltaY =
selectedPointData.point[1] - points[selectedPointData.index][1];
return [
point[0] + deltaX - offsetX,
point[1] + deltaY - offsetY,
] as const;
return point(p[0] + deltaX - offsetX, p[1] + deltaY - offsetY);
}
return offsetX || offsetY
? ([point[0] - offsetX, point[1] - offsetY] as const)
: point;
return offsetX || offsetY ? point(p[0] - offsetX, p[1] - offsetY) : p;
});
LinearElementEditor._updatePoints(
@ -1349,11 +1365,9 @@ export class LinearElementEditor {
}
const origin = linearElementEditor.pointerDownState.origin!;
const dist = distance2d(
origin.x,
origin.y,
pointerCoords.x,
pointerCoords.y,
const dist = pointDistance(
point(origin.x, origin.y),
point(pointerCoords.x, pointerCoords.y),
);
if (
!appState.editingLinearElement &&
@ -1418,7 +1432,7 @@ export class LinearElementEditor {
private static _updatePoints(
element: NonDeleted<ExcalidrawLinearElement>,
nextPoints: readonly Point[],
nextPoints: readonly LocalPoint[],
offsetX: number,
offsetY: number,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
@ -1461,7 +1475,7 @@ export class LinearElementEditor {
element,
mergedElementsMap,
nextPoints,
[offsetX, offsetY],
vector(offsetX, offsetY),
bindings,
options,
);
@ -1474,7 +1488,11 @@ export class LinearElementEditor {
const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
const dX = prevCenterX - nextCenterX;
const dY = prevCenterY - nextCenterY;
const rotated = rotate(offsetX, offsetY, dX, dY, element.angle);
const rotated = pointRotateRads(
point(offsetX, offsetY),
point(dX, dY),
element.angle,
);
mutateElement(element, {
...otherUpdates,
points: nextPoints,
@ -1487,8 +1505,8 @@ export class LinearElementEditor {
private static _getShiftLockedDelta(
element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap,
referencePoint: Point,
scenePointer: Point,
referencePoint: LocalPoint,
scenePointer: GlobalPoint,
gridSize: NullableGridSize,
) {
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
@ -1517,7 +1535,11 @@ export class LinearElementEditor {
gridY,
);
return rotatePoint([width, height], [0, 0], -element.angle);
return pointRotateRads(
point(width, height),
point(0, 0),
-element.angle as Radians,
);
}
static getBoundTextElementPosition = (
@ -1548,7 +1570,7 @@ export class LinearElementEditor {
let midSegmentMidpoint = editorMidPointsCache.points[index];
if (element.points.length === 2) {
midSegmentMidpoint = centerPoint(points[0], points[1]);
midSegmentMidpoint = pointCenter(points[0], points[1]);
}
if (
!midSegmentMidpoint ||
@ -1585,37 +1607,38 @@ export class LinearElementEditor {
);
const boundTextX2 = boundTextX1 + boundTextElement.width;
const boundTextY2 = boundTextY1 + boundTextElement.height;
const centerPoint = point(cx, cy);
const topLeftRotatedPoint = rotatePoint([x1, y1], [cx, cy], element.angle);
const topRightRotatedPoint = rotatePoint([x2, y1], [cx, cy], element.angle);
const counterRotateBoundTextTopLeft = rotatePoint(
[boundTextX1, boundTextY1],
[cx, cy],
-element.angle,
const topLeftRotatedPoint = pointRotateRads(
point(x1, y1),
centerPoint,
element.angle,
);
const counterRotateBoundTextTopRight = rotatePoint(
[boundTextX2, boundTextY1],
[cx, cy],
-element.angle,
const topRightRotatedPoint = pointRotateRads(
point(x2, y1),
centerPoint,
element.angle,
);
const counterRotateBoundTextBottomLeft = rotatePoint(
[boundTextX1, boundTextY2],
[cx, cy],
-element.angle,
const counterRotateBoundTextTopLeft = pointRotateRads(
point(boundTextX1, boundTextY1),
centerPoint,
-element.angle as Radians,
);
const counterRotateBoundTextBottomRight = rotatePoint(
[boundTextX2, boundTextY2],
[cx, cy],
-element.angle,
const counterRotateBoundTextTopRight = pointRotateRads(
point(boundTextX2, boundTextY1),
centerPoint,
-element.angle as Radians,
);
const counterRotateBoundTextBottomLeft = pointRotateRads(
point(boundTextX1, boundTextY2),
centerPoint,
-element.angle as Radians,
);
const counterRotateBoundTextBottomRight = pointRotateRads(
point(boundTextX2, boundTextY2),
centerPoint,
-element.angle as Radians,
);
if (

View file

@ -2,7 +2,6 @@ import type { ExcalidrawElement } from "./types";
import Scene from "../scene/Scene";
import { getSizeFromPoints } from "../points";
import { randomInteger } from "../random";
import type { Point } from "../types";
import { getUpdatedTimestamp } from "../utils";
import type { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache";
@ -59,8 +58,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
let didChangePoints = false;
let index = prevPoints.length;
while (--index) {
const prevPoint: Point = prevPoints[index];
const nextPoint: Point = nextPoints[index];
const prevPoint = prevPoints[index];
const nextPoint = nextPoints[index];
if (
prevPoint[0] !== nextPoint[0] ||
prevPoint[1] !== nextPoint[1]

View file

@ -4,6 +4,8 @@ import { API } from "../tests/helpers/api";
import { FONT_FAMILY, ROUNDNESS } from "../constants";
import { isPrimitive } from "../utils";
import type { ExcalidrawLinearElement } from "./types";
import type { LocalPoint } from "../../math";
import { point } from "../../math";
const assertCloneObjects = (source: any, clone: any) => {
for (const key in clone) {
@ -36,10 +38,7 @@ describe("duplicating single elements", () => {
element.__proto__ = { hello: "world" };
mutateElement(element, {
points: [
[1, 2],
[3, 4],
],
points: [point<LocalPoint>(1, 2), point<LocalPoint>(3, 4)],
});
const copy = duplicateElement(null, new Map(), element);

View file

@ -30,7 +30,6 @@ import { bumpVersion, newElementWith } from "./mutateElement";
import { getNewGroupIdsForDuplication } from "../groups";
import type { AppState } from "../types";
import { getElementAbsoluteCoords } from ".";
import { adjustXYWithRotation } from "../math";
import { getResizedElementAbsoluteCoords } from "./bounds";
import {
measureText,
@ -48,6 +47,7 @@ import {
} from "../constants";
import type { MarkOptional, Merge, Mutable } from "../utility-types";
import { getLineHeight } from "../fonts";
import type { Radians } from "../../math";
export type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
@ -88,7 +88,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
opacity = DEFAULT_ELEMENT_PROPS.opacity,
width = 0,
height = 0,
angle = 0,
angle = 0 as Radians,
groupIds = [],
frameId = null,
index = null,
@ -348,6 +348,53 @@ const getAdjustedDimensions = (
};
};
const adjustXYWithRotation = (
sides: {
n?: boolean;
e?: boolean;
s?: boolean;
w?: boolean;
},
x: number,
y: number,
angle: number,
deltaX1: number,
deltaY1: number,
deltaX2: number,
deltaY2: number,
): [number, number] => {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
if (sides.e && sides.w) {
x += deltaX1 + deltaX2;
} else if (sides.e) {
x += deltaX1 * (1 + cos);
y += deltaX1 * sin;
x += deltaX2 * (1 - cos);
y += deltaX2 * -sin;
} else if (sides.w) {
x += deltaX1 * (1 - cos);
y += deltaX1 * -sin;
x += deltaX2 * (1 + cos);
y += deltaX2 * sin;
}
if (sides.n && sides.s) {
y += deltaY1 + deltaY2;
} else if (sides.n) {
x += deltaY1 * sin;
y += deltaY1 * (1 - cos);
x += deltaY2 * -sin;
y += deltaY2 * (1 + cos);
} else if (sides.s) {
x += deltaY1 * -sin;
y += deltaY1 * (1 + cos);
x += deltaY2 * sin;
y += deltaY2 * (1 - cos);
}
return [x, y];
};
export const refreshTextDimensions = (
textElement: ExcalidrawTextElement,
container: ExcalidrawTextContainer | null,

View file

@ -1,7 +1,5 @@
import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants";
import { rescalePoints } from "../points";
import { rotate, centerPoint, rotatePoint } from "../math";
import type {
ExcalidrawLinearElement,
ExcalidrawTextElement,
@ -38,7 +36,7 @@ import type {
MaybeTransformHandleType,
TransformHandleDirection,
} from "./transformHandles";
import type { Point, PointerDownState } from "../types";
import type { PointerDownState } from "../types";
import Scene from "../scene/Scene";
import {
getApproxMinLineWidth,
@ -55,16 +53,15 @@ import {
import { LinearElementEditor } from "./linearElementEditor";
import { isInGroup } from "../groups";
import { mutateElbowArrow } from "./routing";
export const normalizeAngle = (angle: number): number => {
if (angle < 0) {
return angle + 2 * Math.PI;
}
if (angle >= 2 * Math.PI) {
return angle - 2 * Math.PI;
}
return angle;
};
import type { GlobalPoint } from "../../math";
import {
pointCenter,
normalizeRadians,
point,
pointFromPair,
pointRotateRads,
type Radians,
} from "../../math";
// Returns true when transform (resizing/rotation) happened
export const transformElements = (
@ -158,16 +155,17 @@ const rotateSingleElement = (
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
let angle: number;
let angle: Radians;
if (isFrameLikeElement(element)) {
angle = 0;
angle = 0 as Radians;
} else {
angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
angle = ((5 * Math.PI) / 2 +
Math.atan2(pointerY - cy, pointerX - cx)) as Radians;
if (shouldRotateWithDiscreteAngle) {
angle += SHIFT_LOCKING_ANGLE / 2;
angle -= angle % SHIFT_LOCKING_ANGLE;
angle = (angle + SHIFT_LOCKING_ANGLE / 2) as Radians;
angle = (angle - (angle % SHIFT_LOCKING_ANGLE)) as Radians;
}
angle = normalizeAngle(angle);
angle = normalizeRadians(angle as Radians);
}
const boundTextElementId = getBoundTextElementId(element);
@ -240,12 +238,10 @@ const resizeSingleTextElement = (
elementsMap,
);
// rotation pointer with reverse angle
const [rotatedX, rotatedY] = rotate(
pointerX,
pointerY,
cx,
cy,
-element.angle,
const [rotatedX, rotatedY] = pointRotateRads(
point(pointerX, pointerY),
point(cx, cy),
-element.angle as Radians,
);
let scaleX = 0;
let scaleY = 0;
@ -279,20 +275,26 @@ const resizeSingleTextElement = (
const startBottomRight = [x2, y2];
const startCenter = [cx, cy];
let newTopLeft = [x1, y1] as [number, number];
let newTopLeft = point<GlobalPoint>(x1, y1);
if (["n", "w", "nw"].includes(transformHandleType)) {
newTopLeft = [
newTopLeft = point<GlobalPoint>(
startBottomRight[0] - Math.abs(nextWidth),
startBottomRight[1] - Math.abs(nextHeight),
];
);
}
if (transformHandleType === "ne") {
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(nextHeight)];
newTopLeft = point<GlobalPoint>(
bottomLeft[0],
bottomLeft[1] - Math.abs(nextHeight),
);
}
if (transformHandleType === "sw") {
const topRight = [startBottomRight[0], startTopLeft[1]];
newTopLeft = [topRight[0] - Math.abs(nextWidth), topRight[1]];
newTopLeft = point<GlobalPoint>(
topRight[0] - Math.abs(nextWidth),
topRight[1],
);
}
if (["s", "n"].includes(transformHandleType)) {
@ -308,13 +310,17 @@ const resizeSingleTextElement = (
}
const angle = element.angle;
const rotatedTopLeft = rotatePoint(newTopLeft, [cx, cy], angle);
const newCenter: Point = [
const rotatedTopLeft = pointRotateRads(newTopLeft, point(cx, cy), angle);
const newCenter = point<GlobalPoint>(
newTopLeft[0] + Math.abs(nextWidth) / 2,
newTopLeft[1] + Math.abs(nextHeight) / 2,
];
const rotatedNewCenter = rotatePoint(newCenter, [cx, cy], angle);
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
);
const rotatedNewCenter = pointRotateRads(newCenter, point(cx, cy), angle);
newTopLeft = pointRotateRads(
rotatedTopLeft,
rotatedNewCenter,
-angle as Radians,
);
const [nextX, nextY] = newTopLeft;
mutateElement(element, {
@ -334,14 +340,14 @@ const resizeSingleTextElement = (
stateAtResizeStart.height,
true,
);
const startTopLeft: Point = [x1, y1];
const startBottomRight: Point = [x2, y2];
const startCenter: Point = centerPoint(startTopLeft, startBottomRight);
const startTopLeft = point<GlobalPoint>(x1, y1);
const startBottomRight = point<GlobalPoint>(x2, y2);
const startCenter = pointCenter(startTopLeft, startBottomRight);
const rotatedPointer = rotatePoint(
[pointerX, pointerY],
const rotatedPointer = pointRotateRads(
point(pointerX, pointerY),
startCenter,
-stateAtResizeStart.angle,
-stateAtResizeStart.angle as Radians,
);
const [esx1, , esx2] = getResizedElementAbsoluteCoords(
@ -407,13 +413,21 @@ const resizeSingleTextElement = (
// adjust topLeft to new rotation point
const angle = stateAtResizeStart.angle;
const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle);
const newCenter: Point = [
const rotatedTopLeft = pointRotateRads(
pointFromPair(newTopLeft),
startCenter,
angle,
);
const newCenter = point(
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
];
const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle);
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
);
const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
newTopLeft = pointRotateRads(
rotatedTopLeft,
rotatedNewCenter,
-angle as Radians,
);
const resizedElement: Partial<ExcalidrawTextElement> = {
width: Math.abs(newWidth),
@ -446,15 +460,15 @@ export const resizeSingleElement = (
stateAtResizeStart.height,
true,
);
const startTopLeft: Point = [x1, y1];
const startBottomRight: Point = [x2, y2];
const startCenter: Point = centerPoint(startTopLeft, startBottomRight);
const startTopLeft = point(x1, y1);
const startBottomRight = point(x2, y2);
const startCenter = pointCenter(startTopLeft, startBottomRight);
// Calculate new dimensions based on cursor position
const rotatedPointer = rotatePoint(
[pointerX, pointerY],
const rotatedPointer = pointRotateRads(
point(pointerX, pointerY),
startCenter,
-stateAtResizeStart.angle,
-stateAtResizeStart.angle as Radians,
);
// Get bounds corners rendered on screen
@ -628,13 +642,21 @@ export const resizeSingleElement = (
// adjust topLeft to new rotation point
const angle = stateAtResizeStart.angle;
const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle);
const newCenter: Point = [
const rotatedTopLeft = pointRotateRads(
pointFromPair(newTopLeft),
startCenter,
angle,
);
const newCenter = point(
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
];
const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle);
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
);
const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
newTopLeft = pointRotateRads(
rotatedTopLeft,
rotatedNewCenter,
-angle as Radians,
);
// For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
// So we need to readjust (x,y) to be where the first point should be
@ -793,21 +815,21 @@ export const resizeMultipleElements = (
const direction = transformHandleType;
const anchorsMap: Record<TransformHandleDirection, Point> = {
ne: [minX, maxY],
se: [minX, minY],
sw: [maxX, minY],
nw: [maxX, maxY],
e: [minX, minY + height / 2],
w: [maxX, minY + height / 2],
n: [minX + width / 2, maxY],
s: [minX + width / 2, minY],
const anchorsMap: Record<TransformHandleDirection, GlobalPoint> = {
ne: point(minX, maxY),
se: point(minX, minY),
sw: point(maxX, minY),
nw: point(maxX, maxY),
e: point(minX, minY + height / 2),
w: point(maxX, minY + height / 2),
n: point(minX + width / 2, maxY),
s: point(minX + width / 2, minY),
};
// anchor point must be on the opposite side of the dragged selection handle
// or be the center of the selection if shouldResizeFromCenter
const [anchorX, anchorY]: Point = shouldResizeFromCenter
? [midX, midY]
const [anchorX, anchorY] = shouldResizeFromCenter
? point(midX, midY)
: anchorsMap[direction];
const resizeFromCenterScale = shouldResizeFromCenter ? 2 : 1;
@ -898,7 +920,9 @@ export const resizeMultipleElements = (
const width = orig.width * scaleX;
const height = orig.height * scaleY;
const angle = normalizeAngle(orig.angle * flipFactorX * flipFactorY);
const angle = normalizeRadians(
(orig.angle * flipFactorX * flipFactorY) as Radians,
);
const isLinearOrFreeDraw = isLinearElement(orig) || isFreeDrawElement(orig);
const offsetX = orig.x - anchorX;
@ -1029,12 +1053,10 @@ const rotateMultipleElements = (
const cy = (y1 + y2) / 2;
const origAngle =
originalElements.get(element.id)?.angle ?? element.angle;
const [rotatedCX, rotatedCY] = rotate(
cx,
cy,
centerX,
centerY,
centerAngle + origAngle - element.angle,
const [rotatedCX, rotatedCY] = pointRotateRads(
point(cx, cy),
point(centerX, centerY),
(centerAngle + origAngle - element.angle) as Radians,
);
if (isArrowElement(element) && isElbowArrow(element)) {
@ -1046,7 +1068,7 @@ const rotateMultipleElements = (
{
x: element.x + (rotatedCX - cx),
y: element.y + (rotatedCY - cy),
angle: normalizeAngle(centerAngle + origAngle),
angle: normalizeRadians((centerAngle + origAngle) as Radians),
},
false,
);
@ -1063,7 +1085,7 @@ const rotateMultipleElements = (
{
x: boundText.x + (rotatedCX - cx),
y: boundText.y + (rotatedCY - cy),
angle: normalizeAngle(centerAngle + origAngle),
angle: normalizeRadians((centerAngle + origAngle) as Radians),
},
false,
);
@ -1086,25 +1108,43 @@ export const getResizeOffsetXY = (
: getCommonBounds(selectedElements);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const angle = selectedElements.length === 1 ? selectedElements[0].angle : 0;
[x, y] = rotate(x, y, cx, cy, -angle);
const angle = (
selectedElements.length === 1 ? selectedElements[0].angle : 0
) as Radians;
[x, y] = pointRotateRads(point(x, y), point(cx, cy), -angle as Radians);
switch (transformHandleType) {
case "n":
return rotate(x - (x1 + x2) / 2, y - y1, 0, 0, angle);
return pointRotateRads(
point(x - (x1 + x2) / 2, y - y1),
point(0, 0),
angle,
);
case "s":
return rotate(x - (x1 + x2) / 2, y - y2, 0, 0, angle);
return pointRotateRads(
point(x - (x1 + x2) / 2, y - y2),
point(0, 0),
angle,
);
case "w":
return rotate(x - x1, y - (y1 + y2) / 2, 0, 0, angle);
return pointRotateRads(
point(x - x1, y - (y1 + y2) / 2),
point(0, 0),
angle,
);
case "e":
return rotate(x - x2, y - (y1 + y2) / 2, 0, 0, angle);
return pointRotateRads(
point(x - x2, y - (y1 + y2) / 2),
point(0, 0),
angle,
);
case "nw":
return rotate(x - x1, y - y1, 0, 0, angle);
return pointRotateRads(point(x - x1, y - y1), point(0, 0), angle);
case "ne":
return rotate(x - x2, y - y1, 0, 0, angle);
return pointRotateRads(point(x - x2, y - y1), point(0, 0), angle);
case "sw":
return rotate(x - x1, y - y2, 0, 0, angle);
return pointRotateRads(point(x - x1, y - y2), point(0, 0), angle);
case "se":
return rotate(x - x2, y - y2, 0, 0, angle);
return pointRotateRads(point(x - x2, y - y2), point(0, 0), angle);
default:
return [0, 0];
}

View file

@ -20,13 +20,14 @@ import type { AppState, Device, Zoom } from "../types";
import type { Bounds } from "./bounds";
import { getElementAbsoluteCoords } from "./bounds";
import { SIDE_RESIZING_THRESHOLD } from "../constants";
import {
angleToDegrees,
pointOnLine,
pointRotate,
} from "../../utils/geometry/geometry";
import type { Line, Point } from "../../utils/geometry/shape";
import { isLinearElement } from "./typeChecks";
import type { GlobalPoint, LineSegment, LocalPoint } from "../../math";
import {
point,
pointOnLineSegment,
pointRotateRads,
type Radians,
} from "../../math";
const isInsideTransformHandle = (
transformHandle: TransformHandle,
@ -38,7 +39,7 @@ const isInsideTransformHandle = (
y >= transformHandle[1] &&
y <= transformHandle[1] + transformHandle[3];
export const resizeTest = (
export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
element: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
appState: AppState,
@ -91,15 +92,17 @@ export const resizeTest = (
if (!(isLinearElement(element) && element.points.length <= 2)) {
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
const sides = getSelectionBorders(
[x1 - SPACING, y1 - SPACING],
[x2 + SPACING, y2 + SPACING],
[cx, cy],
angleToDegrees(element.angle),
point(x1 - SPACING, y1 - SPACING),
point(x2 + SPACING, y2 + SPACING),
point(cx, cy),
element.angle,
);
for (const [dir, side] of Object.entries(sides)) {
// test to see if x, y are on the line segment
if (pointOnLine([x, y], side as Line, SPACING)) {
if (
pointOnLineSegment(point(x, y), side as LineSegment<Point>, SPACING)
) {
return dir as TransformHandleType;
}
}
@ -137,7 +140,9 @@ export const getElementWithTransformHandleType = (
}, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null);
};
export const getTransformHandleTypeFromCoords = (
export const getTransformHandleTypeFromCoords = <
Point extends GlobalPoint | LocalPoint,
>(
[x1, y1, x2, y2]: Bounds,
scenePointerX: number,
scenePointerY: number,
@ -147,7 +152,7 @@ export const getTransformHandleTypeFromCoords = (
): MaybeTransformHandleType => {
const transformHandles = getTransformHandlesFromCoords(
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
0,
0 as Radians,
zoom,
pointerType,
getOmitSidesForDevice(device),
@ -173,15 +178,21 @@ export const getTransformHandleTypeFromCoords = (
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
const sides = getSelectionBorders(
[x1 - SPACING, y1 - SPACING],
[x2 + SPACING, y2 + SPACING],
[cx, cy],
angleToDegrees(0),
point(x1 - SPACING, y1 - SPACING),
point(x2 + SPACING, y2 + SPACING),
point(cx, cy),
0 as Radians,
);
for (const [dir, side] of Object.entries(sides)) {
// test to see if x, y are on the line segment
if (pointOnLine([scenePointerX, scenePointerY], side as Line, SPACING)) {
if (
pointOnLineSegment(
point(scenePointerX, scenePointerY),
side as LineSegment<Point>,
SPACING,
)
) {
return dir as TransformHandleType;
}
}
@ -248,16 +259,16 @@ export const getCursorForResizingElement = (resizingElement: {
return cursor ? `${cursor}-resize` : "";
};
const getSelectionBorders = (
const getSelectionBorders = <Point extends LocalPoint | GlobalPoint>(
[x1, y1]: Point,
[x2, y2]: Point,
center: Point,
angleInDegrees: number,
angle: Radians,
) => {
const topLeft = pointRotate([x1, y1], angleInDegrees, center);
const topRight = pointRotate([x2, y1], angleInDegrees, center);
const bottomLeft = pointRotate([x1, y2], angleInDegrees, center);
const bottomRight = pointRotate([x2, y2], angleInDegrees, center);
const topLeft = pointRotateRads(point(x1, y1), center, angle);
const topRight = pointRotateRads(point(x2, y1), center, angle);
const bottomLeft = pointRotateRads(point(x1, y2), center, angle);
const bottomRight = pointRotateRads(point(x2, y2), center, angle);
return {
n: [topLeft, topRight],

View file

@ -17,6 +17,7 @@ import type {
ExcalidrawElbowArrowElement,
} from "./types";
import { ARROW_TYPE } from "../constants";
import { point } from "../../math";
const { h } = window;
@ -31,8 +32,8 @@ describe("elbow arrow routing", () => {
}) as ExcalidrawElbowArrowElement;
scene.insertElement(arrow);
mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [
[-45 - arrow.x, -100.1 - arrow.y],
[45 - arrow.x, 99.9 - arrow.y],
point(-45 - arrow.x, -100.1 - arrow.y),
point(45 - arrow.x, 99.9 - arrow.y),
]);
expect(arrow.points).toEqual([
[0, 0],
@ -68,10 +69,7 @@ describe("elbow arrow routing", () => {
y: -100.1,
width: 90,
height: 200,
points: [
[0, 0],
[90, 200],
],
points: [point(0, 0), point(90, 200)],
}) as ExcalidrawElbowArrowElement;
scene.insertElement(rectangle1);
scene.insertElement(rectangle2);
@ -83,10 +81,7 @@ describe("elbow arrow routing", () => {
expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null);
mutateElbowArrow(arrow, elementsMap, [
[0, 0],
[90, 200],
]);
mutateElbowArrow(arrow, elementsMap, [point(0, 0), point(90, 200)]);
expect(arrow.points).toEqual([
[0, 0],

View file

@ -1,16 +1,19 @@
import { cross } from "../../utils/geometry/geometry";
import BinaryHeap from "../binaryheap";
import type { Radians } from "../../math";
import {
aabbForElement,
arePointsEqual,
pointInsideBounds,
pointToVector,
scalePointFromOrigin,
scaleVector,
translatePoint,
} from "../math";
point,
pointScaleFromOrigin,
pointTranslate,
vector,
vectorCross,
vectorFromPoint,
vectorScale,
type GlobalPoint,
type LocalPoint,
type Vector,
} from "../../math";
import BinaryHeap from "../binaryheap";
import { getSizeFromPoints } from "../points";
import type { Point } from "../types";
import { aabbForElement, pointInsideBounds } from "../shapes";
import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils";
import {
bindPointToSnapToElementOutline,
@ -25,6 +28,8 @@ import {
import type { Bounds } from "./bounds";
import type { Heading } from "./heading";
import {
compareHeading,
flipHeading,
HEADING_DOWN,
HEADING_LEFT,
HEADING_RIGHT,
@ -41,6 +46,8 @@ import type {
} from "./types";
import type { ElementsMap, ExcalidrawBindableElement } from "./types";
type GridAddress = [number, number] & { _brand: "gridaddress" };
type Node = {
f: number;
g: number;
@ -48,8 +55,8 @@ type Node = {
closed: boolean;
visited: boolean;
parent: Node | null;
pos: Point;
addr: [number, number];
pos: GlobalPoint;
addr: GridAddress;
};
type Grid = {
@ -63,8 +70,8 @@ const BASE_PADDING = 40;
export const mutateElbowArrow = (
arrow: ExcalidrawElbowArrowElement,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
nextPoints: readonly Point[],
offset?: Point,
nextPoints: readonly LocalPoint[],
offset?: Vector,
otherUpdates?: {
startBinding?: FixedPointBinding | null;
endBinding?: FixedPointBinding | null;
@ -75,14 +82,20 @@ export const mutateElbowArrow = (
informMutation?: boolean;
},
) => {
const origStartGlobalPoint = translatePoint(nextPoints[0], [
arrow.x + (offset ? offset[0] : 0),
arrow.y + (offset ? offset[1] : 0),
]);
const origEndGlobalPoint = translatePoint(nextPoints[nextPoints.length - 1], [
arrow.x + (offset ? offset[0] : 0),
arrow.y + (offset ? offset[1] : 0),
]);
const origStartGlobalPoint: GlobalPoint = pointTranslate(
pointTranslate<LocalPoint, GlobalPoint>(
nextPoints[0],
vector(arrow.x, arrow.y),
),
offset,
);
const origEndGlobalPoint: GlobalPoint = pointTranslate(
pointTranslate<LocalPoint, GlobalPoint>(
nextPoints[nextPoints.length - 1],
vector(arrow.x, arrow.y),
),
offset,
);
const startElement =
arrow.startBinding &&
@ -275,7 +288,10 @@ export const mutateElbowArrow = (
);
if (path) {
const points = path.map((node) => [node.pos[0], node.pos[1]]) as Point[];
const points = path.map((node) => [
node.pos[0],
node.pos[1],
]) as GlobalPoint[];
startDongle && points.unshift(startGlobalPoint);
endDongle && points.push(endGlobalPoint);
@ -284,7 +300,7 @@ export const mutateElbowArrow = (
{
...otherUpdates,
...normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0),
angle: 0,
angle: 0 as Radians,
},
options?.informMutation,
);
@ -363,7 +379,7 @@ const astar = (
}
// Intersect
const neighborHalfPoint = scalePointFromOrigin(
const neighborHalfPoint = pointScaleFromOrigin(
neighbor.pos,
current.pos,
0.5,
@ -380,17 +396,17 @@ const astar = (
// We need to check if the path we have arrived at this neighbor is the shortest one we have seen yet.
const neighborHeading = neighborIndexToHeading(i as 0 | 1 | 2 | 3);
const previousDirection = current.parent
? vectorToHeading(pointToVector(current.pos, current.parent.pos))
? vectorToHeading(vectorFromPoint(current.pos, current.parent.pos))
: startHeading;
// Do not allow going in reverse
const reverseHeading = scaleVector(previousDirection, -1);
const reverseHeading = flipHeading(previousDirection);
const neighborIsReverseRoute =
arePointsEqual(reverseHeading, neighborHeading) ||
(arePointsEqual(start.addr, neighbor.addr) &&
arePointsEqual(neighborHeading, startHeading)) ||
(arePointsEqual(end.addr, neighbor.addr) &&
arePointsEqual(neighborHeading, endHeading));
compareHeading(reverseHeading, neighborHeading) ||
(gridAddressesEqual(start.addr, neighbor.addr) &&
compareHeading(neighborHeading, startHeading)) ||
(gridAddressesEqual(end.addr, neighbor.addr) &&
compareHeading(neighborHeading, endHeading));
if (neighborIsReverseRoute) {
continue;
}
@ -444,7 +460,7 @@ const pathTo = (start: Node, node: Node) => {
return path;
};
const m_dist = (a: Point, b: Point) =>
const m_dist = (a: GlobalPoint | LocalPoint, b: GlobalPoint | LocalPoint) =>
Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]);
/**
@ -541,7 +557,12 @@ const generateDynamicAABBs = (
const cX = first[2] + (second[0] - first[2]) / 2;
const cY = second[3] + (first[1] - second[3]) / 2;
if (cross([a[2], a[1]], [a[0], a[3]], [endCenterX, endCenterY]) > 0) {
if (
vectorCross(
vector(a[2] - endCenterX, a[1] - endCenterY),
vector(a[0] - endCenterX, a[3] - endCenterY),
) > 0
) {
return [
[first[0], first[1], cX, first[3]],
[cX, second[1], second[2], second[3]],
@ -557,7 +578,12 @@ const generateDynamicAABBs = (
const cX = first[2] + (second[0] - first[2]) / 2;
const cY = first[3] + (second[1] - first[3]) / 2;
if (cross([a[0], a[1]], [a[2], a[3]], [endCenterX, endCenterY]) > 0) {
if (
vectorCross(
vector(a[0] - endCenterX, a[1] - endCenterY),
vector(a[2] - endCenterX, a[3] - endCenterY),
) > 0
) {
return [
[first[0], first[1], first[2], cY],
[second[0], cY, second[2], second[3]],
@ -573,7 +599,12 @@ const generateDynamicAABBs = (
const cX = second[2] + (first[0] - second[2]) / 2;
const cY = first[3] + (second[1] - first[3]) / 2;
if (cross([a[2], a[1]], [a[0], a[3]], [endCenterX, endCenterY]) > 0) {
if (
vectorCross(
vector(a[2] - endCenterX, a[1] - endCenterY),
vector(a[0] - endCenterX, a[3] - endCenterY),
) > 0
) {
return [
[cX, first[1], first[2], first[3]],
[second[0], second[1], cX, second[3]],
@ -589,7 +620,12 @@ const generateDynamicAABBs = (
const cX = second[2] + (first[0] - second[2]) / 2;
const cY = second[3] + (first[1] - second[3]) / 2;
if (cross([a[0], a[1]], [a[2], a[3]], [endCenterX, endCenterY]) > 0) {
if (
vectorCross(
vector(a[0] - endCenterX, a[1] - endCenterY),
vector(a[2] - endCenterX, a[3] - endCenterY),
) > 0
) {
return [
[cX, first[1], first[2], first[3]],
[second[0], second[1], cX, second[3]],
@ -615,9 +651,9 @@ const generateDynamicAABBs = (
*/
const calculateGrid = (
aabbs: Bounds[],
start: Point,
start: GlobalPoint,
startHeading: Heading,
end: Point,
end: GlobalPoint,
endHeading: Heading,
common: Bounds,
): Grid => {
@ -662,8 +698,8 @@ const calculateGrid = (
closed: false,
visited: false,
parent: null,
addr: [col, row] as [number, number],
pos: [x, y] as Point,
addr: [col, row] as GridAddress,
pos: [x, y] as GlobalPoint,
}),
),
),
@ -673,17 +709,17 @@ const calculateGrid = (
const getDonglePosition = (
bounds: Bounds,
heading: Heading,
point: Point,
): Point => {
p: GlobalPoint,
): GlobalPoint => {
switch (heading) {
case HEADING_UP:
return [point[0], bounds[1]];
return point(p[0], bounds[1]);
case HEADING_RIGHT:
return [bounds[2], point[1]];
return point(bounds[2], p[1]);
case HEADING_DOWN:
return [point[0], bounds[3]];
return point(p[0], bounds[3]);
}
return [bounds[0], point[1]];
return point(bounds[0], p[1]);
};
const estimateSegmentCount = (
@ -826,7 +862,7 @@ const gridNodeFromAddr = (
/**
* Get node for global point on canvas (if exists)
*/
const pointToGridNode = (point: Point, grid: Grid): Node | null => {
const pointToGridNode = (point: GlobalPoint, grid: Grid): Node | null => {
for (let col = 0; col < grid.col; col++) {
for (let row = 0; row < grid.row; row++) {
const candidate = gridNodeFromAddr([col, row], grid);
@ -865,15 +901,24 @@ const getBindableElementForId = (
};
const normalizedArrowElementUpdate = (
global: Point[],
global: GlobalPoint[],
externalOffsetX?: number,
externalOffsetY?: number,
) => {
): {
points: LocalPoint[];
x: number;
y: number;
width: number;
height: number;
} => {
const offsetX = global[0][0];
const offsetY = global[0][1];
const points = global.map(
(point) => [point[0] - offsetX, point[1] - offsetY] as const,
const points = global.map((p) =>
pointTranslate<GlobalPoint, LocalPoint>(
p,
vectorScale(vectorFromPoint(global[0]), -1),
),
);
return {
@ -885,19 +930,22 @@ const normalizedArrowElementUpdate = (
};
/// If last and current segments have the same heading, skip the middle point
const simplifyElbowArrowPoints = (points: Point[]): Point[] =>
const simplifyElbowArrowPoints = (points: GlobalPoint[]): GlobalPoint[] =>
points
.slice(2)
.reduce(
(result, point) =>
arePointsEqual(
(result, p) =>
compareHeading(
vectorToHeading(
pointToVector(result[result.length - 1], result[result.length - 2]),
vectorFromPoint(
result[result.length - 1],
result[result.length - 2],
),
),
vectorToHeading(pointToVector(point, result[result.length - 1])),
vectorToHeading(vectorFromPoint(p, result[result.length - 1])),
)
? [...result.slice(0, -1), point]
: [...result, point],
? [...result.slice(0, -1), p]
: [...result, p],
[points[0] ?? [0, 0], points[1] ?? [1, 0]],
);
@ -915,13 +963,13 @@ const neighborIndexToHeading = (idx: number): Heading => {
const getGlobalPoint = (
fixedPointRatio: [number, number] | undefined | null,
initialPoint: Point,
otherPoint: Point,
initialPoint: GlobalPoint,
otherPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
boundElement?: ExcalidrawBindableElement | null,
hoveredElement?: ExcalidrawBindableElement | null,
isDragging?: boolean,
): Point => {
): GlobalPoint => {
if (isDragging) {
if (hoveredElement) {
const snapPoint = getSnapPoint(
@ -956,36 +1004,34 @@ const getGlobalPoint = (
};
const getSnapPoint = (
point: Point,
otherPoint: Point,
p: GlobalPoint,
otherPoint: GlobalPoint,
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
) =>
bindPointToSnapToElementOutline(
isRectanguloidElement(element)
? avoidRectangularCorner(element, point)
: point,
isRectanguloidElement(element) ? avoidRectangularCorner(element, p) : p,
otherPoint,
element,
elementsMap,
);
const getBindPointHeading = (
point: Point,
otherPoint: Point,
p: GlobalPoint,
otherPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
hoveredElement: ExcalidrawBindableElement | null | undefined,
origPoint: Point,
origPoint: GlobalPoint,
) =>
getHeadingForElbowArrowSnap(
point,
p,
otherPoint,
hoveredElement,
hoveredElement &&
aabbForElement(
hoveredElement,
Array(4).fill(
distanceToBindableElement(hoveredElement, point, elementsMap),
distanceToBindableElement(hoveredElement, p, elementsMap),
) as [number, number, number, number],
),
elementsMap,
@ -993,8 +1039,8 @@ const getBindPointHeading = (
);
const getHoveredElements = (
origStartGlobalPoint: Point,
origEndGlobalPoint: Point,
origStartGlobalPoint: GlobalPoint,
origEndGlobalPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
) => {
// TODO: Might be a performance bottleneck and the Map type
@ -1018,3 +1064,6 @@ const getHoveredElements = (
),
];
};
const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean =>
a[0] === b[0] && a[1] === b[1];

View file

@ -284,16 +284,17 @@ export const measureText = (
text: string,
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
forceAdvanceWidth?: true,
) => {
text = text
const _text = text
.split("\n")
// replace empty lines with single space because leading/trailing empty
// lines would be stripped from computation
.map((x) => x || " ")
.join("\n");
const fontSize = parseFloat(font);
const height = getTextHeight(text, fontSize, lineHeight);
const width = getTextWidth(text, font);
const height = getTextHeight(_text, fontSize, lineHeight);
const width = getTextWidth(_text, font, forceAdvanceWidth);
return { width, height };
};

View file

@ -19,6 +19,7 @@ import type {
import { API } from "../tests/helpers/api";
import { getOriginalContainerHeightFromCache } from "./containerCache";
import { getTextEditor, updateTextEditor } from "../tests/queries/dom";
import { point } from "../../math";
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@ -41,10 +42,7 @@ describe("textWysiwyg", () => {
type: "line",
width: 100,
height: 0,
points: [
[0, 0],
[100, 0],
],
points: [point(0, 0), point(100, 0)],
});
const textSize = 20;
const text = API.createElement({

View file

@ -7,7 +7,6 @@ import type {
import type { Bounds } from "./bounds";
import { getElementAbsoluteCoords } from "./bounds";
import { rotate } from "../math";
import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
import {
isElbowArrow,
@ -19,6 +18,8 @@ import {
isAndroid,
isIOS,
} from "../constants";
import type { Radians } from "../../math";
import { point, pointRotateRads } from "../../math";
export type TransformHandleDirection =
| "n"
@ -91,9 +92,13 @@ const generateTransformHandle = (
height: number,
cx: number,
cy: number,
angle: number,
angle: Radians,
): TransformHandle => {
const [xx, yy] = rotate(x + width / 2, y + height / 2, cx, cy, angle);
const [xx, yy] = pointRotateRads(
point(x + width / 2, y + height / 2),
point(cx, cy),
angle,
);
return [xx - width / 2, yy - height / 2, width, height];
};
@ -119,7 +124,7 @@ export const getOmitSidesForDevice = (device: Device) => {
export const getTransformHandlesFromCoords = (
[x1, y1, x2, y2, cx, cy]: [number, number, number, number, number, number],
angle: number,
angle: Radians,
zoom: Zoom,
pointerType: PointerType,
omitSides: { [T in TransformHandleType]?: boolean } = {},

View file

@ -1,6 +1,5 @@
import type { LineSegment } from "../../utils";
import { ROUNDNESS } from "../constants";
import type { ElementOrToolType, Point } from "../types";
import type { ElementOrToolType } from "../types";
import type { MarkNonNullable } from "../utility-types";
import { assertNever } from "../utils";
import type { Bounds } from "./bounds";
@ -191,7 +190,8 @@ export const isRectangularElement = (
element.type === "iframe" ||
element.type === "embeddable" ||
element.type === "frame" ||
element.type === "magicframe")
element.type === "magicframe" ||
element.type === "freedraw")
);
};
@ -325,10 +325,6 @@ export const isFixedPointBinding = (
return binding.fixedPoint != null;
};
// TODO: Move this to @excalidraw/math
export const isPoint = (point: unknown): point is Point =>
Array.isArray(point) && point.length === 2;
// TODO: Move this to @excalidraw/math
export const isBounds = (box: unknown): box is Bounds =>
Array.isArray(box) &&
@ -337,10 +333,3 @@ export const isBounds = (box: unknown): box is Bounds =>
typeof box[1] === "number" &&
typeof box[2] === "number" &&
typeof box[3] === "number";
// TODO: Move this to @excalidraw/math
export const isLineSegment = (segment: unknown): segment is LineSegment =>
Array.isArray(segment) &&
segment.length === 2 &&
isPoint(segment[0]) &&
isPoint(segment[0]);

View file

@ -1,4 +1,4 @@
import type { Point } from "../types";
import type { LocalPoint, Radians } from "../../math";
import type {
FONT_FAMILY,
ROUNDNESS,
@ -49,7 +49,7 @@ type _ExcalidrawElementBase = Readonly<{
opacity: number;
width: number;
height: number;
angle: number;
angle: Radians;
/** Random integer used to seed shape generation so that the roughjs shape
doesn't differ across renders. */
seed: number;
@ -175,6 +175,15 @@ export type ExcalidrawFlowchartNodeElement =
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement;
export type ExcalidrawRectanguloidElement =
| ExcalidrawRectangleElement
| ExcalidrawImageElement
| ExcalidrawTextElement
| ExcalidrawFreeDrawElement
| ExcalidrawIframeLikeElement
| ExcalidrawFrameLikeElement
| ExcalidrawEmbeddableElement;
/**
* ExcalidrawElement should be JSON serializable and (eventually) contain
* no computed data. The list of all ExcalidrawElements should be shareable
@ -283,8 +292,8 @@ export type Arrowhead =
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{
type: "line" | "arrow";
points: readonly Point[];
lastCommittedPoint: Point | null;
points: readonly LocalPoint[];
lastCommittedPoint: LocalPoint | null;
startBinding: PointBinding | null;
endBinding: PointBinding | null;
startArrowhead: Arrowhead | null;
@ -309,10 +318,10 @@ export type ExcalidrawElbowArrowElement = Merge<
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
Readonly<{
type: "freedraw";
points: readonly Point[];
points: readonly LocalPoint[];
pressures: readonly number[];
simulatePressure: boolean;
lastCommittedPoint: Point | null;
lastCommittedPoint: LocalPoint | null;
}>;
export type FileId = string & { _brand: "FileId" };

View file

@ -11,7 +11,6 @@ import type {
NonDeleted,
NonDeletedExcalidrawElement,
} from "./element/types";
import { isPointWithinBounds } from "./math";
import {
getBoundTextElement,
getContainerElement,
@ -30,6 +29,7 @@ import { getElementLineSegments } from "./element/bounds";
import { doLineSegmentsIntersect, elementsOverlappingBBox } from "../utils/";
import { isFrameElement, isFrameLikeElement } from "./element/typeChecks";
import type { ReadonlySetLike } from "./utility-types";
import { isPointWithinBounds, point } from "../math";
// --------------------------- Frame State ------------------------------------
export const bindElementsToFramesAfterDuplication = (
@ -159,9 +159,9 @@ export const isCursorInFrame = (
const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame, elementsMap);
return isPointWithinBounds(
[fx1, fy1],
[cursorCoords.x, cursorCoords.y],
[fx2, fy2],
point(fx1, fy1),
point(cursorCoords.x, cursorCoords.y),
point(fx2, fy2),
);
};

View file

@ -162,6 +162,13 @@
"hint_emptyLibrary": "Select an item on canvas to add it here, or install a library from the public repository, below.",
"hint_emptyPrivateLibrary": "Select an item on canvas to add it here."
},
"search": {
"title": "Find on canvas",
"noMatch": "No matches found...",
"singleResult": "result",
"multipleResults": "results",
"placeholder": "Find text..."
},
"buttons": {
"clearReset": "Reset the canvas",
"exportJSON": "Export to file",
@ -297,6 +304,7 @@
"shapes": "Shapes"
},
"hints": {
"dismissSearch": "Escape to dismiss search",
"canvasPanning": "To move canvas, hold mouse wheel or spacebar while dragging, or use the hand tool",
"linearElement": "Click to start multiple points, drag for single line",
"arrowTool": "Click to start multiple points, drag for single line. Press {{arrowShortcut}} again to change arrow type.",

View file

@ -1,99 +0,0 @@
import {
isPointOnSymmetricArc,
rangeIntersection,
rangesOverlap,
rotate,
} from "./math";
describe("rotate", () => {
it("should rotate over (x2, y2) and return the rotated coordinates for (x1, y1)", () => {
const x1 = 10;
const y1 = 20;
const x2 = 20;
const y2 = 30;
const angle = Math.PI / 2;
const [rotatedX, rotatedY] = rotate(x1, y1, x2, y2, angle);
expect([rotatedX, rotatedY]).toEqual([30, 20]);
const res2 = rotate(rotatedX, rotatedY, x2, y2, -angle);
expect(res2).toEqual([x1, x2]);
});
});
describe("range overlap", () => {
it("should overlap when range a contains range b", () => {
expect(rangesOverlap([1, 4], [2, 3])).toBe(true);
expect(rangesOverlap([1, 4], [1, 4])).toBe(true);
expect(rangesOverlap([1, 4], [1, 3])).toBe(true);
expect(rangesOverlap([1, 4], [2, 4])).toBe(true);
});
it("should overlap when range b contains range a", () => {
expect(rangesOverlap([2, 3], [1, 4])).toBe(true);
expect(rangesOverlap([1, 3], [1, 4])).toBe(true);
expect(rangesOverlap([2, 4], [1, 4])).toBe(true);
});
it("should overlap when range a and b intersect", () => {
expect(rangesOverlap([1, 4], [2, 5])).toBe(true);
});
});
describe("range intersection", () => {
it("should intersect completely with itself", () => {
expect(rangeIntersection([1, 4], [1, 4])).toEqual([1, 4]);
});
it("should intersect irrespective of order", () => {
expect(rangeIntersection([1, 4], [2, 3])).toEqual([2, 3]);
expect(rangeIntersection([2, 3], [1, 4])).toEqual([2, 3]);
expect(rangeIntersection([1, 4], [3, 5])).toEqual([3, 4]);
expect(rangeIntersection([3, 5], [1, 4])).toEqual([3, 4]);
});
it("should intersect at the edge", () => {
expect(rangeIntersection([1, 4], [4, 5])).toEqual([4, 4]);
});
it("should not intersect", () => {
expect(rangeIntersection([1, 4], [5, 7])).toEqual(null);
});
});
describe("point on arc", () => {
it("should detect point on simple arc", () => {
expect(
isPointOnSymmetricArc(
{
radius: 1,
startAngle: -Math.PI / 4,
endAngle: Math.PI / 4,
},
[0.92291667, 0.385],
),
).toBe(true);
});
it("should not detect point outside of a simple arc", () => {
expect(
isPointOnSymmetricArc(
{
radius: 1,
startAngle: -Math.PI / 4,
endAngle: Math.PI / 4,
},
[-0.92291667, 0.385],
),
).toBe(false);
});
it("should not detect point with good angle but incorrect radius", () => {
expect(
isPointOnSymmetricArc(
{
radius: 1,
startAngle: -Math.PI / 4,
endAngle: Math.PI / 4,
},
[-0.5, 0.5],
),
).toBe(false);
});
});

View file

@ -1,715 +0,0 @@
import type {
NormalizedZoomValue,
NullableGridSize,
Point,
Zoom,
} from "./types";
import {
DEFAULT_ADAPTIVE_RADIUS,
LINE_CONFIRM_THRESHOLD,
DEFAULT_PROPORTIONAL_RADIUS,
ROUNDNESS,
} from "./constants";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
NonDeleted,
} from "./element/types";
import type { Bounds } from "./element/bounds";
import { getCurvePathOps } from "./element/bounds";
import type { Mutable } from "./utility-types";
import { ShapeCache } from "./scene/ShapeCache";
import type { Vector } from "../utils/geometry/shape";
export const rotate = (
// target point to rotate
x: number,
y: number,
// point to rotate against
cx: number,
cy: number,
angle: number,
): [number, number] =>
// 𝑎𝑥=(𝑎𝑥𝑐𝑥)cos𝜃(𝑎𝑦𝑐𝑦)sin𝜃+𝑐𝑥
// 𝑎𝑦=(𝑎𝑥𝑐𝑥)sin𝜃+(𝑎𝑦𝑐𝑦)cos𝜃+𝑐𝑦.
// https://math.stackexchange.com/questions/2204520/how-do-i-rotate-a-line-segment-in-a-specific-point-on-the-line
[
(x - cx) * Math.cos(angle) - (y - cy) * Math.sin(angle) + cx,
(x - cx) * Math.sin(angle) + (y - cy) * Math.cos(angle) + cy,
];
export const rotatePoint = (
point: Point,
center: Point,
angle: number,
): [number, number] => rotate(point[0], point[1], center[0], center[1], angle);
export const adjustXYWithRotation = (
sides: {
n?: boolean;
e?: boolean;
s?: boolean;
w?: boolean;
},
x: number,
y: number,
angle: number,
deltaX1: number,
deltaY1: number,
deltaX2: number,
deltaY2: number,
): [number, number] => {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
if (sides.e && sides.w) {
x += deltaX1 + deltaX2;
} else if (sides.e) {
x += deltaX1 * (1 + cos);
y += deltaX1 * sin;
x += deltaX2 * (1 - cos);
y += deltaX2 * -sin;
} else if (sides.w) {
x += deltaX1 * (1 - cos);
y += deltaX1 * -sin;
x += deltaX2 * (1 + cos);
y += deltaX2 * sin;
}
if (sides.n && sides.s) {
y += deltaY1 + deltaY2;
} else if (sides.n) {
x += deltaY1 * sin;
y += deltaY1 * (1 - cos);
x += deltaY2 * -sin;
y += deltaY2 * (1 + cos);
} else if (sides.s) {
x += deltaY1 * -sin;
y += deltaY1 * (1 + cos);
x += deltaY2 * sin;
y += deltaY2 * (1 - cos);
}
return [x, y];
};
export const getPointOnAPath = (point: Point, path: Point[]) => {
const [px, py] = point;
const [start, ...other] = path;
let [lastX, lastY] = start;
let kLine: number = 0;
let idx: number = 0;
// if any item in the array is true, it means that a point is
// on some segment of a line based path
const retVal = other.some(([x2, y2], i) => {
// we always take a line when dealing with line segments
const x1 = lastX;
const y1 = lastY;
lastX = x2;
lastY = y2;
// if a point is not within the domain of the line segment
// it is not on the line segment
if (px < x1 || px > x2) {
return false;
}
// check if all points lie on the same line
// y1 = kx1 + b, y2 = kx2 + b
// y2 - y1 = k(x2 - x2) -> k = (y2 - y1) / (x2 - x1)
// coefficient for the line (p0, p1)
const kL = (y2 - y1) / (x2 - x1);
// coefficient for the line segment (p0, point)
const kP1 = (py - y1) / (px - x1);
// coefficient for the line segment (point, p1)
const kP2 = (py - y2) / (px - x2);
// because we are basing both lines from the same starting point
// the only option for collinearity is having same coefficients
// using it for floating point comparisons
const epsilon = 0.3;
// if coefficient is more than an arbitrary epsilon,
// these lines are nor collinear
if (Math.abs(kP1 - kL) > epsilon && Math.abs(kP2 - kL) > epsilon) {
return false;
}
// store the coefficient because we are goint to need it
kLine = kL;
idx = i;
return true;
});
// Return a coordinate that is always on the line segment
if (retVal === true) {
return { x: point[0], y: kLine * point[0], segment: idx };
}
return null;
};
export const distance2d = (x1: number, y1: number, x2: number, y2: number) => {
const xd = x2 - x1;
const yd = y2 - y1;
return Math.hypot(xd, yd);
};
export const distanceSq2d = (p1: Point, p2: Point) => {
const xd = p2[0] - p1[0];
const yd = p2[1] - p1[1];
return xd * xd + yd * yd;
};
export const centerPoint = (a: Point, b: Point): Point => {
return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
};
// Checks if the first and last point are close enough
// to be considered a loop
export const isPathALoop = (
points: ExcalidrawLinearElement["points"],
/** supply if you want the loop detection to account for current zoom */
zoomValue: Zoom["value"] = 1 as NormalizedZoomValue,
): boolean => {
if (points.length >= 3) {
const [first, last] = [points[0], points[points.length - 1]];
const distance = distance2d(first[0], first[1], last[0], last[1]);
// Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in
// really close we make the threshold smaller, and vice versa.
return distance <= LINE_CONFIRM_THRESHOLD / zoomValue;
}
return false;
};
// Draw a line from the point to the right till infiinty
// Check how many lines of the polygon does this infinite line intersects with
// If the number of intersections is odd, point is in the polygon
export const isPointInPolygon = (
points: Point[],
x: number,
y: number,
): boolean => {
const vertices = points.length;
// There must be at least 3 vertices in polygon
if (vertices < 3) {
return false;
}
const extreme: Point = [Number.MAX_SAFE_INTEGER, y];
const p: Point = [x, y];
let count = 0;
for (let i = 0; i < vertices; i++) {
const current = points[i];
const next = points[(i + 1) % vertices];
if (doSegmentsIntersect(current, next, p, extreme)) {
if (orderedColinearOrientation(current, p, next) === 0) {
return isPointWithinBounds(current, p, next);
}
count++;
}
}
// true if count is off
return count % 2 === 1;
};
// Returns whether `q` lies inside the segment/rectangle defined by `p` and `r`.
// This is an approximation to "does `q` lie on a segment `pr`" check.
export const isPointWithinBounds = (p: Point, q: Point, r: Point) => {
return (
q[0] <= Math.max(p[0], r[0]) &&
q[0] >= Math.min(p[0], r[0]) &&
q[1] <= Math.max(p[1], r[1]) &&
q[1] >= Math.min(p[1], r[1])
);
};
// For the ordered points p, q, r, return
// 0 if p, q, r are colinear
// 1 if Clockwise
// 2 if counterclickwise
const orderedColinearOrientation = (p: Point, q: Point, r: Point) => {
const val = (q[1] - p[1]) * (r[0] - q[0]) - (q[0] - p[0]) * (r[1] - q[1]);
if (val === 0) {
return 0;
}
return val > 0 ? 1 : 2;
};
// Check is p1q1 intersects with p2q2
const doSegmentsIntersect = (p1: Point, q1: Point, p2: Point, q2: Point) => {
const o1 = orderedColinearOrientation(p1, q1, p2);
const o2 = orderedColinearOrientation(p1, q1, q2);
const o3 = orderedColinearOrientation(p2, q2, p1);
const o4 = orderedColinearOrientation(p2, q2, q1);
if (o1 !== o2 && o3 !== o4) {
return true;
}
// p1, q1 and p2 are colinear and p2 lies on segment p1q1
if (o1 === 0 && isPointWithinBounds(p1, p2, q1)) {
return true;
}
// p1, q1 and p2 are colinear and q2 lies on segment p1q1
if (o2 === 0 && isPointWithinBounds(p1, q2, q1)) {
return true;
}
// p2, q2 and p1 are colinear and p1 lies on segment p2q2
if (o3 === 0 && isPointWithinBounds(p2, p1, q2)) {
return true;
}
// p2, q2 and q1 are colinear and q1 lies on segment p2q2
if (o4 === 0 && isPointWithinBounds(p2, q1, q2)) {
return true;
}
return false;
};
// TODO: Rounding this point causes some shake when free drawing
export const getGridPoint = (
x: number,
y: number,
gridSize: NullableGridSize,
): [number, number] => {
if (gridSize) {
return [
Math.round(x / gridSize) * gridSize,
Math.round(y / gridSize) * gridSize,
];
}
return [x, y];
};
export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
if (
element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS ||
element.roundness?.type === ROUNDNESS.LEGACY
) {
return x * DEFAULT_PROPORTIONAL_RADIUS;
}
if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) {
const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS;
const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS;
if (x <= CUTOFF_SIZE) {
return x * DEFAULT_PROPORTIONAL_RADIUS;
}
return fixedRadiusSize;
}
return 0;
};
export const getControlPointsForBezierCurve = (
element: NonDeleted<ExcalidrawLinearElement>,
endPoint: Point,
) => {
const shape = ShapeCache.generateElementShape(element, null);
if (!shape) {
return null;
}
const ops = getCurvePathOps(shape[0]);
let currentP: Mutable<Point> = [0, 0];
let index = 0;
let minDistance = Infinity;
let controlPoints: Mutable<Point>[] | null = null;
while (index < ops.length) {
const { op, data } = ops[index];
if (op === "move") {
currentP = data as unknown as Mutable<Point>;
}
if (op === "bcurveTo") {
const p0 = currentP;
const p1 = [data[0], data[1]] as Mutable<Point>;
const p2 = [data[2], data[3]] as Mutable<Point>;
const p3 = [data[4], data[5]] as Mutable<Point>;
const distance = distance2d(p3[0], p3[1], endPoint[0], endPoint[1]);
if (distance < minDistance) {
minDistance = distance;
controlPoints = [p0, p1, p2, p3];
}
currentP = p3;
}
index++;
}
return controlPoints;
};
export const getBezierXY = (
p0: Point,
p1: Point,
p2: Point,
p3: Point,
t: number,
) => {
const equation = (t: number, idx: number) =>
Math.pow(1 - t, 3) * p3[idx] +
3 * t * Math.pow(1 - t, 2) * p2[idx] +
3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
p0[idx] * Math.pow(t, 3);
const tx = equation(t, 0);
const ty = equation(t, 1);
return [tx, ty];
};
export const getPointsInBezierCurve = (
element: NonDeleted<ExcalidrawLinearElement>,
endPoint: Point,
) => {
const controlPoints: Mutable<Point>[] = getControlPointsForBezierCurve(
element,
endPoint,
)!;
if (!controlPoints) {
return [];
}
const pointsOnCurve: Mutable<Point>[] = [];
let t = 1;
// Take 20 points on curve for better accuracy
while (t > 0) {
const point = getBezierXY(
controlPoints[0],
controlPoints[1],
controlPoints[2],
controlPoints[3],
t,
);
pointsOnCurve.push([point[0], point[1]]);
t -= 0.05;
}
if (pointsOnCurve.length) {
if (arePointsEqual(pointsOnCurve.at(-1)!, endPoint)) {
pointsOnCurve.push([endPoint[0], endPoint[1]]);
}
}
return pointsOnCurve;
};
export const getBezierCurveArcLengths = (
element: NonDeleted<ExcalidrawLinearElement>,
endPoint: Point,
) => {
const arcLengths: number[] = [];
arcLengths[0] = 0;
const points = getPointsInBezierCurve(element, endPoint);
let index = 0;
let distance = 0;
while (index < points.length - 1) {
const segmentDistance = distance2d(
points[index][0],
points[index][1],
points[index + 1][0],
points[index + 1][1],
);
distance += segmentDistance;
arcLengths.push(distance);
index++;
}
return arcLengths;
};
export const getBezierCurveLength = (
element: NonDeleted<ExcalidrawLinearElement>,
endPoint: Point,
) => {
const arcLengths = getBezierCurveArcLengths(element, endPoint);
return arcLengths.at(-1) as number;
};
// This maps interval to actual interval t on the curve so that when t = 0.5, its actually the point at 50% of the length
export const mapIntervalToBezierT = (
element: NonDeleted<ExcalidrawLinearElement>,
endPoint: Point,
interval: number, // The interval between 0 to 1 for which you want to find the point on the curve,
) => {
const arcLengths = getBezierCurveArcLengths(element, endPoint);
const pointsCount = arcLengths.length - 1;
const curveLength = arcLengths.at(-1) as number;
const targetLength = interval * curveLength;
let low = 0;
let high = pointsCount;
let index = 0;
// Doing a binary search to find the largest length that is less than the target length
while (low < high) {
index = Math.floor(low + (high - low) / 2);
if (arcLengths[index] < targetLength) {
low = index + 1;
} else {
high = index;
}
}
if (arcLengths[index] > targetLength) {
index--;
}
if (arcLengths[index] === targetLength) {
return index / pointsCount;
}
return (
1 -
(index +
(targetLength - arcLengths[index]) /
(arcLengths[index + 1] - arcLengths[index])) /
pointsCount
);
};
export const arePointsEqual = (p1: Point, p2: Point) => {
return p1[0] === p2[0] && p1[1] === p2[1];
};
export const isRightAngle = (angle: number) => {
// if our angles were mathematically accurate, we could just check
//
// angle % (Math.PI / 2) === 0
//
// but since we're in floating point land, we need to round.
//
// Below, after dividing by Math.PI, a multiple of 0.5 indicates a right
// angle, which we can check with modulo after rounding.
return Math.round((angle / Math.PI) * 10000) % 5000 === 0;
};
export const radianToDegree = (r: number) => {
return (r * 180) / Math.PI;
};
export const degreeToRadian = (d: number) => {
return (d / 180) * Math.PI;
};
// Given two ranges, return if the two ranges overlap with each other
// e.g. [1, 3] overlaps with [2, 4] while [1, 3] does not overlap with [4, 5]
export const rangesOverlap = (
[a0, a1]: [number, number],
[b0, b1]: [number, number],
) => {
if (a0 <= b0) {
return a1 >= b0;
}
if (a0 >= b0) {
return b1 >= a0;
}
return false;
};
// Given two ranges,return ther intersection of the two ranges if any
// e.g. the intersection of [1, 3] and [2, 4] is [2, 3]
export const rangeIntersection = (
rangeA: [number, number],
rangeB: [number, number],
): [number, number] | null => {
const rangeStart = Math.max(rangeA[0], rangeB[0]);
const rangeEnd = Math.min(rangeA[1], rangeB[1]);
if (rangeStart <= rangeEnd) {
return [rangeStart, rangeEnd];
}
return null;
};
export const isValueInRange = (value: number, min: number, max: number) => {
return value >= min && value <= max;
};
export const translatePoint = (p: Point, v: Vector): Point => [
p[0] + v[0],
p[1] + v[1],
];
export const scaleVector = (v: Vector, scalar: number): Vector => [
v[0] * scalar,
v[1] * scalar,
];
export const pointToVector = (p: Point, origin: Point = [0, 0]): Vector => [
p[0] - origin[0],
p[1] - origin[1],
];
export const scalePointFromOrigin = (
p: Point,
mid: Point,
multiplier: number,
) => translatePoint(mid, scaleVector(pointToVector(p, mid), multiplier));
const triangleSign = (p1: Point, p2: Point, p3: Point): number =>
(p1[0] - p3[0]) * (p2[1] - p3[1]) - (p2[0] - p3[0]) * (p1[1] - p3[1]);
export const PointInTriangle = (pt: Point, v1: Point, v2: Point, v3: Point) => {
const d1 = triangleSign(pt, v1, v2);
const d2 = triangleSign(pt, v2, v3);
const d3 = triangleSign(pt, v3, v1);
const has_neg = d1 < 0 || d2 < 0 || d3 < 0;
const has_pos = d1 > 0 || d2 > 0 || d3 > 0;
return !(has_neg && has_pos);
};
export const magnitudeSq = (vector: Vector) =>
vector[0] * vector[0] + vector[1] * vector[1];
export const magnitude = (vector: Vector) => Math.sqrt(magnitudeSq(vector));
export const normalize = (vector: Vector): Vector => {
const m = magnitude(vector);
return [vector[0] / m, vector[1] / m];
};
export const addVectors = (
vec1: Readonly<Vector>,
vec2: Readonly<Vector>,
): Vector => [vec1[0] + vec2[0], vec1[1] + vec2[1]];
export const subtractVectors = (
vec1: Readonly<Vector>,
vec2: Readonly<Vector>,
): Vector => [vec1[0] - vec2[0], vec1[1] - vec2[1]];
export const pointInsideBounds = (p: Point, bounds: Bounds): boolean =>
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
/**
* Get the axis-aligned bounding box for a given element
*/
export const aabbForElement = (
element: Readonly<ExcalidrawElement>,
offset?: [number, number, number, number],
) => {
const bbox = {
minX: element.x,
minY: element.y,
maxX: element.x + element.width,
maxY: element.y + element.height,
midX: element.x + element.width / 2,
midY: element.y + element.height / 2,
};
const center = [bbox.midX, bbox.midY] as Point;
const [topLeftX, topLeftY] = rotatePoint(
[bbox.minX, bbox.minY],
center,
element.angle,
);
const [topRightX, topRightY] = rotatePoint(
[bbox.maxX, bbox.minY],
center,
element.angle,
);
const [bottomRightX, bottomRightY] = rotatePoint(
[bbox.maxX, bbox.maxY],
center,
element.angle,
);
const [bottomLeftX, bottomLeftY] = rotatePoint(
[bbox.minX, bbox.maxY],
center,
element.angle,
);
const bounds = [
Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
] as Bounds;
if (offset) {
const [topOffset, rightOffset, downOffset, leftOffset] = offset;
return [
bounds[0] - leftOffset,
bounds[1] - topOffset,
bounds[2] + rightOffset,
bounds[3] + downOffset,
] as Bounds;
}
return bounds;
};
type PolarCoords = [number, number];
/**
* Return the polar coordinates for the given carthesian point represented by
* (x, y) for the center point 0,0 where the first number returned is the radius,
* the second is the angle in radians.
*/
export const carthesian2Polar = ([x, y]: Point): PolarCoords => [
Math.hypot(x, y),
Math.atan2(y, x),
];
/**
* Angles are in radians and centered on 0, 0. Zero radians on a 1 radius circle
* corresponds to (1, 0) carthesian coordinates (point), i.e. to the "right".
*/
type SymmetricArc = { radius: number; startAngle: number; endAngle: number };
/**
* Determines if a carthesian point lies on a symmetric arc, i.e. an arc which
* is part of a circle contour centered on 0, 0.
*/
export const isPointOnSymmetricArc = (
{ radius: arcRadius, startAngle, endAngle }: SymmetricArc,
point: Point,
): boolean => {
const [radius, angle] = carthesian2Polar(point);
return startAngle < endAngle
? Math.abs(radius - arcRadius) < 0.0000001 &&
startAngle <= angle &&
endAngle >= angle
: startAngle <= angle || endAngle >= angle;
};
export const getCenterForBounds = (bounds: Bounds): Point => [
bounds[0] + (bounds[2] - bounds[0]) / 2,
bounds[1] + (bounds[3] - bounds[1]) / 2,
];
export const getCenterForElement = (element: ExcalidrawElement): Point => [
element.x + element.width / 2,
element.y + element.height / 2,
];
export const aabbsOverlapping = (a: Bounds, b: Bounds) =>
pointInsideBounds([a[0], a[1]], b) ||
pointInsideBounds([a[2], a[1]], b) ||
pointInsideBounds([a[2], a[3]], b) ||
pointInsideBounds([a[0], a[3]], b) ||
pointInsideBounds([b[0], b[1]], a) ||
pointInsideBounds([b[2], b[1]], a) ||
pointInsideBounds([b[2], b[3]], a) ||
pointInsideBounds([b[0], b[3]], a);
export const clamp = (value: number, min: number, max: number) => {
return Math.min(Math.max(value, min), max);
};
export const round = (value: number, precision: number) => {
const multiplier = Math.pow(10, precision);
return Math.round((value + Number.EPSILON) * multiplier) / multiplier;
};

View file

@ -1,6 +1,8 @@
import type { Point } from "./types";
import { pointFromPair, type GlobalPoint, type LocalPoint } from "../math";
export const getSizeFromPoints = (points: readonly Point[]) => {
export const getSizeFromPoints = (
points: readonly (GlobalPoint | LocalPoint)[],
) => {
const xs = points.map((point) => point[0]);
const ys = points.map((point) => point[1]);
return {
@ -10,7 +12,7 @@ export const getSizeFromPoints = (points: readonly Point[]) => {
};
/** @arg dimension, 0 for rescaling only x, 1 for y */
export const rescalePoints = (
export const rescalePoints = <Point extends GlobalPoint | LocalPoint>(
dimension: 0 | 1,
newSize: number,
points: readonly Point[],
@ -31,7 +33,7 @@ export const rescalePoints = (
if (newCoordinate < nextMinCoordinate) {
nextMinCoordinate = newCoordinate;
}
return newPoint as unknown as Point;
return newPoint as Point;
});
if (!normalize) {
@ -45,11 +47,13 @@ export const rescalePoints = (
const translation = minCoordinate - nextMinCoordinate;
const nextPoints = scaledPoints.map(
(scaledPoint) =>
const nextPoints = scaledPoints.map((scaledPoint) =>
pointFromPair<Point>(
scaledPoint.map((value, currentDimension) => {
return currentDimension === dimension ? value + translation : value;
}) as [number, number],
),
);
return nextPoints;
};

View file

@ -30,8 +30,12 @@ import {
shouldShowBoundingBox,
} from "../element/transformHandles";
import { arrayToMap, throttleRAF } from "../utils";
import type { InteractiveCanvasAppState, Point } from "../types";
import { DEFAULT_TRANSFORM_HANDLE_SPACING, FRAME_STYLE } from "../constants";
import {
DEFAULT_TRANSFORM_HANDLE_SPACING,
FRAME_STYLE,
THEME,
} from "../constants";
import { type InteractiveCanvasAppState } from "../types";
import { renderSnaps } from "../renderer/renderSnaps";
@ -69,7 +73,8 @@ import type {
InteractiveSceneRenderConfig,
RenderableElementsMap,
} from "../scene/types";
import { getCornerRadius } from "../math";
import type { GlobalPoint, LocalPoint, Radians } from "../../math";
import { getCornerRadius } from "../shapes";
const renderLinearElementPointHighlight = (
context: CanvasRenderingContext2D,
@ -101,7 +106,7 @@ const renderLinearElementPointHighlight = (
context.restore();
};
const highlightPoint = (
const highlightPoint = <Point extends LocalPoint | GlobalPoint>(
point: Point,
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
@ -168,7 +173,7 @@ const strokeDiamondWithRotation = (
context.restore();
};
const renderSingleLinearPoint = (
const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
point: Point,
@ -499,7 +504,7 @@ const renderLinearPointHandles = (
element,
elementsMap,
appState,
).filter((midPoint) => midPoint !== null) as Point[];
).filter((midPoint): midPoint is GlobalPoint => midPoint !== null);
midPoints.forEach((segmentMidPoint) => {
if (
@ -931,7 +936,7 @@ const _renderInteractiveScene = ({
context.setLineDash(initialLineDash);
const transformHandles = getTransformHandlesFromCoords(
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
0,
0 as Radians,
appState.zoom,
"mouse",
isFrameSelected
@ -951,9 +956,48 @@ const _renderInteractiveScene = ({
context.restore();
}
appState.searchMatches.forEach(({ id, focus, matchedLines }) => {
const element = elementsMap.get(id);
if (element && isTextElement(element)) {
const [elementX1, elementY1, , , cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
true,
);
context.save();
if (appState.theme === THEME.LIGHT) {
if (focus) {
context.fillStyle = "rgba(255, 124, 0, 0.4)";
} else {
context.fillStyle = "rgba(255, 226, 0, 0.4)";
}
} else if (focus) {
context.fillStyle = "rgba(229, 82, 0, 0.4)";
} else {
context.fillStyle = "rgba(99, 52, 0, 0.4)";
}
context.translate(appState.scrollX, appState.scrollY);
context.translate(cx, cy);
context.rotate(element.angle);
matchedLines.forEach((matchedLine) => {
context.fillRect(
elementX1 + matchedLine.offsetX - cx,
elementY1 + matchedLine.offsetY - cy,
matchedLine.width,
matchedLine.height,
);
});
context.restore();
}
});
renderSnaps(context, appState);
// Reset zoom
context.restore();
renderRemoteCursors({

View file

@ -27,7 +27,6 @@ import type {
InteractiveCanvasRenderConfig,
} from "../scene/types";
import { distance, getFontString, isRTL } from "../utils";
import { getCornerRadius, isRightAngle } from "../math";
import rough from "roughjs/bin/rough";
import type {
AppState,
@ -60,6 +59,8 @@ import { LinearElementEditor } from "../element/linearElementEditor";
import { getContainingFrame } from "../frame";
import { ShapeCache } from "../scene/ShapeCache";
import { getVerticalOffset } from "../fonts";
import { isRightAngleRads } from "../../math";
import { getCornerRadius } from "../shapes";
// using a stronger invert (100% vs our regular 93%) and saturate
// as a temp hack to make images in dark theme look closer to original
@ -907,7 +908,8 @@ export const renderElement = (
(!element.angle ||
// or check if angle is a right angle in which case we can still
// disable smoothing without adversely affecting the result
isRightAngle(element.angle))
// We need less-than comparison because of FP artihmetic
isRightAngleRads(element.angle))
) {
// Disabling smoothing makes output much sharper, especially for
// text. Unless for non-right angles, where the aliasing is really

View file

@ -1,6 +1,7 @@
import { point, type GlobalPoint, type LocalPoint } from "../../math";
import { THEME } from "../constants";
import type { PointSnapLine, PointerSnapLine } from "../snapping";
import type { InteractiveCanvasAppState, Point } from "../types";
import type { InteractiveCanvasAppState } from "../types";
const SNAP_COLOR_LIGHT = "#ff6b6b";
const SNAP_COLOR_DARK = "#ff0000";
@ -85,7 +86,7 @@ const drawPointerSnapLine = (
}
};
const drawCross = (
const drawCross = <Point extends LocalPoint | GlobalPoint>(
[x, y]: Point,
appState: InteractiveCanvasAppState,
context: CanvasRenderingContext2D,
@ -106,18 +107,18 @@ const drawCross = (
context.restore();
};
const drawLine = (
const drawLine = <Point extends LocalPoint | GlobalPoint>(
from: Point,
to: Point,
context: CanvasRenderingContext2D,
) => {
context.beginPath();
context.lineTo(...from);
context.lineTo(...to);
context.lineTo(from[0], from[1]);
context.lineTo(to[0], to[1]);
context.stroke();
};
const drawGapLine = (
const drawGapLine = <Point extends LocalPoint | GlobalPoint>(
from: Point,
to: Point,
direction: "horizontal" | "vertical",
@ -138,24 +139,28 @@ const drawGapLine = (
const halfPoint = [(from[0] + to[0]) / 2, from[1]];
// (1)
if (!appState.zenModeEnabled) {
drawLine([from[0], from[1] - FULL], [from[0], from[1] + FULL], context);
drawLine(
point(from[0], from[1] - FULL),
point(from[0], from[1] + FULL),
context,
);
}
// (3)
drawLine(
[halfPoint[0] - QUARTER, halfPoint[1] - HALF],
[halfPoint[0] - QUARTER, halfPoint[1] + HALF],
point(halfPoint[0] - QUARTER, halfPoint[1] - HALF),
point(halfPoint[0] - QUARTER, halfPoint[1] + HALF),
context,
);
drawLine(
[halfPoint[0] + QUARTER, halfPoint[1] - HALF],
[halfPoint[0] + QUARTER, halfPoint[1] + HALF],
point(halfPoint[0] + QUARTER, halfPoint[1] - HALF),
point(halfPoint[0] + QUARTER, halfPoint[1] + HALF),
context,
);
if (!appState.zenModeEnabled) {
// (4)
drawLine([to[0], to[1] - FULL], [to[0], to[1] + FULL], context);
drawLine(point(to[0], to[1] - FULL), point(to[0], to[1] + FULL), context);
// (2)
drawLine(from, to, context);
@ -164,24 +169,28 @@ const drawGapLine = (
const halfPoint = [from[0], (from[1] + to[1]) / 2];
// (1)
if (!appState.zenModeEnabled) {
drawLine([from[0] - FULL, from[1]], [from[0] + FULL, from[1]], context);
drawLine(
point(from[0] - FULL, from[1]),
point(from[0] + FULL, from[1]),
context,
);
}
// (3)
drawLine(
[halfPoint[0] - HALF, halfPoint[1] - QUARTER],
[halfPoint[0] + HALF, halfPoint[1] - QUARTER],
point(halfPoint[0] - HALF, halfPoint[1] - QUARTER),
point(halfPoint[0] + HALF, halfPoint[1] - QUARTER),
context,
);
drawLine(
[halfPoint[0] - HALF, halfPoint[1] + QUARTER],
[halfPoint[0] + HALF, halfPoint[1] + QUARTER],
point(halfPoint[0] - HALF, halfPoint[1] + QUARTER),
point(halfPoint[0] + HALF, halfPoint[1] + QUARTER),
context,
);
if (!appState.zenModeEnabled) {
// (4)
drawLine([to[0] - FULL, to[1]], [to[0] + FULL, to[1]], context);
drawLine(point(to[0] - FULL, to[1]), point(to[0] + FULL, to[1]), context);
// (2)
drawLine(from, to, context);

View file

@ -30,13 +30,13 @@ import type {
NonDeletedExcalidrawElement,
} from "../element/types";
import { getContainingFrame } from "../frame";
import { getCornerRadius, isPathALoop } from "../math";
import { ShapeCache } from "../scene/ShapeCache";
import type { RenderableElementsMap, SVGRenderConfig } from "../scene/types";
import type { AppState, BinaryFiles } from "../types";
import { getFontFamilyString, isRTL, isTestEnv } from "../utils";
import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement";
import { getVerticalOffset } from "../fonts";
import { getCornerRadius, isPathALoop } from "../shapes";
const roughSVGDrawWithPrecision = (
rsvg: RoughSVG,

View file

@ -1,3 +1,4 @@
import type { Point as RoughPoint } from "roughjs/bin/geometry";
import type { Drawable, Options } from "roughjs/bin/core";
import type { RoughGenerator } from "roughjs/bin/generator";
import { getDiamondPoints, getArrowheadPoints } from "../element";
@ -9,7 +10,6 @@ import type {
ExcalidrawLinearElement,
Arrowhead,
} from "../element/types";
import { isPathALoop, getCornerRadius, distanceSq2d } from "../math";
import { generateFreeDrawShape } from "../renderer/renderElement";
import { isTransparent, assertNever } from "../utils";
import { simplify } from "points-on-curve";
@ -23,6 +23,13 @@ import {
} from "../element/typeChecks";
import { canChangeRoundness } from "./comparisons";
import type { EmbedsValidationStatus } from "../types";
import {
point,
pointDistance,
type GlobalPoint,
type LocalPoint,
} from "../../math";
import { getCornerRadius, isPathALoop } from "../shapes";
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
@ -399,12 +406,14 @@ export const _generateElementShape = (
// points array can be empty in the beginning, so it is important to add
// initial position to it
const points = element.points.length ? element.points : [[0, 0]];
const points = element.points.length
? element.points
: [point<LocalPoint>(0, 0)];
if (isElbowArrow(element)) {
shape = [
generator.path(
generateElbowArrowShape(points as [number, number][], 16),
generateElbowArrowShape(points, 16),
generateRoughOptions(element, true),
),
];
@ -412,12 +421,16 @@ export const _generateElementShape = (
// curve is always the first element
// this simplifies finding the curve for an element
if (options.fill) {
shape = [generator.polygon(points as [number, number][], options)];
shape = [
generator.polygon(points as unknown as RoughPoint[], options),
];
} else {
shape = [generator.linearPath(points as [number, number][], options)];
shape = [
generator.linearPath(points as unknown as RoughPoint[], options),
];
}
} else {
shape = [generator.curve(points as [number, number][], options)];
shape = [generator.curve(points as unknown as RoughPoint[], options)];
}
// add lines only in arrow
@ -491,8 +504,8 @@ export const _generateElementShape = (
}
};
const generateElbowArrowShape = (
points: [number, number][],
const generateElbowArrowShape = <Point extends GlobalPoint | LocalPoint>(
points: readonly Point[],
radius: number,
) => {
const subpoints = [] as [number, number][];
@ -501,8 +514,8 @@ const generateElbowArrowShape = (
const next = points[i + 1];
const corner = Math.min(
radius,
Math.sqrt(distanceSq2d(points[i], next)) / 2,
Math.sqrt(distanceSq2d(points[i], prev)) / 2,
pointDistance(points[i], next) / 2,
pointDistance(points[i], prev) / 2,
);
if (prev[0] < points[i][0] && prev[1] === points[i][1]) {

View file

@ -1,5 +1,5 @@
import { clamp, round } from "../../math";
import { MAX_ZOOM, MIN_ZOOM } from "../constants";
import { clamp, round } from "../math";
import type { NormalizedZoomValue } from "../types";
export const getNormalizedZoom = (zoom: number): NormalizedZoomValue => {

View file

@ -1,5 +1,16 @@
import {
isPoint,
point,
pointDistance,
pointFromPair,
pointRotateRads,
pointsEqual,
type GlobalPoint,
type LocalPoint,
} from "../math";
import {
getClosedCurveShape,
getCurvePathOps,
getCurveShape,
getEllipseShape,
getFreedrawShape,
@ -18,13 +29,27 @@ import {
SelectionIcon,
TextIcon,
} from "./components/icons";
import {
DEFAULT_ADAPTIVE_RADIUS,
DEFAULT_PROPORTIONAL_RADIUS,
LINE_CONFIRM_THRESHOLD,
ROUNDNESS,
} from "./constants";
import { getElementAbsoluteCoords } from "./element";
import type { Bounds } from "./element/bounds";
import { shouldTestInside } from "./element/collision";
import { LinearElementEditor } from "./element/linearElementEditor";
import { getBoundTextElement } from "./element/textElement";
import type { ElementsMap, ExcalidrawElement } from "./element/types";
import type {
ElementsMap,
ExcalidrawElement,
ExcalidrawLinearElement,
NonDeleted,
} from "./element/types";
import { KEYS } from "./keys";
import { ShapeCache } from "./scene/ShapeCache";
import type { NormalizedZoomValue, Zoom } from "./types";
import { invariant } from "./utils";
export const SHAPES = [
{
@ -116,10 +141,10 @@ export const findShapeByKey = (key: string) => {
* get the pure geometric shape of an excalidraw element
* which is then used for hit detection
*/
export const getElementShape = (
export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
element: ExcalidrawElement,
elementsMap: ElementsMap,
): GeometricShape => {
): GeometricShape<Point> => {
switch (element.type) {
case "rectangle":
case "diamond":
@ -139,17 +164,19 @@ export const getElementShape = (
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
return shouldTestInside(element)
? getClosedCurveShape(
? getClosedCurveShape<Point>(
element,
roughShape,
[element.x, element.y],
point<Point>(element.x, element.y),
element.angle,
[cx, cy],
point(cx, cy),
)
: getCurveShape(roughShape, [element.x, element.y], element.angle, [
cx,
cy,
]);
: getCurveShape<Point>(
roughShape,
point<Point>(element.x, element.y),
element.angle,
point(cx, cy),
);
}
case "ellipse":
@ -157,15 +184,19 @@ export const getElementShape = (
case "freedraw": {
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
return getFreedrawShape(element, [cx, cy], shouldTestInside(element));
return getFreedrawShape(
element,
point(cx, cy),
shouldTestInside(element),
);
}
}
};
export const getBoundTextShape = (
export const getBoundTextShape = <Point extends GlobalPoint | LocalPoint>(
element: ExcalidrawElement,
elementsMap: ElementsMap,
): GeometricShape | null => {
): GeometricShape<Point> | null => {
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
@ -189,3 +220,274 @@ export const getBoundTextShape = (
return null;
};
export const getControlPointsForBezierCurve = <
P extends GlobalPoint | LocalPoint,
>(
element: NonDeleted<ExcalidrawLinearElement>,
endPoint: P,
) => {
const shape = ShapeCache.generateElementShape(element, null);
if (!shape) {
return null;
}
const ops = getCurvePathOps(shape[0]);
let currentP = point<P>(0, 0);
let index = 0;
let minDistance = Infinity;
let controlPoints: P[] | null = null;
while (index < ops.length) {
const { op, data } = ops[index];
if (op === "move") {
invariant(
isPoint(data),
"The returned ops is not compatible with a point",
);
currentP = pointFromPair(data);
}
if (op === "bcurveTo") {
const p0 = currentP;
const p1 = point<P>(data[0], data[1]);
const p2 = point<P>(data[2], data[3]);
const p3 = point<P>(data[4], data[5]);
const distance = pointDistance(p3, endPoint);
if (distance < minDistance) {
minDistance = distance;
controlPoints = [p0, p1, p2, p3];
}
currentP = p3;
}
index++;
}
return controlPoints;
};
export const getBezierXY = <P extends GlobalPoint | LocalPoint>(
p0: P,
p1: P,
p2: P,
p3: P,
t: number,
): P => {
const equation = (t: number, idx: number) =>
Math.pow(1 - t, 3) * p3[idx] +
3 * t * Math.pow(1 - t, 2) * p2[idx] +
3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
p0[idx] * Math.pow(t, 3);
const tx = equation(t, 0);
const ty = equation(t, 1);
return point(tx, ty);
};
const getPointsInBezierCurve = <P extends GlobalPoint | LocalPoint>(
element: NonDeleted<ExcalidrawLinearElement>,
endPoint: P,
) => {
const controlPoints: P[] = getControlPointsForBezierCurve(element, endPoint)!;
if (!controlPoints) {
return [];
}
const pointsOnCurve: P[] = [];
let t = 1;
// Take 20 points on curve for better accuracy
while (t > 0) {
const p = getBezierXY(
controlPoints[0],
controlPoints[1],
controlPoints[2],
controlPoints[3],
t,
);
pointsOnCurve.push(point(p[0], p[1]));
t -= 0.05;
}
if (pointsOnCurve.length) {
if (pointsEqual(pointsOnCurve.at(-1)!, endPoint)) {
pointsOnCurve.push(point(endPoint[0], endPoint[1]));
}
}
return pointsOnCurve;
};
const getBezierCurveArcLengths = <P extends GlobalPoint | LocalPoint>(
element: NonDeleted<ExcalidrawLinearElement>,
endPoint: P,
) => {
const arcLengths: number[] = [];
arcLengths[0] = 0;
const points = getPointsInBezierCurve(element, endPoint);
let index = 0;
let distance = 0;
while (index < points.length - 1) {
const segmentDistance = pointDistance(points[index], points[index + 1]);
distance += segmentDistance;
arcLengths.push(distance);
index++;
}
return arcLengths;
};
export const getBezierCurveLength = <P extends GlobalPoint | LocalPoint>(
element: NonDeleted<ExcalidrawLinearElement>,
endPoint: P,
) => {
const arcLengths = getBezierCurveArcLengths(element, endPoint);
return arcLengths.at(-1) as number;
};
// This maps interval to actual interval t on the curve so that when t = 0.5, its actually the point at 50% of the length
export const mapIntervalToBezierT = <P extends GlobalPoint | LocalPoint>(
element: NonDeleted<ExcalidrawLinearElement>,
endPoint: P,
interval: number, // The interval between 0 to 1 for which you want to find the point on the curve,
) => {
const arcLengths = getBezierCurveArcLengths(element, endPoint);
const pointsCount = arcLengths.length - 1;
const curveLength = arcLengths.at(-1) as number;
const targetLength = interval * curveLength;
let low = 0;
let high = pointsCount;
let index = 0;
// Doing a binary search to find the largest length that is less than the target length
while (low < high) {
index = Math.floor(low + (high - low) / 2);
if (arcLengths[index] < targetLength) {
low = index + 1;
} else {
high = index;
}
}
if (arcLengths[index] > targetLength) {
index--;
}
if (arcLengths[index] === targetLength) {
return index / pointsCount;
}
return (
1 -
(index +
(targetLength - arcLengths[index]) /
(arcLengths[index + 1] - arcLengths[index])) /
pointsCount
);
};
/**
* Get the axis-aligned bounding box for a given element
*/
export const aabbForElement = (
element: Readonly<ExcalidrawElement>,
offset?: [number, number, number, number],
) => {
const bbox = {
minX: element.x,
minY: element.y,
maxX: element.x + element.width,
maxY: element.y + element.height,
midX: element.x + element.width / 2,
midY: element.y + element.height / 2,
};
const center = point(bbox.midX, bbox.midY);
const [topLeftX, topLeftY] = pointRotateRads(
point(bbox.minX, bbox.minY),
center,
element.angle,
);
const [topRightX, topRightY] = pointRotateRads(
point(bbox.maxX, bbox.minY),
center,
element.angle,
);
const [bottomRightX, bottomRightY] = pointRotateRads(
point(bbox.maxX, bbox.maxY),
center,
element.angle,
);
const [bottomLeftX, bottomLeftY] = pointRotateRads(
point(bbox.minX, bbox.maxY),
center,
element.angle,
);
const bounds = [
Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
] as Bounds;
if (offset) {
const [topOffset, rightOffset, downOffset, leftOffset] = offset;
return [
bounds[0] - leftOffset,
bounds[1] - topOffset,
bounds[2] + rightOffset,
bounds[3] + downOffset,
] as Bounds;
}
return bounds;
};
export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
p: P,
bounds: Bounds,
): boolean =>
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
export const aabbsOverlapping = (a: Bounds, b: Bounds) =>
pointInsideBounds(point(a[0], a[1]), b) ||
pointInsideBounds(point(a[2], a[1]), b) ||
pointInsideBounds(point(a[2], a[3]), b) ||
pointInsideBounds(point(a[0], a[3]), b) ||
pointInsideBounds(point(b[0], b[1]), a) ||
pointInsideBounds(point(b[2], b[1]), a) ||
pointInsideBounds(point(b[2], b[3]), a) ||
pointInsideBounds(point(b[0], b[3]), a);
export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
if (
element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS ||
element.roundness?.type === ROUNDNESS.LEGACY
) {
return x * DEFAULT_PROPORTIONAL_RADIUS;
}
if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) {
const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS;
const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS;
if (x <= CUTOFF_SIZE) {
return x * DEFAULT_PROPORTIONAL_RADIUS;
}
return fixedRadiusSize;
}
return 0;
};
// Checks if the first and last point are close enough
// to be considered a loop
export const isPathALoop = (
points: ExcalidrawLinearElement["points"],
/** supply if you want the loop detection to account for current zoom */
zoomValue: Zoom["value"] = 1 as NormalizedZoomValue,
): boolean => {
if (points.length >= 3) {
const [first, last] = [points[0], points[points.length - 1]];
const distance = pointDistance(first, last);
// Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in
// really close we make the threshold smaller, and vice versa.
return distance <= LINE_CONFIRM_THRESHOLD / zoomValue;
}
return false;
};

View file

@ -1,3 +1,12 @@
import type { InclusiveRange } from "../math";
import {
point,
pointRotateRads,
rangeInclusive,
rangeIntersection,
rangesOverlap,
type GlobalPoint,
} from "../math";
import { TOOL_TYPE } from "./constants";
import type { Bounds } from "./element/bounds";
import {
@ -14,7 +23,6 @@ import type {
} from "./element/types";
import { getMaximumGroups } from "./groups";
import { KEYS } from "./keys";
import { rangeIntersection, rangesOverlap, rotatePoint } from "./math";
import {
getSelectedElements,
getVisibleAndNonSelectedElements,
@ -23,7 +31,7 @@ import type {
AppClassProperties,
AppState,
KeyboardModifiersObject,
Point,
NullableGridSize,
} from "./types";
const SNAP_DISTANCE = 8;
@ -42,7 +50,7 @@ type Vector2D = {
y: number;
};
type PointPair = [Point, Point];
type PointPair = [GlobalPoint, GlobalPoint];
export type PointSnap = {
type: "point";
@ -62,9 +70,9 @@ export type Gap = {
// ↑ end side
startBounds: Bounds;
endBounds: Bounds;
startSide: [Point, Point];
endSide: [Point, Point];
overlap: [number, number];
startSide: [GlobalPoint, GlobalPoint];
endSide: [GlobalPoint, GlobalPoint];
overlap: InclusiveRange;
length: number;
};
@ -88,7 +96,7 @@ export type Snaps = Snap[];
export type PointSnapLine = {
type: "points";
points: Point[];
points: GlobalPoint[];
};
export type PointerSnapLine = {
@ -108,14 +116,14 @@ export type SnapLine = PointSnapLine | GapSnapLine | PointerSnapLine;
// -----------------------------------------------------------------------------
export class SnapCache {
private static referenceSnapPoints: Point[] | null = null;
private static referenceSnapPoints: GlobalPoint[] | null = null;
private static visibleGaps: {
verticalGaps: Gap[];
horizontalGaps: Gap[];
} | null = null;
public static setReferenceSnapPoints = (snapPoints: Point[] | null) => {
public static setReferenceSnapPoints = (snapPoints: GlobalPoint[] | null) => {
SnapCache.referenceSnapPoints = snapPoints;
};
@ -191,8 +199,8 @@ export const getElementsCorners = (
omitCenter: false,
boundingBoxCorners: false,
},
): Point[] => {
let result: Point[] = [];
): GlobalPoint[] => {
let result: GlobalPoint[] = [];
if (elements.length === 1) {
const element = elements[0];
@ -219,33 +227,53 @@ export const getElementsCorners = (
(element.type === "diamond" || element.type === "ellipse") &&
!boundingBoxCorners
) {
const leftMid = rotatePoint(
[x1, y1 + halfHeight],
[cx, cy],
const leftMid = pointRotateRads<GlobalPoint>(
point(x1, y1 + halfHeight),
point(cx, cy),
element.angle,
);
const topMid = rotatePoint([x1 + halfWidth, y1], [cx, cy], element.angle);
const rightMid = rotatePoint(
[x2, y1 + halfHeight],
[cx, cy],
const topMid = pointRotateRads<GlobalPoint>(
point(x1 + halfWidth, y1),
point(cx, cy),
element.angle,
);
const bottomMid = rotatePoint(
[x1 + halfWidth, y2],
[cx, cy],
const rightMid = pointRotateRads<GlobalPoint>(
point(x2, y1 + halfHeight),
point(cx, cy),
element.angle,
);
const center: Point = [cx, cy];
const bottomMid = pointRotateRads<GlobalPoint>(
point(x1 + halfWidth, y2),
point(cx, cy),
element.angle,
);
const center = point<GlobalPoint>(cx, cy);
result = omitCenter
? [leftMid, topMid, rightMid, bottomMid]
: [leftMid, topMid, rightMid, bottomMid, center];
} else {
const topLeft = rotatePoint([x1, y1], [cx, cy], element.angle);
const topRight = rotatePoint([x2, y1], [cx, cy], element.angle);
const bottomLeft = rotatePoint([x1, y2], [cx, cy], element.angle);
const bottomRight = rotatePoint([x2, y2], [cx, cy], element.angle);
const center: Point = [cx, cy];
const topLeft = pointRotateRads<GlobalPoint>(
point(x1, y1),
point(cx, cy),
element.angle,
);
const topRight = pointRotateRads<GlobalPoint>(
point(x2, y1),
point(cx, cy),
element.angle,
);
const bottomLeft = pointRotateRads<GlobalPoint>(
point(x1, y2),
point(cx, cy),
element.angle,
);
const bottomRight = pointRotateRads<GlobalPoint>(
point(x2, y2),
point(cx, cy),
element.angle,
);
const center = point<GlobalPoint>(cx, cy);
result = omitCenter
? [topLeft, topRight, bottomLeft, bottomRight]
@ -259,18 +287,18 @@ export const getElementsCorners = (
const width = maxX - minX;
const height = maxY - minY;
const topLeft: Point = [minX, minY];
const topRight: Point = [maxX, minY];
const bottomLeft: Point = [minX, maxY];
const bottomRight: Point = [maxX, maxY];
const center: Point = [minX + width / 2, minY + height / 2];
const topLeft = point<GlobalPoint>(minX, minY);
const topRight = point<GlobalPoint>(maxX, minY);
const bottomLeft = point<GlobalPoint>(minX, maxY);
const bottomRight = point<GlobalPoint>(maxX, maxY);
const center = point<GlobalPoint>(minX + width / 2, minY + height / 2);
result = omitCenter
? [topLeft, topRight, bottomLeft, bottomRight]
: [topLeft, topRight, bottomLeft, bottomRight, center];
}
return result.map((point) => [round(point[0]), round(point[1])] as Point);
return result.map((p) => point(round(p[0]), round(p[1])));
};
const getReferenceElements = (
@ -339,23 +367,20 @@ export const getVisibleGaps = (
if (
startMaxX < endMinX &&
rangesOverlap([startMinY, startMaxY], [endMinY, endMaxY])
rangesOverlap(
rangeInclusive(startMinY, startMaxY),
rangeInclusive(endMinY, endMaxY),
)
) {
horizontalGaps.push({
startBounds,
endBounds,
startSide: [
[startMaxX, startMinY],
[startMaxX, startMaxY],
],
endSide: [
[endMinX, endMinY],
[endMinX, endMaxY],
],
startSide: [point(startMaxX, startMinY), point(startMaxX, startMaxY)],
endSide: [point(endMinX, endMinY), point(endMinX, endMaxY)],
length: endMinX - startMaxX,
overlap: rangeIntersection(
[startMinY, startMaxY],
[endMinY, endMaxY],
rangeInclusive(startMinY, startMaxY),
rangeInclusive(endMinY, endMaxY),
)!,
});
}
@ -382,23 +407,20 @@ export const getVisibleGaps = (
if (
startMaxY < endMinY &&
rangesOverlap([startMinX, startMaxX], [endMinX, endMaxX])
rangesOverlap(
rangeInclusive(startMinX, startMaxX),
rangeInclusive(endMinX, endMaxX),
)
) {
verticalGaps.push({
startBounds,
endBounds,
startSide: [
[startMinX, startMaxY],
[startMaxX, startMaxY],
],
endSide: [
[endMinX, endMinY],
[endMaxX, endMinY],
],
startSide: [point(startMinX, startMaxY), point(startMaxX, startMaxY)],
endSide: [point(endMinX, endMinY), point(endMaxX, endMinY)],
length: endMinY - startMaxY,
overlap: rangeIntersection(
[startMinX, startMaxX],
[endMinX, endMaxX],
rangeInclusive(startMinX, startMaxX),
rangeInclusive(endMinX, endMaxX),
)!,
});
}
@ -441,7 +463,7 @@ const getGapSnaps = (
const centerY = (minY + maxY) / 2;
for (const gap of horizontalGaps) {
if (!rangesOverlap([minY, maxY], gap.overlap)) {
if (!rangesOverlap(rangeInclusive(minY, maxY), gap.overlap)) {
continue;
}
@ -510,7 +532,7 @@ const getGapSnaps = (
}
}
for (const gap of verticalGaps) {
if (!rangesOverlap([minX, maxX], gap.overlap)) {
if (!rangesOverlap(rangeInclusive(minX, maxX), gap.overlap)) {
continue;
}
@ -603,7 +625,7 @@ export const getReferenceSnapPoints = (
const getPointSnaps = (
selectedElements: ExcalidrawElement[],
selectionSnapPoints: Point[],
selectionSnapPoints: GlobalPoint[],
app: AppClassProperties,
event: KeyboardModifiersObject,
nearestSnapsX: Snaps,
@ -779,8 +801,8 @@ const round = (x: number) => {
return Math.round(x * 10 ** decimalPlaces) / 10 ** decimalPlaces;
};
const dedupePoints = (points: Point[]): Point[] => {
const map = new Map<string, Point>();
const dedupePoints = (points: GlobalPoint[]): GlobalPoint[] => {
const map = new Map<string, GlobalPoint>();
for (const point of points) {
const key = point.join(",");
@ -797,8 +819,8 @@ const createPointSnapLines = (
nearestSnapsX: Snaps,
nearestSnapsY: Snaps,
): PointSnapLine[] => {
const snapsX = {} as { [key: string]: Point[] };
const snapsY = {} as { [key: string]: Point[] };
const snapsX = {} as { [key: string]: GlobalPoint[] };
const snapsY = {} as { [key: string]: GlobalPoint[] };
if (nearestSnapsX.length > 0) {
for (const snap of nearestSnapsX) {
@ -809,8 +831,8 @@ const createPointSnapLines = (
snapsX[key] = [];
}
snapsX[key].push(
...snap.points.map(
(point) => [round(point[0]), round(point[1])] as Point,
...snap.points.map((p) =>
point<GlobalPoint>(round(p[0]), round(p[1])),
),
);
}
@ -826,8 +848,8 @@ const createPointSnapLines = (
snapsY[key] = [];
}
snapsY[key].push(
...snap.points.map(
(point) => [round(point[0]), round(point[1])] as Point,
...snap.points.map((p) =>
point<GlobalPoint>(round(p[0]), round(p[1])),
),
);
}
@ -840,8 +862,8 @@ const createPointSnapLines = (
type: "points",
points: dedupePoints(
points
.map((point) => {
return [Number(key), point[1]] as Point;
.map((p) => {
return point<GlobalPoint>(Number(key), p[1]);
})
.sort((a, b) => a[1] - b[1]),
),
@ -853,8 +875,8 @@ const createPointSnapLines = (
type: "points",
points: dedupePoints(
points
.map((point) => {
return [point[0], Number(key)] as Point;
.map((p) => {
return point<GlobalPoint>(p[0], Number(key));
})
.sort((a, b) => a[0] - b[0]),
),
@ -898,12 +920,12 @@ const createGapSnapLines = (
const [endMinX, endMinY, endMaxX, endMaxY] = gapSnap.gap.endBounds;
const verticalIntersection = rangeIntersection(
[minY, maxY],
rangeInclusive(minY, maxY),
gapSnap.gap.overlap,
);
const horizontalGapIntersection = rangeIntersection(
[minX, maxX],
rangeInclusive(minX, maxX),
gapSnap.gap.overlap,
);
@ -918,16 +940,16 @@ const createGapSnapLines = (
type: "gap",
direction: "horizontal",
points: [
[gapSnap.gap.startSide[0][0], gapLineY],
[minX, gapLineY],
point(gapSnap.gap.startSide[0][0], gapLineY),
point(minX, gapLineY),
],
},
{
type: "gap",
direction: "horizontal",
points: [
[maxX, gapLineY],
[gapSnap.gap.endSide[0][0], gapLineY],
point(maxX, gapLineY),
point(gapSnap.gap.endSide[0][0], gapLineY),
],
},
);
@ -944,16 +966,16 @@ const createGapSnapLines = (
type: "gap",
direction: "vertical",
points: [
[gapLineX, gapSnap.gap.startSide[0][1]],
[gapLineX, minY],
point(gapLineX, gapSnap.gap.startSide[0][1]),
point(gapLineX, minY),
],
},
{
type: "gap",
direction: "vertical",
points: [
[gapLineX, maxY],
[gapLineX, gapSnap.gap.endSide[0][1]],
point(gapLineX, maxY),
point(gapLineX, gapSnap.gap.endSide[0][1]),
],
},
);
@ -969,18 +991,12 @@ const createGapSnapLines = (
{
type: "gap",
direction: "horizontal",
points: [
[startMaxX, gapLineY],
[endMinX, gapLineY],
],
points: [point(startMaxX, gapLineY), point(endMinX, gapLineY)],
},
{
type: "gap",
direction: "horizontal",
points: [
[endMaxX, gapLineY],
[minX, gapLineY],
],
points: [point(endMaxX, gapLineY), point(minX, gapLineY)],
},
);
}
@ -995,18 +1011,12 @@ const createGapSnapLines = (
{
type: "gap",
direction: "horizontal",
points: [
[maxX, gapLineY],
[startMinX, gapLineY],
],
points: [point(maxX, gapLineY), point(startMinX, gapLineY)],
},
{
type: "gap",
direction: "horizontal",
points: [
[startMaxX, gapLineY],
[endMinX, gapLineY],
],
points: [point(startMaxX, gapLineY), point(endMinX, gapLineY)],
},
);
}
@ -1021,18 +1031,12 @@ const createGapSnapLines = (
{
type: "gap",
direction: "vertical",
points: [
[gapLineX, maxY],
[gapLineX, startMinY],
],
points: [point(gapLineX, maxY), point(gapLineX, startMinY)],
},
{
type: "gap",
direction: "vertical",
points: [
[gapLineX, startMaxY],
[gapLineX, endMinY],
],
points: [point(gapLineX, startMaxY), point(gapLineX, endMinY)],
},
);
}
@ -1047,18 +1051,12 @@ const createGapSnapLines = (
{
type: "gap",
direction: "vertical",
points: [
[gapLineX, startMaxY],
[gapLineX, endMinY],
],
points: [point(gapLineX, startMaxY), point(gapLineX, endMinY)],
},
{
type: "gap",
direction: "vertical",
points: [
[gapLineX, endMaxY],
[gapLineX, minY],
],
points: [point(gapLineX, endMaxY), point(gapLineX, minY)],
},
);
}
@ -1071,8 +1069,8 @@ const createGapSnapLines = (
gapSnapLines.map((gapSnapLine) => {
return {
...gapSnapLine,
points: gapSnapLine.points.map(
(point) => [round(point[0]), round(point[1])] as Point,
points: gapSnapLine.points.map((p) =>
point(round(p[0]), round(p[1])),
) as PointPair,
};
}),
@ -1117,40 +1115,40 @@ export const snapResizingElements = (
}
}
const selectionSnapPoints: Point[] = [];
const selectionSnapPoints: GlobalPoint[] = [];
if (transformHandle) {
switch (transformHandle) {
case "e": {
selectionSnapPoints.push([maxX, minY], [maxX, maxY]);
selectionSnapPoints.push(point(maxX, minY), point(maxX, maxY));
break;
}
case "w": {
selectionSnapPoints.push([minX, minY], [minX, maxY]);
selectionSnapPoints.push(point(minX, minY), point(minX, maxY));
break;
}
case "n": {
selectionSnapPoints.push([minX, minY], [maxX, minY]);
selectionSnapPoints.push(point(minX, minY), point(maxX, minY));
break;
}
case "s": {
selectionSnapPoints.push([minX, maxY], [maxX, maxY]);
selectionSnapPoints.push(point(minX, maxY), point(maxX, maxY));
break;
}
case "ne": {
selectionSnapPoints.push([maxX, minY]);
selectionSnapPoints.push(point(maxX, minY));
break;
}
case "nw": {
selectionSnapPoints.push([minX, minY]);
selectionSnapPoints.push(point(minX, minY));
break;
}
case "se": {
selectionSnapPoints.push([maxX, maxY]);
selectionSnapPoints.push(point(maxX, maxY));
break;
}
case "sw": {
selectionSnapPoints.push([minX, maxY]);
selectionSnapPoints.push(point(minX, maxY));
break;
}
}
@ -1192,11 +1190,11 @@ export const snapResizingElements = (
round(bound),
);
const corners: Point[] = [
[x1, y1],
[x1, y2],
[x2, y1],
[x2, y2],
const corners: GlobalPoint[] = [
point(x1, y1),
point(x1, y2),
point(x2, y1),
point(x2, y2),
];
getPointSnaps(
@ -1232,8 +1230,8 @@ export const snapNewElement = (
};
}
const selectionSnapPoints: Point[] = [
[origin.x + dragOffset.x, origin.y + dragOffset.y],
const selectionSnapPoints: GlobalPoint[] = [
point(origin.x + dragOffset.x, origin.y + dragOffset.y),
];
const snapDistance = getSnapDistance(app.state.zoom.value);
@ -1333,7 +1331,7 @@ export const getSnapLinesAtPointer = (
verticalSnapLines.push({
type: "pointer",
points: [corner, [corner[0], pointer.y]],
points: [corner, point(corner[0], pointer.y)],
direction: "vertical",
});
@ -1349,7 +1347,7 @@ export const getSnapLinesAtPointer = (
horizontalSnapLines.push({
type: "pointer",
points: [corner, [pointer.x, corner[1]]],
points: [corner, point(pointer.x, corner[1])],
direction: "horizontal",
});
@ -1386,3 +1384,18 @@ export const isActiveToolNonLinearSnappable = (
activeToolType === TOOL_TYPE.text
);
};
// TODO: Rounding this point causes some shake when free drawing
export const getGridPoint = (
x: number,
y: number,
gridSize: NullableGridSize,
): [number, number] => {
if (gridSize) {
return [
Math.round(x / gridSize) * gridSize,
Math.round(y / gridSize) * gridSize,
];
}
return [x, y];
};

View file

@ -866,6 +866,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
"id1": true,
@ -1068,6 +1069,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
},
@ -1283,6 +1285,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
},
@ -1613,6 +1616,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
},
@ -1943,6 +1947,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
},
@ -2158,6 +2163,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -2397,6 +2403,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0_copy": true,
},
@ -2699,6 +2706,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
"id1": true,
@ -3065,6 +3073,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
},
@ -3539,6 +3548,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id1": true,
},
@ -3861,6 +3871,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id1": true,
},
@ -4185,6 +4196,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
"id1": true,
@ -5370,6 +5382,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
"id1": true,
@ -6496,6 +6509,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
"id1": true,
@ -7431,6 +7445,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -8339,6 +8354,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
},
@ -9235,6 +9251,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id1": true,
},

View file

@ -239,6 +239,55 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
Ctrl+Shift+E
</div>
</button>
<button
aria-label="Find on canvas"
class="dropdown-menu-item dropdown-menu-item-base"
data-testid="search-menu-button"
title="Find on canvas"
>
<div
class="dropdown-menu-item__icon"
>
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
>
<g
stroke-width="1.5"
>
<path
d="M0 0h24v24H0z"
fill="none"
stroke="none"
/>
<path
d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"
/>
<path
d="M21 21l-6 -6"
/>
</g>
</svg>
</div>
<div
class="dropdown-menu-item__text"
>
Find on canvas
</div>
<div
class="dropdown-menu-item__shortcut"
>
Ctrl+F
</div>
</button>
<button
aria-label="Help"
class="dropdown-menu-item dropdown-menu-item-base"

View file

@ -80,6 +80,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {
"id166": true,
},
@ -681,6 +682,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {
"id161": true,
},
@ -1187,6 +1189,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -1554,6 +1557,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -1922,6 +1926,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -2185,6 +2190,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {
"id180": true,
},
@ -2627,6 +2633,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -2925,6 +2932,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -3208,6 +3216,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -3501,6 +3510,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -3786,6 +3796,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -4020,6 +4031,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -4278,6 +4290,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -4550,6 +4563,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -4780,6 +4794,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -5010,6 +5025,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -5238,6 +5254,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -5466,6 +5483,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -5723,6 +5741,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -6053,6 +6072,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -6477,6 +6497,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {
"id100": true,
"id101": true,
@ -6855,6 +6876,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {
"id105": true,
"id106": true,
@ -7170,6 +7192,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {
"id113": true,
},
@ -7467,6 +7490,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -7695,6 +7719,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -8049,6 +8074,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -8406,6 +8432,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {
"id127": true,
"id128": true,
@ -8806,6 +8833,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -9092,6 +9120,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {
"id124": true,
},
@ -9356,6 +9385,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {
"id81": true,
},
@ -9619,6 +9649,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {
"id83": true,
},
@ -9852,6 +9883,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -10149,6 +10181,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {
"id92": true,
},
@ -10488,6 +10521,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -10725,6 +10759,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -11174,6 +11209,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {
"id84": true,
},
@ -11427,6 +11463,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -11665,6 +11702,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {
"id14": true,
},
@ -11905,6 +11943,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -12308,6 +12347,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
"resizingElement": null,
"scrollX": -50,
"scrollY": -50,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -12551,6 +12591,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {
"id78": true,
},
@ -12791,6 +12832,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {
"id19": true,
},
@ -13033,6 +13075,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -13277,6 +13320,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {
"id8": true,
},
@ -13611,6 +13655,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -13779,6 +13824,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {
"id1": true,
"id2": true,
@ -14069,6 +14115,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -14334,6 +14381,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {
"id11": true,
},
@ -14609,6 +14657,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -14768,6 +14817,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {
"id55": true,
},
@ -15463,6 +15513,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {
"id49": true,
},
@ -16082,6 +16133,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {
"id61": true,
},
@ -16699,6 +16751,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {
"id62": true,
},
@ -17412,6 +17465,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {
"id69": true,
"id71": true,
@ -18161,6 +18215,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {
"id39": true,
"id41": true,
@ -18634,6 +18689,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {
"id35_copy_copy": true,
"id36_copy_copy": true,
@ -19155,6 +19211,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -19610,6 +19667,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": [],
"selectedElementIds": {
"id27": true,
},

View file

@ -84,6 +84,7 @@ exports[`given element A and group of elements B and given both are selected whe
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
"id2": true,
@ -495,6 +496,7 @@ exports[`given element A and group of elements B and given both are selected whe
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id1": true,
},
@ -893,6 +895,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id7": true,
},
@ -1434,6 +1437,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
},
@ -1636,6 +1640,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
"id2": true,
@ -2007,6 +2012,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
},
@ -2241,6 +2247,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
},
@ -2419,6 +2426,7 @@ exports[`regression tests > can drag element that covers another element, while
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id1": true,
},
@ -2733,6 +2741,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
},
@ -2977,6 +2986,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
},
@ -3216,6 +3226,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
},
@ -3442,6 +3453,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
},
@ -3694,6 +3706,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
"id1": true,
@ -3999,6 +4012,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id1": true,
},
@ -4412,6 +4426,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -4691,6 +4706,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -4939,6 +4955,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -5145,6 +5162,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -5338,6 +5356,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id2": true,
},
@ -5719,6 +5738,7 @@ exports[`regression tests > drags selected elements from point inside common bou
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
"id1": true,
@ -6002,6 +6022,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -6809,6 +6830,7 @@ exports[`regression tests > given a group of selected elements with an element t
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id1": true,
},
@ -7134,6 +7156,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
"id1": true,
@ -7406,6 +7429,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id1": true,
},
@ -7636,6 +7660,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
},
@ -7867,6 +7892,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
},
@ -8043,6 +8069,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
},
@ -8219,6 +8246,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
},
@ -8395,6 +8423,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
},
@ -8613,6 +8642,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
},
@ -8830,6 +8860,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -9020,6 +9051,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
},
@ -9238,6 +9270,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
},
@ -9414,6 +9447,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
},
@ -9631,6 +9665,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
},
@ -9807,6 +9842,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -9997,6 +10033,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
},
@ -10177,6 +10214,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
"id1": true,
@ -10685,6 +10723,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id1": true,
},
@ -10956,6 +10995,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
"scrollX": "-6.25000",
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -11080,6 +11120,7 @@ exports[`regression tests > shift click on selected element should deselect it o
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -11276,6 +11317,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
"id1": true,
@ -11584,6 +11626,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
"id1": true,
@ -11993,6 +12036,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
"id1": true,
@ -12600,6 +12644,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
"scrollX": 60,
"scrollY": 60,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -12724,6 +12769,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id1": true,
},
@ -13305,6 +13351,7 @@ exports[`regression tests > switches from group of selected elements to another
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
},
@ -13638,6 +13685,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0": true,
},
@ -13897,6 +13945,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
"scrollX": 20,
"scrollY": "-18.53553",
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -14019,6 +14068,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id1": true,
},
@ -14394,6 +14444,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@ -14519,6 +14570,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},

View file

@ -7,6 +7,7 @@ import { API } from "./helpers/api";
import { KEYS } from "../keys";
import { actionWrapTextInContainer } from "../actions/actionBoundText";
import { arrayToMap } from "../utils";
import { point } from "../../math";
const { h } = window;
@ -31,12 +32,7 @@ describe("element binding", () => {
y: 0,
width: 100,
height: 1,
points: [
[0, 0],
[0, 0],
[100, 0],
[100, 0],
],
points: [point(0, 0), point(0, 0), point(100, 0), point(100, 0)],
});
API.setElements([rect, arrow]);
expect(arrow.startBinding).toBe(null);
@ -314,10 +310,7 @@ describe("element binding", () => {
const arrow1 = API.createElement({
type: "arrow",
id: "arrow1",
points: [
[0, 0],
[0, -87.45777932247563],
],
points: [point(0, 0), point(0, -87.45777932247563)],
startBinding: {
elementId: "rectangle1",
focus: 0.2,
@ -335,10 +328,7 @@ describe("element binding", () => {
const arrow2 = API.createElement({
type: "arrow",
id: "arrow2",
points: [
[0, 0],
[0, -87.45777932247563],
],
points: [point(0, 0), point(0, -87.45777932247563)],
startBinding: {
elementId: "text1",
focus: 0.2,

View file

@ -1,3 +1,4 @@
import type { Radians } from "../../../math";
import { DEFAULT_FONT_FAMILY } from "../../constants";
import type { ExcalidrawElement } from "../../element/types";
@ -7,7 +8,7 @@ const elementBase: Omit<ExcalidrawElement, "type"> = {
y: 237,
width: 214,
height: 214,
angle: 0,
angle: 0 as Radians,
strokeColor: "#000000",
backgroundColor: "#15aabf",
fillStyle: "hachure",

View file

@ -23,23 +23,24 @@ import { Excalidraw } from "../index";
import type { NormalizedZoomValue } from "../types";
import { ROUNDNESS } from "../constants";
import { vi } from "vitest";
import * as blob from "../data/blob";
import { KEYS } from "../keys";
import { getBoundTextElementPosition } from "../element/textElement";
import { createPasteEvent } from "../clipboard";
import { arrayToMap, cloneJSON } from "../utils";
import type { LocalPoint } from "../../math";
import { point, type Radians } from "../../math";
const { h } = window;
const mouse = new Pointer("mouse");
// This needs to fixed in vitest mock, as when importActual used with mock
// the tests hangs - https://github.com/vitest-dev/vitest/issues/546.
// But fortunately spying and mocking the return value of spy works :p
const resizeImageFileSpy = vi.spyOn(blob, "resizeImageFile");
const generateIdFromFileSpy = vi.spyOn(blob, "generateIdFromFile");
resizeImageFileSpy.mockImplementation(async (imageFile: File) => imageFile);
generateIdFromFileSpy.mockImplementation(async () => "fileId" as FileId);
vi.mock("../data/blob", async (actual) => {
const orig: Object = await actual();
return {
...orig,
resizeImageFile: (imageFile: File) => imageFile,
generateIdFromFile: () => "fileId" as FileId,
};
});
beforeEach(async () => {
// Unmount ReactDOM from root
@ -131,7 +132,7 @@ const createLinearElementWithCurveInsideMinMaxPoints = (
y: -2412.5069664197654,
width: 1750.4888916015625,
height: 410.51605224609375,
angle: 0,
angle: 0 as Radians,
strokeColor: "#000000",
backgroundColor: "#fa5252",
fillStyle: "hachure",
@ -145,9 +146,9 @@ const createLinearElementWithCurveInsideMinMaxPoints = (
link: null,
locked: false,
points: [
[0, 0],
[-922.4761962890625, 300.3277587890625],
[828.0126953125, 410.51605224609375],
point<LocalPoint>(0, 0),
point<LocalPoint>(-922.4761962890625, 300.3277587890625),
point<LocalPoint>(828.0126953125, 410.51605224609375),
],
});
};
@ -423,8 +424,8 @@ describe("arrow", () => {
});
it("flips a rotated arrow horizontally with line inside min/max points bounds", async () => {
const originalAngle = Math.PI / 4;
const expectedAngle = (7 * Math.PI) / 4;
const originalAngle = (Math.PI / 4) as Radians;
const expectedAngle = ((7 * Math.PI) / 4) as Radians;
const line = createLinearElementWithCurveInsideMinMaxPoints("arrow");
API.setElements([line]);
API.setAppState({
@ -444,8 +445,8 @@ describe("arrow", () => {
});
it("flips a rotated arrow vertically with line inside min/max points bounds", async () => {
const originalAngle = Math.PI / 4;
const expectedAngle = (7 * Math.PI) / 4;
const originalAngle = (Math.PI / 4) as Radians;
const expectedAngle = ((7 * Math.PI) / 4) as Radians;
const line = createLinearElementWithCurveInsideMinMaxPoints("arrow");
API.setElements([line]);
API.setAppState({
@ -477,8 +478,8 @@ describe("arrow", () => {
//TODO: elements with curve outside minMax points have a wrong bounding box!!!
it.skip("flips a rotated arrow horizontally with line outside min/max points bounds", async () => {
const originalAngle = Math.PI / 4;
const expectedAngle = (7 * Math.PI) / 4;
const originalAngle = (Math.PI / 4) as Radians;
const expectedAngle = ((7 * Math.PI) / 4) as Radians;
const line = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
API.updateElement(line, { angle: originalAngle });
API.setElements([line]);
@ -501,8 +502,8 @@ describe("arrow", () => {
//TODO: elements with curve outside minMax points have a wrong bounding box!!!
it.skip("flips a rotated arrow vertically with line outside min/max points bounds", async () => {
const originalAngle = Math.PI / 4;
const expectedAngle = (7 * Math.PI) / 4;
const originalAngle = (Math.PI / 4) as Radians;
const expectedAngle = ((7 * Math.PI) / 4) as Radians;
const line = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
API.updateElement(line, { angle: originalAngle });
API.setElements([line]);
@ -585,8 +586,8 @@ describe("line", () => {
//TODO: elements with curve outside minMax points have a wrong bounding box
it.skip("flips a rotated line horizontally with line outside min/max points bounds", async () => {
const originalAngle = Math.PI / 4;
const expectedAngle = (7 * Math.PI) / 4;
const originalAngle = (Math.PI / 4) as Radians;
const expectedAngle = ((7 * Math.PI) / 4) as Radians;
const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
API.updateElement(line, { angle: originalAngle });
API.setElements([line]);
@ -600,8 +601,8 @@ describe("line", () => {
//TODO: elements with curve outside minMax points have a wrong bounding box
it.skip("flips a rotated line vertically with line outside min/max points bounds", async () => {
const originalAngle = Math.PI / 4;
const expectedAngle = (7 * Math.PI) / 4;
const originalAngle = (Math.PI / 4) as Radians;
const expectedAngle = ((7 * Math.PI) / 4) as Radians;
const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
API.updateElement(line, { angle: originalAngle });
API.setElements([line]);
@ -619,8 +620,8 @@ describe("line", () => {
});
it("flips a rotated line horizontally with line inside min/max points bounds", async () => {
const originalAngle = Math.PI / 4;
const expectedAngle = (7 * Math.PI) / 4;
const originalAngle = (Math.PI / 4) as Radians;
const expectedAngle = ((7 * Math.PI) / 4) as Radians;
const line = createLinearElementWithCurveInsideMinMaxPoints("line");
API.setElements([line]);
API.setAppState({
@ -640,8 +641,8 @@ describe("line", () => {
});
it("flips a rotated line vertically with line inside min/max points bounds", async () => {
const originalAngle = Math.PI / 4;
const expectedAngle = (7 * Math.PI) / 4;
const originalAngle = (Math.PI / 4) as Radians;
const expectedAngle = ((7 * Math.PI) / 4) as Radians;
const line = createLinearElementWithCurveInsideMinMaxPoints("line");
API.setElements([line]);
API.setAppState({
@ -772,8 +773,8 @@ describe("image", () => {
});
it("flips an rotated image horizontally correctly", async () => {
const originalAngle = Math.PI / 4;
const expectedAngle = (7 * Math.PI) / 4;
const originalAngle = (Math.PI / 4) as Radians;
const expectedAngle = ((7 * Math.PI) / 4) as Radians;
//paste image
await createImage();
await waitFor(() => {
@ -790,8 +791,8 @@ describe("image", () => {
});
it("flips an rotated image vertically correctly", async () => {
const originalAngle = Math.PI / 4;
const expectedAngle = (7 * Math.PI) / 4;
const originalAngle = (Math.PI / 4) as Radians;
const expectedAngle = ((7 * Math.PI) / 4) as Radians;
//paste image
await createImage();
await waitFor(() => {

View file

@ -27,7 +27,7 @@ import {
newImageElement,
newMagicFrameElement,
} from "../../element/newElement";
import type { AppState, Point } from "../../types";
import type { AppState } from "../../types";
import { getSelectedElements } from "../../scene/selection";
import { isLinearElementType } from "../../element/typeChecks";
import type { Mutable } from "../../utility-types";
@ -36,6 +36,7 @@ import type App from "../../components/App";
import { createTestHook } from "../../components/App";
import type { Action } from "../../actions/types";
import { mutateElement } from "../../element/mutateElement";
import { point, type LocalPoint, type Radians } from "../../../math";
const readFile = util.promisify(fs.readFile);
// so that window.h is available when App.tsx is not imported as well.
@ -75,11 +76,12 @@ export class API {
});
};
static updateElement = (
...[element, updates]: Parameters<typeof mutateElement>
// eslint-disable-next-line prettier/prettier
static updateElement = <T extends ExcalidrawElement>(
...args: Parameters<typeof mutateElement<T>>
) => {
act(() => {
mutateElement(element, updates);
mutateElement<T>(...args);
});
};
@ -171,7 +173,7 @@ export class API {
containerId?: T extends "text"
? ExcalidrawTextElement["containerId"]
: never;
points?: T extends "arrow" | "line" ? readonly Point[] : never;
points?: T extends "arrow" | "line" ? readonly LocalPoint[] : never;
locked?: boolean;
fileId?: T extends "image" ? string : never;
scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never;
@ -218,7 +220,7 @@ export class API {
y,
frameId: rest.frameId ?? null,
index: rest.index ?? null,
angle: rest.angle ?? 0,
angle: (rest.angle ?? 0) as Radians,
strokeColor: rest.strokeColor ?? appState.currentItemStrokeColor,
backgroundColor:
rest.backgroundColor ?? appState.currentItemBackgroundColor,
@ -293,8 +295,8 @@ export class API {
height,
type,
points: rest.points ?? [
[0, 0],
[100, 100],
point<LocalPoint>(0, 0),
point<LocalPoint>(100, 100),
],
elbowed: rest.elbowed ?? false,
});
@ -306,8 +308,8 @@ export class API {
height,
type,
points: rest.points ?? [
[0, 0],
[100, 100],
point<LocalPoint>(0, 0),
point<LocalPoint>(100, 100),
],
});
break;

View file

@ -1,4 +1,4 @@
import type { Point, ToolType } from "../../types";
import type { ToolType } from "../../types";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
@ -30,10 +30,11 @@ import {
isFrameLikeElement,
} from "../../element/typeChecks";
import { getCommonBounds, getElementPointsCoords } from "../../element/bounds";
import { rotatePoint } from "../../math";
import { getTextEditor } from "../queries/dom";
import { arrayToMap } from "../../utils";
import { createTestHook } from "../../components/App";
import type { GlobalPoint, LocalPoint, Radians } from "../../../math";
import { point, pointRotateRads } from "../../../math";
// so that window.h is available when App.tsx is not imported as well.
createTestHook();
@ -68,8 +69,11 @@ export class Keyboard {
}
};
static keyDown = (key: string) => {
fireEvent.keyDown(document, {
static keyDown = (
key: string,
target: HTMLElement | Document | Window = document,
) => {
fireEvent.keyDown(target, {
key,
ctrlKey,
shiftKey,
@ -77,8 +81,11 @@ export class Keyboard {
});
};
static keyUp = (key: string) => {
fireEvent.keyUp(document, {
static keyUp = (
key: string,
target: HTMLElement | Document | Window = document,
) => {
fireEvent.keyUp(target, {
key,
ctrlKey,
shiftKey,
@ -86,9 +93,9 @@ export class Keyboard {
});
};
static keyPress = (key: string) => {
Keyboard.keyDown(key);
Keyboard.keyUp(key);
static keyPress = (key: string, target?: HTMLElement | Document | Window) => {
Keyboard.keyDown(key, target);
Keyboard.keyUp(key, target);
};
static codeDown = (code: string) => {
@ -131,27 +138,29 @@ export class Keyboard {
};
}
const getElementPointForSelection = (element: ExcalidrawElement): Point => {
const getElementPointForSelection = (
element: ExcalidrawElement,
): GlobalPoint => {
const { x, y, width, height, angle } = element;
const target: Point = [
const target = point<GlobalPoint>(
x +
(isLinearElement(element) || isFreeDrawElement(element) ? 0 : width / 2),
y,
];
let center: Point;
);
let center: GlobalPoint;
if (isLinearElement(element)) {
const bounds = getElementPointsCoords(element, element.points);
center = [(bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2];
center = point((bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2);
} else {
center = [x + width / 2, y + height / 2];
center = point(x + width / 2, y + height / 2);
}
if (isTextElement(element)) {
return center;
}
return rotatePoint(target, center, angle);
return pointRotateRads(target, center, angle);
};
export class Pointer {
@ -328,7 +337,7 @@ const transform = (
const isFrameSelected = elements.some(isFrameLikeElement);
const transformHandles = getTransformHandlesFromCoords(
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
0,
0 as Radians,
h.state.zoom,
"mouse",
isFrameSelected ? OMIT_SIDES_FOR_FRAME : OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
@ -450,7 +459,7 @@ export class UI {
width?: number;
height?: number;
angle?: number;
points?: T extends "line" | "arrow" | "freedraw" ? Point[] : never;
points?: T extends "line" | "arrow" | "freedraw" ? LocalPoint[] : never;
} = {},
): Element<T> & {
/** Returns the actual, current element from the elements array, instead
@ -459,9 +468,9 @@ export class UI {
} {
const width = initialWidth ?? initialHeight ?? size;
const height = initialHeight ?? size;
const points: Point[] = initialPoints ?? [
[0, 0],
[width, height],
const points: LocalPoint[] = initialPoints ?? [
point(0, 0),
point(width, height),
];
UI.clickTool(type);

View file

@ -44,6 +44,8 @@ import { queryByText } from "@testing-library/react";
import { HistoryEntry } from "../history";
import { AppStateChange, ElementsChange } from "../change";
import { Snapshot, StoreAction } from "../store";
import type { LocalPoint, Radians } from "../../math";
import { point } from "../../math";
const { h } = window;
@ -2038,9 +2040,9 @@ describe("history", () => {
width: 178.9000000000001,
height: 236.10000000000002,
points: [
[0, 0],
[178.9000000000001, 0],
[178.9000000000001, 236.10000000000002],
point(0, 0),
point(178.9000000000001, 0),
point(178.9000000000001, 236.10000000000002),
],
startBinding: {
elementId: "KPrBI4g_v9qUB1XxYLgSz",
@ -2156,12 +2158,12 @@ describe("history", () => {
elements: [
newElementWith(h.elements[0] as ExcalidrawLinearElement, {
points: [
[0, 0],
[5, 5],
[10, 10],
[15, 15],
[20, 20],
],
point(0, 0),
point(5, 5),
point(10, 10),
point(15, 15),
point(20, 20),
] as LocalPoint[],
}),
],
storeAction: StoreAction.UPDATE,
@ -4003,7 +4005,7 @@ describe("history", () => {
newElementWith(h.elements[0], {
x: 200,
y: 200,
angle: 90,
angle: 90 as Radians,
}),
],
storeAction: StoreAction.CAPTURE,
@ -4121,7 +4123,7 @@ describe("history", () => {
newElementWith(h.elements[0], {
x: 205,
y: 205,
angle: 90,
angle: 90 as Radians,
}),
],
storeAction: StoreAction.CAPTURE,

View file

@ -8,7 +8,6 @@ import type {
SceneElementsMap,
} from "../element/types";
import { Excalidraw, mutateElement } from "../index";
import { centerPoint } from "../math";
import { reseed } from "../random";
import * as StaticScene from "../renderer/staticScene";
import * as InteractiveCanvas from "../renderer/interactiveScene";
@ -16,7 +15,6 @@ import * as InteractiveCanvas from "../renderer/interactiveScene";
import { Keyboard, Pointer, UI } from "./helpers/ui";
import { screen, render, fireEvent, GlobalTestState } from "./test-utils";
import { API } from "../tests/helpers/api";
import type { Point } from "../types";
import { KEYS } from "../keys";
import { LinearElementEditor } from "../element/linearElementEditor";
import { act, queryByTestId, queryByText } from "@testing-library/react";
@ -29,6 +27,8 @@ import * as textElementUtils from "../element/textElement";
import { ROUNDNESS, VERTICAL_ALIGN } from "../constants";
import { vi } from "vitest";
import { arrayToMap } from "../utils";
import type { GlobalPoint } from "../../math";
import { pointCenter, point } from "../../math";
const renderInteractiveScene = vi.spyOn(
InteractiveCanvas,
@ -57,9 +57,9 @@ describe("Test Linear Elements", () => {
interactiveCanvas = container.querySelector("canvas.interactive")!;
});
const p1: Point = [20, 20];
const p2: Point = [60, 20];
const midpoint = centerPoint(p1, p2);
const p1 = point<GlobalPoint>(20, 20);
const p2 = point<GlobalPoint>(60, 20);
const midpoint = pointCenter<GlobalPoint>(p1, p2);
const delta = 50;
const mouse = new Pointer("mouse");
@ -75,10 +75,7 @@ describe("Test Linear Elements", () => {
height: 0,
type,
roughness,
points: [
[0, 0],
[p2[0] - p1[0], p2[1] - p1[1]],
],
points: [point(0, 0), point(p2[0] - p1[0], p2[1] - p1[1])],
roundness,
});
API.setElements([line]);
@ -102,9 +99,9 @@ describe("Test Linear Elements", () => {
type,
roughness,
points: [
[0, 0],
[p3[0], p3[1]],
[p2[0] - p1[0], p2[1] - p1[1]],
point(0, 0),
point(p3[0], p3[1]),
point(p2[0] - p1[0], p2[1] - p1[1]),
],
roundness,
});
@ -129,7 +126,7 @@ describe("Test Linear Elements", () => {
expect(h.state.editingLinearElement?.elementId).toEqual(line.id);
};
const drag = (startPoint: Point, endPoint: Point) => {
const drag = (startPoint: GlobalPoint, endPoint: GlobalPoint) => {
fireEvent.pointerDown(interactiveCanvas, {
clientX: startPoint[0],
clientY: startPoint[1],
@ -144,7 +141,7 @@ describe("Test Linear Elements", () => {
});
};
const deletePoint = (point: Point) => {
const deletePoint = (point: GlobalPoint) => {
fireEvent.pointerDown(interactiveCanvas, {
clientX: point[0],
clientY: point[1],
@ -164,7 +161,7 @@ describe("Test Linear Elements", () => {
expect(line.points.length).toEqual(2);
mouse.clickAt(midpoint[0], midpoint[1]);
drag(midpoint, [midpoint[0] + 1, midpoint[1] + 1]);
drag(midpoint, point(midpoint[0] + 1, midpoint[1] + 1));
expect(line.points.length).toEqual(2);
@ -172,7 +169,7 @@ describe("Test Linear Elements", () => {
expect(line.y).toBe(originalY);
expect(line.points.length).toEqual(2);
drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]);
drag(midpoint, point(midpoint[0] + delta, midpoint[1] + delta));
expect(line.x).toBe(originalX);
expect(line.y).toBe(originalY);
expect(line.points.length).toEqual(3);
@ -187,7 +184,7 @@ describe("Test Linear Elements", () => {
expect((h.elements[0] as ExcalidrawLinearElement).points.length).toEqual(2);
// drag line from midpoint
drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]);
drag(midpoint, point(midpoint[0] + delta, midpoint[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`9`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(line.points.length).toEqual(3);
@ -251,7 +248,7 @@ describe("Test Linear Elements", () => {
mouse.clickAt(midpoint[0], midpoint[1]);
expect(line.points.length).toEqual(2);
drag(midpoint, [midpoint[0] + 1, midpoint[1] + 1]);
drag(midpoint, point(midpoint[0] + 1, midpoint[1] + 1));
expect(line.x).toBe(originalX);
expect(line.y).toBe(originalY);
expect(line.points.length).toEqual(3);
@ -264,7 +261,7 @@ describe("Test Linear Elements", () => {
enterLineEditingMode(line);
// drag line from midpoint
drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]);
drag(midpoint, point(midpoint[0] + delta, midpoint[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`,
);
@ -356,10 +353,13 @@ describe("Test Linear Elements", () => {
h.state,
);
const startPoint = centerPoint(points[0], midPoints[0] as Point);
const startPoint = pointCenter(points[0], midPoints[0]!);
const deltaX = 50;
const deltaY = 20;
const endPoint: Point = [startPoint[0] + deltaX, startPoint[1] + deltaY];
const endPoint = point<GlobalPoint>(
startPoint[0] + deltaX,
startPoint[1] + deltaY,
);
// Move the element
drag(startPoint, endPoint);
@ -399,8 +399,8 @@ describe("Test Linear Elements", () => {
// This is the expected midpoint for line with round edge
// hence hardcoding it so if later some bug is introduced
// this will fail and we can fix it
const firstSegmentMidpoint: Point = [55, 45];
const lastSegmentMidpoint: Point = [75, 40];
const firstSegmentMidpoint = point<GlobalPoint>(55, 45);
const lastSegmentMidpoint = point<GlobalPoint>(75, 40);
let line: ExcalidrawLinearElement;
@ -414,17 +414,20 @@ describe("Test Linear Elements", () => {
it("should allow dragging lines from midpoints in between segments", async () => {
// drag line via first segment midpoint
drag(firstSegmentMidpoint, [
firstSegmentMidpoint[0] + delta,
firstSegmentMidpoint[1] + delta,
]);
drag(
firstSegmentMidpoint,
point(
firstSegmentMidpoint[0] + delta,
firstSegmentMidpoint[1] + delta,
),
);
expect(line.points.length).toEqual(4);
// drag line from last segment midpoint
drag(lastSegmentMidpoint, [
lastSegmentMidpoint[0] + delta,
lastSegmentMidpoint[1] + delta,
]);
drag(
lastSegmentMidpoint,
point(lastSegmentMidpoint[0] + delta, lastSegmentMidpoint[1] + delta),
);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`16`,
@ -472,10 +475,10 @@ describe("Test Linear Elements", () => {
h.state,
);
const hitCoords: Point = [points[0][0], points[0][1]];
const hitCoords = point<GlobalPoint>(points[0][0], points[0][1]);
// Drag from first point
drag(hitCoords, [hitCoords[0] - delta, hitCoords[1] - delta]);
drag(hitCoords, point(hitCoords[0] - delta, hitCoords[1] - delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`,
@ -513,10 +516,10 @@ describe("Test Linear Elements", () => {
h.state,
);
const hitCoords: Point = [points[0][0], points[0][1]];
const hitCoords = point<GlobalPoint>(points[0][0], points[0][1]);
// Drag from first point
drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]);
drag(hitCoords, point(hitCoords[0] + delta, hitCoords[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`,
@ -551,10 +554,10 @@ describe("Test Linear Elements", () => {
);
// dragging line from last segment midpoint
drag(lastSegmentMidpoint, [
lastSegmentMidpoint[0] + 50,
lastSegmentMidpoint[1] + 50,
]);
drag(
lastSegmentMidpoint,
point(lastSegmentMidpoint[0] + 50, lastSegmentMidpoint[1] + 50),
);
expect(line.points.length).toEqual(4);
const midPoints = LinearElementEditor.getEditorMidPoints(
@ -586,12 +589,14 @@ describe("Test Linear Elements", () => {
// This is the expected midpoint for line with round edge
// hence hardcoding it so if later some bug is introduced
// this will fail and we can fix it
const firstSegmentMidpoint: Point = [
55.9697848965255, 47.442326230998205,
];
const lastSegmentMidpoint: Point = [
76.08587175006699, 43.294165939653226,
];
const firstSegmentMidpoint = point<GlobalPoint>(
55.9697848965255,
47.442326230998205,
);
const lastSegmentMidpoint = point<GlobalPoint>(
76.08587175006699,
43.294165939653226,
);
let line: ExcalidrawLinearElement;
beforeEach(() => {
@ -605,17 +610,20 @@ describe("Test Linear Elements", () => {
it("should allow dragging lines from midpoints in between segments", async () => {
// drag line from first segment midpoint
drag(firstSegmentMidpoint, [
firstSegmentMidpoint[0] + delta,
firstSegmentMidpoint[1] + delta,
]);
drag(
firstSegmentMidpoint,
point(
firstSegmentMidpoint[0] + delta,
firstSegmentMidpoint[1] + delta,
),
);
expect(line.points.length).toEqual(4);
// drag line from last segment midpoint
drag(lastSegmentMidpoint, [
lastSegmentMidpoint[0] + delta,
lastSegmentMidpoint[1] + delta,
]);
drag(
lastSegmentMidpoint,
point(lastSegmentMidpoint[0] + delta, lastSegmentMidpoint[1] + delta),
);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`16`,
);
@ -661,10 +669,10 @@ describe("Test Linear Elements", () => {
h.state,
);
const hitCoords: Point = [points[0][0], points[0][1]];
const hitCoords = point<GlobalPoint>(points[0][0], points[0][1]);
// Drag from first point
drag(hitCoords, [hitCoords[0] - delta, hitCoords[1] - delta]);
drag(hitCoords, point(hitCoords[0] - delta, hitCoords[1] - delta));
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
line,
@ -709,10 +717,10 @@ describe("Test Linear Elements", () => {
h.state,
);
const hitCoords: Point = [points[0][0], points[0][1]];
const hitCoords = point<GlobalPoint>(points[0][0], points[0][1]);
// Drag from first point
drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]);
drag(hitCoords, point(hitCoords[0] + delta, hitCoords[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`,
@ -741,10 +749,10 @@ describe("Test Linear Elements", () => {
it("should update all the midpoints when a point is deleted", async () => {
const elementsMap = arrayToMap(h.elements);
drag(lastSegmentMidpoint, [
lastSegmentMidpoint[0] + delta,
lastSegmentMidpoint[1] + delta,
]);
drag(
lastSegmentMidpoint,
point(lastSegmentMidpoint[0] + delta, lastSegmentMidpoint[1] + delta),
);
expect(line.points.length).toEqual(4);
const midPoints = LinearElementEditor.getEditorMidPoints(
@ -803,8 +811,11 @@ describe("Test Linear Elements", () => {
API.setSelectedElements([line]);
enterLineEditingMode(line, true);
drag(
[line.points[0][0] + line.x, line.points[0][1] + line.y],
[dragEndPositionOffset[0] + line.x, dragEndPositionOffset[1] + line.y],
point(line.points[0][0] + line.x, line.points[0][1] + line.y),
point(
dragEndPositionOffset[0] + line.x,
dragEndPositionOffset[1] + line.y,
),
);
expect(line.points).toMatchInlineSnapshot(`
[
@ -916,14 +927,18 @@ describe("Test Linear Elements", () => {
// This is the expected midpoint for line with round edge
// hence hardcoding it so if later some bug is introduced
// this will fail and we can fix it
const firstSegmentMidpoint: Point = [
55.9697848965255, 47.442326230998205,
];
const firstSegmentMidpoint = point<GlobalPoint>(
55.9697848965255,
47.442326230998205,
);
// drag line from first segment midpoint
drag(firstSegmentMidpoint, [
firstSegmentMidpoint[0] + delta,
firstSegmentMidpoint[1] + delta,
]);
drag(
firstSegmentMidpoint,
point(
firstSegmentMidpoint[0] + delta,
firstSegmentMidpoint[1] + delta,
),
);
const position = LinearElementEditor.getBoundTextElementPosition(
container,
@ -1136,7 +1151,7 @@ describe("Test Linear Elements", () => {
);
// Drag from last point
drag(points[1], [points[1][0] + 300, points[1][1]]);
drag(points[1], point(points[1][0] + 300, points[1][1]));
expect({ width: container.width, height: container.height })
.toMatchInlineSnapshot(`
@ -1335,14 +1350,14 @@ describe("Test Linear Elements", () => {
[
{
index: 0,
point: [line.points[0][0] + 10, line.points[0][1] + 10],
point: point(line.points[0][0] + 10, line.points[0][1] + 10),
},
{
index: line.points.length - 1,
point: [
point: point(
line.points[line.points.length - 1][0] - 10,
line.points[line.points.length - 1][1] - 10,
],
),
},
],
new Map() as SceneElementsMap,

View file

@ -43,7 +43,7 @@ describe("event callbacks", () => {
// files
{},
);
expect(onChange.mock.lastCall[1].viewBackgroundColor).not.toBe(
expect(onChange.mock?.lastCall?.[1].viewBackgroundColor).not.toBe(
origBackgroundColor,
);
});

View file

@ -11,7 +11,7 @@ export const getTextEditor = async (selector: string, waitForEditor = true) => {
};
export const updateTextEditor = (
editor: HTMLTextAreaElement,
editor: HTMLTextAreaElement | HTMLInputElement,
value: string,
) => {
fireEvent.change(editor, { target: { value } });

View file

@ -7,7 +7,6 @@ import type {
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
} from "../element/types";
import type { Point } from "../types";
import type { Bounds } from "../element/bounds";
import { getElementPointsCoords } from "../element/bounds";
import { Excalidraw } from "../index";
@ -16,6 +15,8 @@ import { KEYS } from "../keys";
import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor";
import { arrayToMap } from "../utils";
import type { LocalPoint } from "../../math";
import { point } from "../../math";
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@ -217,18 +218,13 @@ describe("generic element", () => {
});
describe.each(["line", "freedraw"] as const)("%s element", (type) => {
const points: Record<typeof type, Point[]> = {
line: [
[0, 0],
[60, -20],
[20, 40],
[-40, 0],
],
const points: Record<typeof type, LocalPoint[]> = {
line: [point(0, 0), point(60, -20), point(20, 40), point(-40, 0)],
freedraw: [
[0, 0],
[-2.474600807561444, 41.021700699972],
[3.6627956000014024, 47.84174560617245],
[40.495224145598115, 47.15909710753482],
point(0, 0),
point(-2.474600807561444, 41.021700699972),
point(3.6627956000014024, 47.84174560617245),
point(40.495224145598115, 47.15909710753482),
],
};
@ -296,11 +292,11 @@ describe("arrow element", () => {
it("resizes with a label", async () => {
const arrow = UI.createElement("arrow", {
points: [
[0, 0],
[40, 140],
[80, 60], // label's anchor
[180, 20],
[200, 120],
point(0, 0),
point(40, 140),
point(80, 60), // label's anchor
point(180, 20),
point(200, 120),
],
});
const label = await UI.editText(arrow, "Hello");
@ -694,24 +690,24 @@ describe("multiple selection", () => {
x: 60,
y: 40,
points: [
[0, 0],
[-40, 40],
[-60, 0],
[0, -40],
[40, 20],
[0, 40],
point(0, 0),
point(-40, 40),
point(-60, 0),
point(0, -40),
point(40, 20),
point(0, 40),
],
});
const freedraw = UI.createElement("freedraw", {
x: 63.56072661326618,
y: 100,
points: [
[0, 0],
[-43.56072661326618, 18.15048126846341],
[-43.56072661326618, 29.041198460587566],
[-38.115368017204105, 42.652452795512204],
[-19.964886748740696, 66.24829266003775],
[19.056612930986716, 77.1390098521619],
point(0, 0),
point(-43.56072661326618, 18.15048126846341),
point(-43.56072661326618, 29.041198460587566),
point(-38.115368017204105, 42.652452795512204),
point(-19.964886748740696, 66.24829266003775),
point(19.056612930986716, 77.1390098521619),
],
});
@ -1050,13 +1046,13 @@ describe("multiple selection", () => {
x: 60,
y: 0,
points: [
[0, 0],
[-40, 40],
[-20, 60],
[20, 20],
[40, 40],
[-20, 100],
[-60, 60],
point(0, 0),
point(-40, 40),
point(-20, 60),
point(20, 20),
point(40, 40),
point(-20, 100),
point(-60, 60),
],
});

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,145 @@
import React from "react";
import { act, render, waitFor } from "./test-utils";
import { Excalidraw } from "../index";
import { CLASSES, SEARCH_SIDEBAR } from "../constants";
import { Keyboard } from "./helpers/ui";
import { KEYS } from "../keys";
import { updateTextEditor } from "./queries/dom";
import { API } from "./helpers/api";
import type { ExcalidrawTextElement } from "../element/types";
const { h } = window;
const querySearchInput = async () => {
const input =
h.app.excalidrawContainerValue.container?.querySelector<HTMLInputElement>(
`.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`,
)!;
await waitFor(() => expect(input).not.toBeNull());
return input;
};
describe("search", () => {
beforeEach(async () => {
await render(<Excalidraw handleKeyboardGlobally />);
API.setAppState({
openSidebar: null,
});
});
it("should toggle search on cmd+f", async () => {
expect(h.app.state.openSidebar).toBeNull();
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.F);
});
expect(h.app.state.openSidebar).not.toBeNull();
expect(h.app.state.openSidebar?.name).toBe(SEARCH_SIDEBAR.name);
const searchInput = await querySearchInput();
expect(searchInput.matches(":focus")).toBe(true);
});
it("should refocus search input with cmd+f when search sidebar is still open", async () => {
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.F);
});
const searchInput =
h.app.excalidrawContainerValue.container?.querySelector<HTMLInputElement>(
`.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`,
);
act(() => {
searchInput?.blur();
});
expect(h.app.state.openSidebar).not.toBeNull();
expect(searchInput?.matches(":focus")).toBe(false);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.F);
});
expect(searchInput?.matches(":focus")).toBe(true);
});
it("should match text and cycle through matches on Enter", async () => {
const scrollIntoViewMock = jest.fn();
window.HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
API.setElements([
API.createElement({ type: "text", text: "test one" }),
API.createElement({ type: "text", text: "test two" }),
]);
expect(h.app.state.openSidebar).toBeNull();
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.F);
});
expect(h.app.state.openSidebar).not.toBeNull();
expect(h.app.state.openSidebar?.name).toBe(SEARCH_SIDEBAR.name);
const searchInput = await querySearchInput();
expect(searchInput.matches(":focus")).toBe(true);
updateTextEditor(searchInput, "test");
await waitFor(() => {
expect(h.app.state.searchMatches.length).toBe(2);
expect(h.app.state.searchMatches[0].focus).toBe(true);
});
Keyboard.keyPress(KEYS.ENTER, searchInput);
expect(h.app.state.searchMatches[0].focus).toBe(false);
expect(h.app.state.searchMatches[1].focus).toBe(true);
Keyboard.keyPress(KEYS.ENTER, searchInput);
expect(h.app.state.searchMatches[0].focus).toBe(true);
expect(h.app.state.searchMatches[1].focus).toBe(false);
});
it("should match text split across multiple lines", async () => {
const scrollIntoViewMock = jest.fn();
window.HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
API.setElements([
API.createElement({
type: "text",
text: "",
}),
]);
API.updateElement(h.elements[0] as ExcalidrawTextElement, {
text: "t\ne\ns\nt \nt\ne\nx\nt \ns\np\nli\nt \ni\nn\nt\no\nm\nu\nlt\ni\np\nl\ne \nli\nn\ne\ns",
originalText: "test text split into multiple lines",
});
expect(h.app.state.openSidebar).toBeNull();
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.F);
});
expect(h.app.state.openSidebar).not.toBeNull();
expect(h.app.state.openSidebar?.name).toBe(SEARCH_SIDEBAR.name);
const searchInput = await querySearchInput();
expect(searchInput.matches(":focus")).toBe(true);
updateTextEditor(searchInput, "test");
await waitFor(() => {
expect(h.app.state.searchMatches.length).toBe(1);
expect(h.app.state.searchMatches[0]?.matchedLines?.length).toBe(4);
});
updateTextEditor(searchInput, "ext spli");
await waitFor(() => {
expect(h.app.state.searchMatches.length).toBe(1);
expect(h.app.state.searchMatches[0]?.matchedLines?.length).toBe(6);
});
});
});

View file

@ -24,7 +24,6 @@ import type {
ExcalidrawNonSelectionElement,
} from "./element/types";
import type { Action } from "./actions/types";
import type { Point as RoughPoint } from "roughjs/bin/geometry";
import type { LinearElementEditor } from "./element/linearElementEditor";
import type { SuggestedBinding } from "./element/binding";
import type { ImportedDataState } from "./data/types";
@ -43,8 +42,6 @@ import type { SnapLine } from "./snapping";
import type { Merge, MaybePromise, ValueOf, MakeBrand } from "./utility-types";
import type { StoreActionType } from "./store";
export type Point = Readonly<RoughPoint>;
export type SocketId = string & { _brand: "SocketId" };
export type Collaborator = Readonly<{
@ -201,6 +198,8 @@ export type InteractiveCanvasAppState = Readonly<
snapLines: AppState["snapLines"];
zenModeEnabled: AppState["zenModeEnabled"];
editingTextElement: AppState["editingTextElement"];
// Search matches
searchMatches: AppState["searchMatches"];
}
>;
@ -387,8 +386,20 @@ export interface AppState {
userToFollow: UserToFollow | null;
/** the socket ids of the users following the current user */
followedBy: Set<SocketId>;
searchMatches: readonly SearchMatch[];
}
type SearchMatch = {
id: string;
focus: boolean;
matchedLines: {
offsetX: number;
offsetY: number;
width: number;
height: number;
}[];
};
export type UIAppState = Omit<
AppState,
| "suggestedBindings"
@ -645,6 +656,9 @@ export type AppClassProperties = {
getEffectiveGridSize: App["getEffectiveGridSize"];
setPlugins: App["setPlugins"];
plugins: App["plugins"];
getEditorUIOffsets: App["getEditorUIOffsets"];
visibleElements: App["visibleElements"];
excalidrawContainerValue: App["excalidrawContainerValue"];
};
export type PointerDownState = Readonly<{

View file

@ -1,3 +1,4 @@
import { average } from "../math";
import { COLOR_PALETTE } from "./colors";
import type { EVENT } from "./constants";
import {
@ -992,10 +993,6 @@ export const isMemberOf = <T extends string>(
export const cloneJSON = <T>(obj: T): T => JSON.parse(JSON.stringify(obj));
export const isFiniteNumber = (value: any): value is number => {
return typeof value === "number" && Number.isFinite(value);
};
export const updateStable = <T extends any[] | Record<string, any>>(
prevValue: T,
nextValue: T,
@ -1079,7 +1076,6 @@ export function addEventListener(
};
}
const average = (a: number, b: number) => (a + b) / 2;
export function getSvgPathFromStroke(points: number[][], closed = true) {
const len = points.length;

View file

@ -1,7 +1,7 @@
import { isLineSegment, lineSegment, point, type GlobalPoint } from "../math";
import type { LineSegment } from "../utils";
import type { BoundingBox, Bounds } from "./element/bounds";
import { isBounds, isLineSegment } from "./element/typeChecks";
import type { Point } from "./types";
import { isBounds } from "./element/typeChecks";
// The global data holder to collect the debug operations
declare global {
@ -15,18 +15,22 @@ declare global {
export type DebugElement = {
color: string;
data: LineSegment;
data: LineSegment<GlobalPoint>;
permanent: boolean;
};
export const debugDrawLine = (
segment: LineSegment | LineSegment[],
segment: LineSegment<GlobalPoint> | LineSegment<GlobalPoint>[],
opts?: {
color?: string;
permanent?: boolean;
},
) => {
(isLineSegment(segment) ? [segment] : segment).forEach((data) =>
const segments = (
isLineSegment(segment) ? [segment] : segment
) as LineSegment<GlobalPoint>[];
segments.forEach((data) =>
addToCurrentFrame({
color: opts?.color ?? "red",
data,
@ -36,7 +40,7 @@ export const debugDrawLine = (
};
export const debugDrawPoint = (
point: Point,
p: GlobalPoint,
opts?: {
color?: string;
permanent?: boolean;
@ -47,20 +51,20 @@ export const debugDrawPoint = (
const yOffset = opts?.fuzzy ? Math.random() * 3 : 0;
debugDrawLine(
[
[point[0] + xOffset - 10, point[1] + yOffset - 10],
[point[0] + xOffset + 10, point[1] + yOffset + 10],
],
lineSegment(
point<GlobalPoint>(p[0] + xOffset - 10, p[1] + yOffset - 10),
point<GlobalPoint>(p[0] + xOffset + 10, p[1] + yOffset + 10),
),
{
color: opts?.color ?? "cyan",
permanent: opts?.permanent,
},
);
debugDrawLine(
[
[point[0] + xOffset - 10, point[1] + yOffset + 10],
[point[0] + xOffset + 10, point[1] + yOffset - 10],
],
lineSegment(
point<GlobalPoint>(p[0] + xOffset - 10, p[1] + yOffset + 10),
point<GlobalPoint>(p[0] + xOffset + 10, p[1] + yOffset - 10),
),
{
color: opts?.color ?? "cyan",
permanent: opts?.permanent,
@ -78,22 +82,22 @@ export const debugDrawBoundingBox = (
(Array.isArray(box) ? box : [box]).forEach((bbox) =>
debugDrawLine(
[
[
[bbox.minX, bbox.minY],
[bbox.maxX, bbox.minY],
],
[
[bbox.maxX, bbox.minY],
[bbox.maxX, bbox.maxY],
],
[
[bbox.maxX, bbox.maxY],
[bbox.minX, bbox.maxY],
],
[
[bbox.minX, bbox.maxY],
[bbox.minX, bbox.minY],
],
lineSegment(
point<GlobalPoint>(bbox.minX, bbox.minY),
point<GlobalPoint>(bbox.maxX, bbox.minY),
),
lineSegment(
point<GlobalPoint>(bbox.maxX, bbox.minY),
point<GlobalPoint>(bbox.maxX, bbox.maxY),
),
lineSegment(
point<GlobalPoint>(bbox.maxX, bbox.maxY),
point<GlobalPoint>(bbox.minX, bbox.maxY),
),
lineSegment(
point<GlobalPoint>(bbox.minX, bbox.maxY),
point<GlobalPoint>(bbox.minX, bbox.minY),
),
],
{
color: opts?.color ?? "cyan",
@ -113,22 +117,22 @@ export const debugDrawBounds = (
(isBounds(box) ? [box] : box).forEach((bbox) =>
debugDrawLine(
[
[
[bbox[0], bbox[1]],
[bbox[2], bbox[1]],
],
[
[bbox[2], bbox[1]],
[bbox[2], bbox[3]],
],
[
[bbox[2], bbox[3]],
[bbox[0], bbox[3]],
],
[
[bbox[0], bbox[3]],
[bbox[0], bbox[1]],
],
lineSegment(
point<GlobalPoint>(bbox[0], bbox[1]),
point<GlobalPoint>(bbox[2], bbox[1]),
),
lineSegment(
point<GlobalPoint>(bbox[2], bbox[1]),
point<GlobalPoint>(bbox[2], bbox[3]),
),
lineSegment(
point<GlobalPoint>(bbox[2], bbox[3]),
point<GlobalPoint>(bbox[0], bbox[3]),
),
lineSegment(
point<GlobalPoint>(bbox[0], bbox[3]),
point<GlobalPoint>(bbox[0], bbox[1]),
),
],
{
color: opts?.color ?? "green",

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