Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot] 4b4de3db1a build(deps): bump braces from 3.0.2 to 3.0.3 in /dev-docs
Bumps [braces](https://github.com/micromatch/braces) from 3.0.2 to 3.0.3.
- [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3)

---
updated-dependencies:
- dependency-name: braces
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-25 16:56:26 +00:00
150 changed files with 5789 additions and 10144 deletions
-1
View File
@@ -8,7 +8,6 @@
!package.json
!public/
!packages/
!scripts/
!tsconfig.json
!yarn.lock
-2
View File
@@ -6,5 +6,3 @@ firebase/
dist/
public/workbox
packages/excalidraw/types
examples/**/public
dev-dist
+1
View File
@@ -1,6 +1,7 @@
name: Tests
on:
pull_request:
push:
branches: master
+1 -1
View File
@@ -12,7 +12,7 @@ ARG NODE_ENV=production
RUN yarn build:app:docker
FROM nginx:1.27-alpine
FROM nginx:1.24-alpine
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
@@ -9,9 +9,9 @@ All `props` are _optional_.
| [`isCollaborating`](#iscollaborating) | `boolean` | _ | This indicates if the app is in `collaboration` mode |
| [`onChange`](#onchange) | `function` | _ | This callback is triggered whenever the component updates due to any change. This callback will receive the excalidraw `elements` and the current `app state`. |
| [`onPointerUpdate`](#onpointerupdate) | `function` | _ | Callback triggered when mouse pointer is updated. |
| [`onPointerDown`](#onpointerdown) | `function` | _ | This prop if passed gets triggered on pointer down events |
| [`onPointerDown`](#onpointerdown) | `function` | _ | This prop if passed gets triggered on pointer down evenets |
| [`onScrollChange`](#onscrollchange) | `function` | _ | This prop if passed gets triggered when scrolling the canvas. |
| [`onPaste`](#onpaste) | `function` | _ | Callback to be triggered if passed when something is pasted into the scene |
| [`onPaste`](#onpaste) | `function` | _ | Callback to be triggered if passed when the something is pasted in to the scene |
| [`onLibraryChange`](#onlibrarychange) | `function` | _ | The callback if supplied is triggered when the library is updated and receives the library items. |
| [`onLinkOpen`](#onlinkopen) | `function` | _ | The callback if supplied is triggered when any link is opened. |
| [`langCode`](#langcode) | `string` | `en` | Language code string to be used in Excalidraw |
@@ -26,7 +26,7 @@ All `props` are _optional_.
| [`UIOptions`](/docs/@excalidraw/excalidraw/api/props/ui-options) | `object` | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/constants.ts#L151) | To customise UI options. Currently we support customising [`canvas actions`](/docs/@excalidraw/excalidraw/api/props/ui-options#canvasactions) |
| [`detectScroll`](#detectscroll) | `boolean` | `true` | Indicates whether to update the offsets when nearest ancestor is scrolled. |
| [`handleKeyboardGlobally`](#handlekeyboardglobally) | `boolean` | `false` | Indicates whether to bind the keyboard events to document. |
| [`autoFocus`](#autofocus) | `boolean` | `false` | Indicates whether to focus the Excalidraw component on page load |
| [`autoFocus`](#autofocus) | `boolean` | `false` | indicates whether to focus the Excalidraw component on page load |
| [`generateIdForFile`](#generateidforfile) | `function` | _ | Allows you to override `id` generation for files added on canvas |
| [`validateEmbeddable`](#validateEmbeddable) | string[] | `boolean | RegExp | RegExp[] | ((link: string) => boolean | undefined)` | \_ | use for custom src url validation |
| [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` |
+3 -3
View File
@@ -18,13 +18,13 @@
"@docusaurus/core": "2.2.0",
"@docusaurus/preset-classic": "2.2.0",
"@docusaurus/theme-live-codeblock": "2.2.0",
"@excalidraw/excalidraw": "0.17.6",
"@excalidraw/excalidraw": "0.17.0",
"@mdx-js/react": "^1.6.22",
"clsx": "^1.2.1",
"docusaurus-plugin-sass": "0.2.3",
"prism-react-renderer": "^1.3.5",
"react": "18.2.0",
"react-dom": "18.2.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"sass": "1.57.1"
},
"devDependencies": {
+21 -13
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==
@@ -1718,10 +1718,10 @@
url-loader "^4.1.1"
webpack "^5.73.0"
"@excalidraw/excalidraw@0.17.6":
version "0.17.6"
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.17.6.tgz#5fd208ce69d33ca712d1804b50d7d06d5c46ac4d"
integrity sha512-fyCl+zG/Z5yhHDh5Fq2ZGmphcrALmuOdtITm8gN4d8w4ntnaopTXcTfnAAaU3VleDC6LhTkoLOTG6P5kgREiIg==
"@excalidraw/excalidraw@0.17.0":
version "0.17.0"
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.17.0.tgz#3c64aa8e36406ac171b008cfecbdce5bb0755725"
integrity sha512-NzP22v5xMqxYW27ZtTHhiGFe7kE8NeBk45aoeM/mDSkXiOXPDH+PcvwzHRN/Ei+Vj/0sTPHxejn8bZyRWKGjXg==
"@hapi/hoek@^9.0.0":
version "9.3.0"
@@ -2790,11 +2790,11 @@ brace-expansion@^1.1.7:
concat-map "0.0.1"
braces@^3.0.2, 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==
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.0.1"
fill-range "^7.1.1"
browserslist@^4.0.0, browserslist@^4.14.5, browserslist@^4.16.6, browserslist@^4.18.1, browserslist@^4.20.2, browserslist@^4.20.3, browserslist@^4.21.2:
version "4.21.2"
@@ -4004,10 +4004,10 @@ filesize@^8.0.6:
resolved "https://registry.yarnpkg.com/filesize/-/filesize-8.0.7.tgz#695e70d80f4e47012c132d57a059e80c6b580bd8"
integrity sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==
fill-range@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==
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"
@@ -6260,6 +6260,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"
+4 -4
View File
@@ -13,13 +13,13 @@
"dependencies": {
"@excalidraw/excalidraw": "*",
"next": "14.1",
"react": "18.2.0",
"react-dom": "18.2.0"
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "18.2.0",
"@types/react-dom": "18.2.0",
"@types/react": "^18",
"@types/react-dom": "^18",
"path2d-polyfill": "2.0.1",
"typescript": "^5"
}
+41 -17
View File
@@ -95,11 +95,6 @@
color: #fff;
}
</style>
<!-- Warmup the connection for Google fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!------------------------------------------------------------------------->
<% if (typeof PROD != 'undefined' && PROD == true) { %>
<script>
@@ -119,16 +114,52 @@
) {
window.location.href = "https://app.excalidraw.com";
}
// point into our CDN in prod
window.EXCALIDRAW_ASSET_PATH =
"https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/oss/";
</script>
<!-- Following placeholder is replaced during the build step -->
<!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS -->
<% } else { %>
<script>
window.EXCALIDRAW_ASSET_PATH = window.origin;
</script>
<% } %>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<!-- Excalidraw version -->
<meta name="version" content="{version}" />
<!-- Warmup the connection for Google fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!-- Preload all default fonts and Virgil for backwards compatibility to avoid swap on init -->
<% if (typeof PROD != 'undefined' && PROD == true) { %>
<link
rel="preload"
href="https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/oss/Excalifont-Regular-C9eKQy_N.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/oss/Virgil-Regular-hO16qHwV.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/oss/ComicShanns-Regular-D0c8wzsC.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<% } else { %>
<!-- in DEV we need to preload from the local server and without the hash -->
<link
rel="preload"
@@ -153,7 +184,7 @@
/>
<% } %>
<!-- For Nunito only preload the latin range, which should be good enough for now -->
<!-- For Nunito only preload the latin range, which should be enough for now -->
<link
rel="preload"
href="https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2"
@@ -169,13 +200,6 @@
type="text/css"
/>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<!-- Excalidraw version -->
<meta name="version" content="{version}" />
<% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' &&
VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %>
<script>
+1 -11
View File
@@ -26,17 +26,7 @@
"node": ">=18.0.0"
},
"dependencies": {
"firebase": "8.3.3",
"idb-keyval": "6.0.3",
"jotai": "1.13.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"vite-plugin-html": "3.2.2",
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"i18next-browser-languagedetector": "6.1.4",
"socket.io-client": "4.7.2"
"vite-plugin-html": "3.2.2"
},
"prettier": "@excalidraw/prettier-config",
"scripts": {
+6 -5
View File
@@ -2,6 +2,7 @@ import { vi } from "vitest";
import {
act,
render,
updateSceneData,
waitFor,
} from "../../packages/excalidraw/tests/test-utils";
import ExcalidrawApp from "../App";
@@ -87,12 +88,12 @@ describe("collaboration", () => {
const rect1 = API.createElement({ ...rect1Props });
const rect2 = API.createElement({ ...rect2Props });
API.updateScene({
updateSceneData({
elements: syncInvalidIndices([rect1, rect2]),
storeAction: StoreAction.CAPTURE,
});
API.updateScene({
updateSceneData({
elements: syncInvalidIndices([
rect1,
newElementWith(h.elements[1], { isDeleted: true }),
@@ -142,7 +143,7 @@ describe("collaboration", () => {
});
// simulate force deleting the element remotely
API.updateScene({
updateSceneData({
elements: syncInvalidIndices([rect1]),
storeAction: StoreAction.UPDATE,
});
@@ -177,7 +178,7 @@ describe("collaboration", () => {
act(() => h.app.actionManager.executeAction(undoAction));
// simulate local update
API.updateScene({
updateSceneData({
elements: syncInvalidIndices([
h.elements[0],
newElementWith(h.elements[1], { x: 100 }),
@@ -215,7 +216,7 @@ describe("collaboration", () => {
});
// simulate force deleting the element remotely
API.updateScene({
updateSceneData({
elements: syncInvalidIndices([rect1]),
storeAction: StoreAction.UPDATE,
});
+3 -3
View File
@@ -26,10 +26,10 @@ export default defineConfig({
assetFileNames(chunkInfo) {
if (chunkInfo?.name?.endsWith(".woff2")) {
// put on root so we are flexible about the CDN path
return "[name]-[hash][extname]";
return '[name]-[hash][extname]';
}
return "assets/[name]-[hash][extname]";
return 'assets/[name]-[hash][extname]';
},
// Creating separate chunk for locales except for en and percentages.json so they
// can be cached at runtime and not merged with
@@ -44,7 +44,7 @@ export default defineConfig({
// Taking the substring after "locales/"
return `locales/${id.substring(index + 8)}`;
}
},
}
},
},
sourcemap: true,
+14 -9
View File
@@ -9,8 +9,19 @@
"examples/excalidraw",
"examples/excalidraw/*"
],
"dependencies": {
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.4",
"idb-keyval": "6.0.3",
"jotai": "1.13.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"socket.io-client": "4.7.2"
},
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "7.21.11",
"@excalidraw/eslint-config": "1.0.3",
"@excalidraw/prettier-config": "1.0.2",
"@types/chai": "4.3.0",
@@ -40,7 +51,7 @@
"vite-plugin-ejs": "1.7.0",
"vite-plugin-pwa": "0.17.4",
"vite-plugin-svgr": "2.4.0",
"vitest": "1.6.0",
"vitest": "1.5.3",
"vitest-canvas-mock": "0.3.2"
},
"engines": {
@@ -76,12 +87,6 @@
"autorelease": "node scripts/autorelease.js",
"prerelease:excalidraw": "node scripts/prerelease.js",
"build:preview": "yarn build && vite preview --port 5000",
"release:excalidraw": "node scripts/release.js",
"rm:build": "rm -rf excalidraw-app/{build,dist,dev-dist} && rm -rf packages/*/{dist,build} && rm -rf examples/*/*/{build,dist}",
"rm:node_modules": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules",
"clean-install": "yarn rm:node_modules && yarn install"
},
"resolutions": {
"@types/react": "18.2.0"
"release:excalidraw": "node scripts/release.js"
}
}
@@ -5,27 +5,20 @@ import { t } from "../i18n";
import { register } from "./register";
import { getNonDeletedElements } from "../element";
import type { ExcalidrawElement } from "../element/types";
import type { AppClassProperties, AppState } from "../types";
import { mutateElement, newElementWith } from "../element/mutateElement";
import type { AppState } from "../types";
import { newElementWith } from "../element/mutateElement";
import { getElementsInGroup } from "../groups";
import { LinearElementEditor } from "../element/linearElementEditor";
import { fixBindingsAfterDeletion } from "../element/binding";
import {
isBoundToContainer,
isElbowArrow,
isFrameLikeElement,
} from "../element/typeChecks";
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
import { updateActiveTool } from "../utils";
import { TrashIcon } from "../components/icons";
import { StoreAction } from "../store";
import { mutateElbowArrow } from "../element/routing";
const deleteSelectedElements = (
elements: readonly ExcalidrawElement[],
appState: AppState,
app: AppClassProperties,
) => {
const elementsMap = app.scene.getNonDeletedElementsMap();
const framesToBeDeleted = new Set(
getSelectedElements(
elements.filter((el) => isFrameLikeElement(el)),
@@ -36,26 +29,6 @@ const deleteSelectedElements = (
return {
elements: elements.map((el) => {
if (appState.selectedElementIds[el.id]) {
if (el.boundElements) {
el.boundElements.forEach((candidate) => {
const bound = app.scene
.getNonDeletedElementsMap()
.get(candidate.id);
if (bound && isElbowArrow(bound)) {
mutateElement(bound, {
startBinding:
el.id === bound.startBinding?.elementId
? null
: bound.startBinding,
endBinding:
el.id === bound.endBinding?.elementId
? null
: bound.endBinding,
});
mutateElbowArrow(bound, elementsMap, bound.points);
}
});
}
return newElementWith(el, { isDeleted: true });
}
@@ -157,11 +130,7 @@ export const actionDeleteSelected = register({
: endBindingElement,
};
LinearElementEditor.deletePoints(
element,
selectedPointsIndices,
elementsMap,
);
LinearElementEditor.deletePoints(element, selectedPointsIndices);
return {
elements,
@@ -180,7 +149,7 @@ export const actionDeleteSelected = register({
};
}
let { elements: nextElements, appState: nextAppState } =
deleteSelectedElements(elements, appState, app);
deleteSelectedElements(elements, appState);
fixBindingsAfterDeletion(
nextElements,
elements.filter(({ id }) => appState.selectedElementIds[id]),
@@ -40,11 +40,12 @@ export const actionDuplicateSelection = register({
icon: DuplicateIcon,
trackEvent: { category: "element" },
perform: (elements, appState, formData, app) => {
const elementsMap = app.scene.getNonDeletedElementsMap();
// duplicate selected point(s) if editing a line
if (appState.editingLinearElement) {
const ret = LinearElementEditor.duplicateSelectedPoints(
appState,
app.scene.getNonDeletedElementsMap(),
elementsMap,
);
if (!ret) {
@@ -1,4 +1,3 @@
import React from "react";
import { Excalidraw } from "../index";
import { queryByTestId, fireEvent } from "@testing-library/react";
import { render } from "../tests/test-utils";
@@ -38,7 +38,6 @@ export const actionFinalize = register({
startBindingElement,
endBindingElement,
elementsMap,
scene,
);
}
return {
@@ -73,8 +72,8 @@ export const actionFinalize = register({
const multiPointElement = appState.multiElement
? appState.multiElement
: appState.newElement?.type === "freedraw"
? appState.newElement
: appState.editingElement?.type === "freedraw"
? appState.editingElement
: null;
if (multiPointElement) {
@@ -137,7 +136,6 @@ export const actionFinalize = register({
appState,
{ x, y },
elementsMap,
elements,
);
}
}
@@ -176,8 +174,7 @@ export const actionFinalize = register({
? appState.activeTool
: activeTool,
activeEmbeddable: null,
newElement: null,
selectionElement: null,
draggingElement: null,
multiElement: null,
editingElement: null,
startBoundElement: null,
@@ -205,7 +202,7 @@ export const actionFinalize = register({
keyTest: (event, appState) =>
(event.key === KEYS.ESCAPE &&
(appState.editingLinearElement !== null ||
(!appState.newElement && appState.multiElement === null))) ||
(!appState.draggingElement && appState.multiElement === null))) ||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
appState.multiElement !== null),
PanelComponent: ({ appState, updateData, data }) => (
@@ -125,8 +125,6 @@ const flipElements = (
bindOrUnbindLinearElements(
selectedElements.filter(isLinearElement),
elementsMap,
app.scene.getNonDeletedElements(),
app.scene,
isBindingEnabled(appState),
[],
);
@@ -21,9 +21,7 @@ const writeData = (
!appState.multiElement &&
!appState.resizingElement &&
!appState.editingElement &&
!appState.newElement &&
!appState.selectedElementsAreBeingDragged &&
!appState.selectionElement
!appState.draggingElement
) {
const result = updater();
@@ -52,7 +50,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
icon: UndoIcon,
trackEvent: { category: "history" },
viewMode: false,
perform: (elements, appState, value, app) =>
perform: (elements, appState) =>
writeData(appState, () =>
history.undo(
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
@@ -93,7 +91,7 @@ export const createRedoAction: ActionCreator = (history, store) => ({
icon: RedoIcon,
trackEvent: { category: "history" },
viewMode: false,
perform: (elements, appState, _, app) =>
perform: (elements, appState) =>
writeData(appState, () =>
history.redo(
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
@@ -1,6 +1,6 @@
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
import { LinearElementEditor } from "../element/linearElementEditor";
import { isElbowArrow, isLinearElement } from "../element/typeChecks";
import { isLinearElement } from "../element/typeChecks";
import type { ExcalidrawLinearElement } from "../element/types";
import { StoreAction } from "../store";
import { register } from "./register";
@@ -29,8 +29,7 @@ export const actionToggleLinearEditor = register({
if (
!appState.editingLinearElement &&
selectedElements.length === 1 &&
isLinearElement(selectedElements[0]) &&
!isElbowArrow(selectedElements[0])
isLinearElement(selectedElements[0])
) {
return true;
}
@@ -1,4 +1,3 @@
import React from "react";
import { Excalidraw } from "../index";
import { queryByTestId } from "@testing-library/react";
import { render } from "../tests/test-utils";
@@ -7,6 +6,8 @@ import { API } from "../tests/helpers/api";
import { COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_PICKS } from "../colors";
import { FONT_FAMILY, STROKE_WIDTH } from "../constants";
const { h } = window;
describe("element locking", () => {
beforeEach(async () => {
await render(<Excalidraw />);
@@ -21,7 +22,7 @@ describe("element locking", () => {
// just in case we change it in the future
expect(color).not.toBe(COLOR_PALETTE.transparent);
API.setAppState({
h.setState({
currentItemBackgroundColor: color,
});
const activeColor = queryByTestId(
@@ -39,14 +40,14 @@ describe("element locking", () => {
// just in case we change it in the future
expect(color).not.toBe(COLOR_PALETTE.transparent);
API.setAppState({
h.setState({
currentItemBackgroundColor: color,
currentItemFillStyle: "hachure",
});
const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
expect(hachureFillButton).toHaveClass("active");
API.setAppState({
h.setState({
currentItemFillStyle: "solid",
});
const solidFillStyle = queryByTestId(document.body, `fill-solid`);
@@ -56,7 +57,7 @@ describe("element locking", () => {
it("should not show fill style when background transparent", () => {
UI.clickTool("rectangle");
API.setAppState({
h.setState({
currentItemBackgroundColor: COLOR_PALETTE.transparent,
currentItemFillStyle: "hachure",
});
@@ -68,7 +69,7 @@ describe("element locking", () => {
it("should show horizontal text align for text tool", () => {
UI.clickTool("text");
API.setAppState({
h.setState({
currentItemTextAlign: "right",
});
@@ -84,7 +85,7 @@ describe("element locking", () => {
backgroundColor: "red",
fillStyle: "cross-hatch",
});
API.setElements([rect]);
h.elements = [rect];
API.setSelectedElements([rect]);
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
@@ -97,7 +98,7 @@ describe("element locking", () => {
backgroundColor: COLOR_PALETTE.transparent,
fillStyle: "cross-hatch",
});
API.setElements([rect]);
h.elements = [rect];
API.setSelectedElements([rect]);
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
@@ -113,7 +114,7 @@ describe("element locking", () => {
type: "rectangle",
strokeWidth: STROKE_WIDTH.thin,
});
API.setElements([rect1, rect2]);
h.elements = [rect1, rect2];
API.setSelectedElements([rect1, rect2]);
const thinStrokeWidthButton = queryByTestId(
@@ -132,7 +133,7 @@ describe("element locking", () => {
type: "rectangle",
strokeWidth: STROKE_WIDTH.bold,
});
API.setElements([rect1, rect2]);
h.elements = [rect1, rect2];
API.setSelectedElements([rect1, rect2]);
expect(queryByTestId(document.body, `strokeWidth-thin`)).not.toBe(null);
@@ -156,7 +157,7 @@ describe("element locking", () => {
type: "text",
fontFamily: FONT_FAMILY["Comic Shanns"],
});
API.setElements([rect, text]);
h.elements = [rect, text];
API.setSelectedElements([rect, text]);
expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
+17 -255
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,
@@ -50,12 +50,8 @@ import {
ArrowheadDiamondIcon,
ArrowheadDiamondOutlineIcon,
fontSizeIcon,
sharpArrowIcon,
roundArrowIcon,
elbowArrowIcon,
} from "../components/icons";
import {
ARROW_TYPE,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
FONT_FAMILY,
@@ -71,15 +67,12 @@ import {
import { mutateElement, newElementWith } from "../element/mutateElement";
import { getBoundTextElement } from "../element/textElement";
import {
isArrowElement,
isBoundToContainer,
isElbowArrow,
isLinearElement,
isUsingAdaptiveRadius,
} from "../element/typeChecks";
import type {
Arrowhead,
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
@@ -98,23 +91,10 @@ import {
isSomeElementSelected,
} from "../scene";
import { hasStrokeColor } from "../scene/comparisons";
import {
arrayToMap,
getFontFamilyString,
getShortcutKey,
tupleToCoors,
} from "../utils";
import { arrayToMap, getFontFamilyString, getShortcutKey } from "../utils";
import { register } from "./register";
import { StoreAction } from "../store";
import { Fonts, getLineHeight } from "../fonts";
import {
bindLinearElement,
bindPointToSnapToElementOutline,
calculateFixedPointForElbowArrowBinding,
getHoveredElementForBinding,
} from "../element/binding";
import { mutateElbowArrow } from "../element/routing";
import { LinearElementEditor } from "../element/linearElementEditor";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@@ -850,7 +830,7 @@ export const actionChangeFontFamily = register({
ExcalidrawTextElement,
ExcalidrawElement | null
>();
let uniqueChars = new Set<string>();
let uniqueGlyphs = new Set<string>();
let skipFontFaceCheck = false;
const fontsCache = Array.from(Fonts.loadedFontsCache.values());
@@ -898,8 +878,8 @@ export const actionChangeFontFamily = register({
}
if (!skipFontFaceCheck) {
uniqueChars = new Set([
...uniqueChars,
uniqueGlyphs = new Set([
...uniqueGlyphs,
...Array.from(newElement.originalText),
]);
}
@@ -919,9 +899,12 @@ export const actionChangeFontFamily = register({
const fontString = `10px ${getFontFamilyString({
fontFamily: nextFontFamily,
})}`;
const chars = Array.from(uniqueChars.values()).join();
const glyphs = Array.from(uniqueGlyphs.values()).join();
if (skipFontFaceCheck || window.document.fonts.check(fontString, chars)) {
if (
skipFontFaceCheck ||
window.document.fonts.check(fontString, glyphs)
) {
// we either skip the check (have at least one font face loaded) or do the check and find out all the font faces have loaded
for (const [element, container] of elementContainerMapping) {
// trigger synchronous redraw
@@ -933,8 +916,8 @@ export const actionChangeFontFamily = register({
);
}
} else {
// otherwise try to load all font faces for the given chars and redraw elements once our font faces loaded
window.document.fonts.load(fontString, chars).then((fontFaces) => {
// otherwise try to load all font faces for the given glyphs and redraw elements once our font faces loaded
window.document.fonts.load(fontString, glyphs).then((fontFaces) => {
for (const [element, container] of elementContainerMapping) {
// use latest element state to ensure we don't have closure over an old instance in order to avoid possible race conditions (i.e. font faces load out-of-order while rapidly switching fonts)
const latestElement = app.scene.getElement(element.id);
@@ -1321,12 +1304,8 @@ export const actionChangeRoundness = register({
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) => {
if (isElbowArrow(el)) {
return el;
}
return newElementWith(el, {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
roundness:
value === "round"
? {
@@ -1335,8 +1314,8 @@ export const actionChangeRoundness = register({
: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
});
}),
}),
),
appState: {
...appState,
currentItemRoundness: value,
@@ -1376,8 +1355,7 @@ export const actionChangeRoundness = register({
appState,
(element) =>
hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
(element) =>
!isArrowElement(element) && element.hasOwnProperty("roundness"),
(element) => element.hasOwnProperty("roundness"),
(hasSelection) =>
hasSelection ? null : appState.currentItemRoundness,
)}
@@ -1540,219 +1518,3 @@ export const actionChangeArrowhead = register({
);
},
});
export const actionChangeArrowType = register({
name: "changeArrowType",
label: "Change arrow types",
trackEvent: false,
perform: (elements, appState, value, app) => {
return {
elements: changeProperty(elements, appState, (el) => {
if (!isArrowElement(el)) {
return el;
}
const newElement = newElementWith(el, {
roundness:
value === ARROW_TYPE.round
? {
type: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null,
elbowed: value === ARROW_TYPE.elbow,
points:
value === ARROW_TYPE.elbow || el.elbowed
? [el.points[0], el.points[el.points.length - 1]]
: el.points,
});
if (isElbowArrow(newElement)) {
const elementsMap = app.scene.getNonDeletedElementsMap();
app.dismissLinearEditor();
const startGlobalPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
newElement,
0,
elementsMap,
);
const endGlobalPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
newElement,
-1,
elementsMap,
);
const startHoveredElement =
!newElement.startBinding &&
getHoveredElementForBinding(
tupleToCoors(startGlobalPoint),
elements,
elementsMap,
true,
);
const endHoveredElement =
!newElement.endBinding &&
getHoveredElementForBinding(
tupleToCoors(endGlobalPoint),
elements,
elementsMap,
true,
);
const startElement = startHoveredElement
? startHoveredElement
: newElement.startBinding &&
(elementsMap.get(
newElement.startBinding.elementId,
) as ExcalidrawBindableElement);
const endElement = endHoveredElement
? endHoveredElement
: newElement.endBinding &&
(elementsMap.get(
newElement.endBinding.elementId,
) as ExcalidrawBindableElement);
const finalStartPoint = startHoveredElement
? bindPointToSnapToElementOutline(
startGlobalPoint,
endGlobalPoint,
startHoveredElement,
elementsMap,
)
: startGlobalPoint;
const finalEndPoint = endHoveredElement
? bindPointToSnapToElementOutline(
endGlobalPoint,
startGlobalPoint,
endHoveredElement,
elementsMap,
)
: endGlobalPoint;
startHoveredElement &&
bindLinearElement(
newElement,
startHoveredElement,
"start",
elementsMap,
);
endHoveredElement &&
bindLinearElement(
newElement,
endHoveredElement,
"end",
elementsMap,
);
mutateElbowArrow(
newElement,
elementsMap,
[finalStartPoint, finalEndPoint].map(
(point) =>
[point[0] - newElement.x, point[1] - newElement.y] as Point,
),
[0, 0],
{
...(startElement && newElement.startBinding
? {
startBinding: {
// @ts-ignore TS cannot discern check above
...newElement.startBinding!,
...calculateFixedPointForElbowArrowBinding(
newElement,
startElement,
"start",
elementsMap,
),
},
}
: {}),
...(endElement && newElement.endBinding
? {
endBinding: {
// @ts-ignore TS cannot discern check above
...newElement.endBinding,
...calculateFixedPointForElbowArrowBinding(
newElement,
endElement,
"end",
elementsMap,
),
},
}
: {}),
},
);
} else {
mutateElement(
newElement,
{
startBinding: newElement.startBinding
? { ...newElement.startBinding, fixedPoint: null }
: null,
endBinding: newElement.endBinding
? { ...newElement.endBinding, fixedPoint: null }
: null,
},
false,
);
}
return newElement;
}),
appState: {
...appState,
currentItemArrowType: value,
},
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
return (
<fieldset>
<legend>{t("labels.arrowtypes")}</legend>
<ButtonIconSelect
group="arrowtypes"
options={[
{
value: ARROW_TYPE.sharp,
text: t("labels.arrowtype_sharp"),
icon: sharpArrowIcon,
testId: "sharp-arrow",
},
{
value: ARROW_TYPE.round,
text: t("labels.arrowtype_round"),
icon: roundArrowIcon,
testId: "round-arrow",
},
{
value: ARROW_TYPE.elbow,
text: t("labels.arrowtype_elbowed"),
icon: elbowArrowIcon,
testId: "elbow-arrow",
},
]}
value={getFormValue(
elements,
appState,
(element) => {
if (isArrowElement(element)) {
return element.elbowed
? ARROW_TYPE.elbow
: element.roundness
? ARROW_TYPE.round
: ARROW_TYPE.sharp;
}
return null;
},
(element) => isArrowElement(element),
(hasSelection) =>
hasSelection ? null : appState.currentItemArrowType,
)}
onChange={(value) => updateData(value)}
/>
</fieldset>
);
},
});
-1
View File
@@ -70,7 +70,6 @@ export type ActionName =
| "changeSloppiness"
| "changeStrokeStyle"
| "changeArrowhead"
| "changeArrowType"
| "changeOpacity"
| "changeFontSize"
| "toggleCanvasMenu"
+2 -9
View File
@@ -1,6 +1,5 @@
import { COLOR_PALETTE } from "./colors";
import {
ARROW_TYPE,
DEFAULT_ELEMENT_PROPS,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
@@ -34,14 +33,13 @@ export const getDefaultAppState = (): Omit<
currentItemStartArrowhead: null,
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
currentItemRoundness: "round",
currentItemArrowType: ARROW_TYPE.round,
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
currentHoveredFontFamily: null,
cursorButton: "up",
activeEmbeddable: null,
newElement: null,
draggingElement: null,
editingElement: null,
editingGroupId: null,
editingLinearElement: null,
@@ -145,11 +143,6 @@ const APP_STATE_STORAGE_CONF = (<
export: false,
server: false,
},
currentItemArrowType: {
browser: true,
export: false,
server: false,
},
currentItemOpacity: { browser: true, export: false, server: false },
currentItemRoughness: { browser: true, export: false, server: false },
currentItemStartArrowhead: { browser: true, export: false, server: false },
@@ -160,7 +153,7 @@ const APP_STATE_STORAGE_CONF = (<
currentHoveredFontFamily: { browser: false, export: false, server: false },
cursorButton: { browser: true, export: false, server: false },
activeEmbeddable: { browser: false, export: false, server: false },
newElement: { browser: false, export: false, server: false },
draggingElement: { browser: false, export: false, server: false },
editingElement: { browser: false, export: false, server: false },
editingGroupId: { browser: true, export: false, server: false },
editingLinearElement: { browser: false, export: false, server: false },
-105
View File
@@ -1,105 +0,0 @@
export default class BinaryHeap<T> {
private content: T[] = [];
constructor(private scoreFunction: (node: T) => number) {}
sinkDown(idx: number) {
const node = this.content[idx];
while (idx > 0) {
const parentN = ((idx + 1) >> 1) - 1;
const parent = this.content[parentN];
if (this.scoreFunction(node) < this.scoreFunction(parent)) {
this.content[parentN] = node;
this.content[idx] = parent;
idx = parentN; // TODO: Optimize
} else {
break;
}
}
}
bubbleUp(idx: number) {
const length = this.content.length;
const node = this.content[idx];
const score = this.scoreFunction(node);
while (true) {
const child2N = (idx + 1) << 1;
const child1N = child2N - 1;
let swap = null;
let child1Score = 0;
if (child1N < length) {
const child1 = this.content[child1N];
child1Score = this.scoreFunction(child1);
if (child1Score < score) {
swap = child1N;
}
}
if (child2N < length) {
const child2 = this.content[child2N];
const child2Score = this.scoreFunction(child2);
if (child2Score < (swap === null ? score : child1Score)) {
swap = child2N;
}
}
if (swap !== null) {
this.content[idx] = this.content[swap];
this.content[swap] = node;
idx = swap; // TODO: Optimize
} else {
break;
}
}
}
push(node: T) {
this.content.push(node);
this.sinkDown(this.content.length - 1);
}
pop(): T | null {
if (this.content.length === 0) {
return null;
}
const result = this.content[0];
const end = this.content.pop()!;
if (this.content.length > 0) {
this.content[0] = end;
this.bubbleUp(0);
}
return result;
}
remove(node: T) {
if (this.content.length === 0) {
return;
}
const i = this.content.indexOf(node);
const end = this.content.pop()!;
if (i < this.content.length) {
this.content[i] = end;
if (this.scoreFunction(end) < this.scoreFunction(node)) {
this.sinkDown(i);
} else {
this.bubbleUp(i);
}
}
}
size(): number {
return this.content.length;
}
rescoreElement(node: T) {
this.sinkDown(this.content.indexOf(node));
}
}
+2 -6
View File
@@ -1100,6 +1100,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
try {
// TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state
ElementsChange.redrawTextBoundingBoxes(nextElements, changedElements);
ElementsChange.redrawBoundArrows(nextElements, changedElements);
// the following reorder performs also mutations, but only on new instances of changed elements
// (unless something goes really bad and it fallbacks to fixing all invalid indices)
@@ -1108,9 +1109,6 @@ export class ElementsChange implements Change<SceneElementsMap> {
changedElements,
flags,
);
// Need ordered nextElements to avoid z-index binding issues
ElementsChange.redrawBoundArrows(nextElements, changedElements);
} catch (e) {
console.error(
`Couldn't mutate elements after applying elements change`,
@@ -1462,9 +1460,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
) {
for (const element of changed.values()) {
if (!element.isDeleted && isBindableElement(element)) {
updateBoundElements(element, elements, {
changedElements: changed,
});
updateBoundElements(element, elements);
}
}
}
+10
View File
@@ -257,6 +257,8 @@ const chartLines = (
type: "line",
x,
y,
startArrowhead: null,
endArrowhead: null,
width: chartWidth,
points: [
[0, 0],
@@ -271,6 +273,8 @@ const chartLines = (
type: "line",
x,
y,
startArrowhead: null,
endArrowhead: null,
height: chartHeight,
points: [
[0, 0],
@@ -285,6 +289,8 @@ const chartLines = (
type: "line",
x,
y: y - BAR_HEIGHT - BAR_GAP,
startArrowhead: null,
endArrowhead: null,
strokeStyle: "dotted",
width: chartWidth,
opacity: GRID_OPACITY,
@@ -412,6 +418,8 @@ const chartTypeLine = (
type: "line",
x: x + BAR_GAP + BAR_WIDTH / 2,
y: y - BAR_GAP,
startArrowhead: null,
endArrowhead: null,
height: maxY - minY,
width: maxX - minX,
strokeWidth: 2,
@@ -445,6 +453,8 @@ const chartTypeLine = (
type: "line",
x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2,
y: y - cy,
startArrowhead: null,
endArrowhead: null,
height: cy,
strokeStyle: "dotted",
opacity: GRID_OPACITY,
+2 -9
View File
@@ -21,11 +21,10 @@ import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
import { capitalizeString, isTransparent } from "../utils";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { hasStrokeColor, toolIsArrow } from "../scene/comparisons";
import { hasStrokeColor } from "../scene/comparisons";
import { trackEvent } from "../analytics";
import {
hasBoundTextElement,
isElbowArrow,
isLinearElement,
isTextElement,
} from "../element/typeChecks";
@@ -122,8 +121,7 @@ export const SelectedShapeActions = ({
const showLineEditorAction =
!appState.editingLinearElement &&
targetElements.length === 1 &&
isLinearElement(targetElements[0]) &&
!isElbowArrow(targetElements[0]);
isLinearElement(targetElements[0]);
return (
<div className="panelColumn">
@@ -157,11 +155,6 @@ export const SelectedShapeActions = ({
<>{renderAction("changeRoundness")}</>
)}
{(toolIsArrow(appState.activeTool.type) ||
targetElements.some((element) => toolIsArrow(element.type))) && (
<>{renderAction("changeArrowType")}</>
)}
{(appState.activeTool.type === "text" ||
targetElements.some(isTextElement)) && (
<>
File diff suppressed because it is too large Load Diff
@@ -9,7 +9,7 @@ import {
import {
assertExcalidrawWithSidebar,
assertSidebarDockButton,
} from "./Sidebar/siderbar.test.helpers";
} from "./Sidebar/Sidebar.test";
const { h } = window;
@@ -63,15 +63,15 @@ export const FontPickerList = React.memo(
() =>
Array.from(Fonts.registered.entries())
.filter(([_, { metadata }]) => !metadata.serverSide)
.map(([familyId, { metadata, fonts }]) => {
const fontDescriptor = {
.map(([familyId, { metadata, fontFaces }]) => {
const font = {
value: familyId,
icon: metadata.icon,
text: fonts[0].fontFace.family,
text: fontFaces[0].fontFace.family,
};
if (metadata.deprecated) {
Object.assign(fontDescriptor, {
Object.assign(font, {
deprecated: metadata.deprecated,
badge: {
type: DropDownMenuItemBadgeType.RED,
@@ -80,7 +80,7 @@ export const FontPickerList = React.memo(
});
}
return fontDescriptor as FontDescriptor;
return font as FontDescriptor;
})
.sort((a, b) =>
a.text.toLowerCase() > b.text.toLowerCase() ? 1 : -1,
@@ -89,7 +89,7 @@ export const FontPickerList = React.memo(
);
const sceneFamilies = useMemo(
() => new Set(fonts.getSceneFontFamilies()),
() => new Set(fonts.sceneFamilies),
// cache per selected font family, so hover re-render won't mess it up
// eslint-disable-next-line react-hooks/exhaustive-deps
[selectedFontFamily],
+6 -10
View File
@@ -30,13 +30,10 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
return t("hints.eraserRevert");
}
if (activeTool.type === "arrow" || activeTool.type === "line") {
if (multiMode) {
return t("hints.linearElementMulti");
if (!multiMode) {
return t("hints.linearElement");
}
if (activeTool.type === "arrow") {
return t("hints.arrowTool", { arrowShortcut: getShortcutKey("A") });
}
return t("hints.linearElement");
return t("hints.linearElementMulti");
}
if (activeTool.type === "freedraw") {
@@ -85,7 +82,7 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
if (activeTool.type === "selection") {
if (
appState.selectionElement &&
appState.draggingElement?.type === "selection" &&
!selectedElements.length &&
!appState.editingElement &&
!appState.editingLinearElement
@@ -93,7 +90,7 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
return t("hints.deepBoxSelect");
}
if (appState.gridSize && appState.selectedElementsAreBeingDragged) {
if (appState.gridSize && appState.draggingElement) {
return t("hints.disableSnapping");
}
@@ -111,8 +108,7 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
return t("hints.lineEditor_info");
}
if (
!appState.newElement &&
!appState.selectedElementsAreBeingDragged &&
!appState.draggingElement &&
isTextBindableContainer(selectedElements[0])
) {
return t("hints.bindTextToElement");
@@ -2,8 +2,8 @@ import React from "react";
import { DEFAULT_SIDEBAR } from "../../constants";
import { Excalidraw, Sidebar } from "../../index";
import {
act,
fireEvent,
GlobalTestState,
queryAllByTestId,
queryByTestId,
render,
@@ -11,17 +11,39 @@ import {
withExcalidrawDimensions,
} from "../../tests/test-utils";
import { vi } from "vitest";
import {
assertExcalidrawWithSidebar,
assertSidebarDockButton,
} from "./siderbar.test.helpers";
const toggleSidebar = (
...args: Parameters<typeof window.h.app.toggleSidebar>
): Promise<boolean> => {
return act(() => {
return window.h.app.toggleSidebar(...args);
});
export const assertSidebarDockButton = async <T extends boolean>(
hasDockButton: T,
): Promise<
T extends false
? { dockButton: null; sidebar: HTMLElement }
: { dockButton: HTMLElement; sidebar: HTMLElement }
> => {
const sidebar =
GlobalTestState.renderResult.container.querySelector<HTMLElement>(
".sidebar",
);
expect(sidebar).not.toBe(null);
const dockButton = queryByTestId(sidebar!, "sidebar-dock");
if (hasDockButton) {
expect(dockButton).not.toBe(null);
return { dockButton: dockButton!, sidebar: sidebar! } as any;
}
expect(dockButton).toBe(null);
return { dockButton: null, sidebar: sidebar! } as any;
};
export const assertExcalidrawWithSidebar = async (
sidebar: React.ReactNode,
name: string,
test: () => void,
) => {
await render(
<Excalidraw initialData={{ appState: { openSidebar: { name } } }}>
{sidebar}
</Excalidraw>,
);
await withExcalidrawDimensions({ width: 1920, height: 1080 }, test);
};
describe("Sidebar", () => {
@@ -81,7 +103,7 @@ describe("Sidebar", () => {
// toggle sidebar on
// -------------------------------------------------------------------------
expect(await toggleSidebar({ name: "customSidebar" })).toBe(true);
expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(true);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
@@ -90,7 +112,7 @@ describe("Sidebar", () => {
// toggle sidebar off
// -------------------------------------------------------------------------
expect(await toggleSidebar({ name: "customSidebar" })).toBe(false);
expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(false);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
@@ -99,9 +121,9 @@ describe("Sidebar", () => {
// force-toggle sidebar off (=> still hidden)
// -------------------------------------------------------------------------
expect(await toggleSidebar({ name: "customSidebar", force: false })).toBe(
false,
);
expect(
window.h.app.toggleSidebar({ name: "customSidebar", force: false }),
).toBe(false);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
@@ -110,12 +132,12 @@ describe("Sidebar", () => {
// force-toggle sidebar on
// -------------------------------------------------------------------------
expect(await toggleSidebar({ name: "customSidebar", force: true })).toBe(
true,
);
expect(await toggleSidebar({ name: "customSidebar", force: true })).toBe(
true,
);
expect(
window.h.app.toggleSidebar({ name: "customSidebar", force: true }),
).toBe(true);
expect(
window.h.app.toggleSidebar({ name: "customSidebar", force: true }),
).toBe(true);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
@@ -124,7 +146,9 @@ describe("Sidebar", () => {
// toggle library (= hide custom sidebar)
// -------------------------------------------------------------------------
expect(await toggleSidebar({ name: DEFAULT_SIDEBAR.name })).toBe(true);
expect(window.h.app.toggleSidebar({ name: DEFAULT_SIDEBAR.name })).toBe(
true,
);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
@@ -137,13 +161,13 @@ describe("Sidebar", () => {
// closing sidebar using `{ name: null }`
// -------------------------------------------------------------------------
expect(await toggleSidebar({ name: "customSidebar" })).toBe(true);
expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(true);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).not.toBe(null);
});
expect(await toggleSidebar({ name: null })).toBe(false);
expect(window.h.app.toggleSidebar({ name: null })).toBe(false);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).toBe(null);
@@ -297,9 +321,6 @@ describe("Sidebar", () => {
});
it("shouldn't be user-dockable when only `onDock` supplied w/o `docked`", async () => {
// we expect warnings in this test and don't want to pollute stdout
const mock = jest.spyOn(console, "warn").mockImplementation(() => {});
await render(
<Excalidraw
initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
@@ -320,8 +341,6 @@ describe("Sidebar", () => {
await assertSidebarDockButton(false);
},
);
mock.mockRestore();
});
});
@@ -348,9 +367,9 @@ describe("Sidebar", () => {
).toBeNull();
// open library sidebar
expect(await toggleSidebar({ name: "custom", tab: "library" })).toBe(
true,
);
expect(
window.h.app.toggleSidebar({ name: "custom", tab: "library" }),
).toBe(true);
expect(
container.querySelector<HTMLElement>(
"[role=tabpanel][data-testid=library]",
@@ -358,9 +377,9 @@ describe("Sidebar", () => {
).not.toBeNull();
// switch to comments tab
expect(await toggleSidebar({ name: "custom", tab: "comments" })).toBe(
true,
);
expect(
window.h.app.toggleSidebar({ name: "custom", tab: "comments" }),
).toBe(true);
expect(
container.querySelector<HTMLElement>(
"[role=tabpanel][data-testid=comments]",
@@ -368,9 +387,9 @@ describe("Sidebar", () => {
).not.toBeNull();
// toggle sidebar closed
expect(await toggleSidebar({ name: "custom", tab: "comments" })).toBe(
false,
);
expect(
window.h.app.toggleSidebar({ name: "custom", tab: "comments" }),
).toBe(false);
expect(
container.querySelector<HTMLElement>(
"[role=tabpanel][data-testid=comments]",
@@ -378,9 +397,9 @@ describe("Sidebar", () => {
).toBeNull();
// toggle sidebar open
expect(await toggleSidebar({ name: "custom", tab: "comments" })).toBe(
true,
);
expect(
window.h.app.toggleSidebar({ name: "custom", tab: "comments" }),
).toBe(true);
expect(
container.querySelector<HTMLElement>(
"[role=tabpanel][data-testid=comments]",
@@ -1,42 +0,0 @@
import React from "react";
import { Excalidraw } from "../..";
import {
GlobalTestState,
queryByTestId,
render,
withExcalidrawDimensions,
} from "../../tests/test-utils";
export const assertSidebarDockButton = async <T extends boolean>(
hasDockButton: T,
): Promise<
T extends false
? { dockButton: null; sidebar: HTMLElement }
: { dockButton: HTMLElement; sidebar: HTMLElement }
> => {
const sidebar =
GlobalTestState.renderResult.container.querySelector<HTMLElement>(
".sidebar",
);
expect(sidebar).not.toBe(null);
const dockButton = queryByTestId(sidebar!, "sidebar-dock");
if (hasDockButton) {
expect(dockButton).not.toBe(null);
return { dockButton: dockButton!, sidebar: sidebar! } as any;
}
expect(dockButton).toBe(null);
return { dockButton: null, sidebar: sidebar! } as any;
};
export const assertExcalidrawWithSidebar = async (
sidebar: React.ReactNode,
name: string,
test: () => void,
) => {
await render(
<Excalidraw initialData={{ appState: { openSidebar: { name } } }}>
{sidebar}
</Excalidraw>,
);
await withExcalidrawDimensions({ width: 1920, height: 1080 }, test);
};
@@ -1,6 +1,6 @@
import { mutateElement } from "../../element/mutateElement";
import { getBoundTextElement } from "../../element/textElement";
import { isArrowElement, isElbowArrow } from "../../element/typeChecks";
import { isArrowElement } from "../../element/typeChecks";
import type { ExcalidrawElement } from "../../element/types";
import { degreeToRadian, radianToDegree } from "../../math";
import { angleIcon } from "../icons";
@@ -27,9 +27,8 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
scene,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const origElement = originalElements[0];
if (origElement && !isElbowArrow(origElement)) {
if (origElement) {
const latestElement = elementsMap.get(origElement.id);
if (!latestElement) {
return;
@@ -40,7 +39,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
mutateElement(latestElement, {
angle: nextAngle,
});
updateBindings(latestElement, elementsMap, elements, scene);
updateBindings(latestElement, elementsMap);
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) {
@@ -66,7 +65,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
mutateElement(latestElement, {
angle: nextAngle,
});
updateBindings(latestElement, elementsMap, elements, scene);
updateBindings(latestElement, elementsMap);
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) {
@@ -31,7 +31,6 @@ const handleDimensionChange: DragInputCallbackType<
scene,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const origElement = originalElements[0];
if (origElement) {
const keepAspectRatio =
@@ -62,8 +61,6 @@ const handleDimensionChange: DragInputCallbackType<
keepAspectRatio,
origElement,
elementsMap,
elements,
scene,
);
return;
@@ -106,8 +103,6 @@ const handleDimensionChange: DragInputCallbackType<
keepAspectRatio,
origElement,
elementsMap,
elements,
scene,
);
}
};
@@ -25,9 +25,9 @@ export type DragInputCallbackType<
originalElementsMap: ElementsMap;
shouldKeepAspectRatio: boolean;
shouldChangeByStepSize: boolean;
scene: Scene;
nextValue?: number;
property: P;
scene: Scene;
originalAppState: AppState;
}) => void;
@@ -122,9 +122,9 @@ const StatsDragInput = <
originalElementsMap: app.scene.getNonDeletedElementsMap(),
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
shouldChangeByStepSize: false,
scene,
nextValue: rounded,
property,
scene,
originalAppState: appState,
});
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
@@ -68,7 +68,6 @@ const resizeElementInGroup = (
originalElementsMap: ElementsMap,
) => {
const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
const { width: oldWidth, height: oldHeight } = latestElement;
mutateElement(latestElement, updates, false);
const boundTextElement = getBoundTextElement(
@@ -78,7 +77,7 @@ const resizeElementInGroup = (
if (boundTextElement) {
const newFontSize = boundTextElement.fontSize * scale;
updateBoundElements(latestElement, elementsMap, {
oldSize: { width: oldWidth, height: oldHeight },
newSize: { width: updates.width, height: updates.height },
});
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
@@ -150,7 +149,6 @@ const handleDimensionChange: DragInputCallbackType<
property,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const atomicUnits = getAtomicUnits(originalElements, originalAppState);
if (nextValue !== undefined) {
for (const atomicUnit of atomicUnits) {
@@ -229,8 +227,6 @@ const handleDimensionChange: DragInputCallbackType<
false,
origElement,
elementsMap,
elements,
scene,
false,
);
}
@@ -324,15 +320,7 @@ const handleDimensionChange: DragInputCallbackType<
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
resizeElement(
nextWidth,
nextHeight,
false,
origElement,
elementsMap,
elements,
scene,
);
resizeElement(nextWidth, nextHeight, false, origElement, elementsMap);
}
}
}
@@ -1,7 +1,6 @@
import type {
ElementsMap,
ExcalidrawElement,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
} from "../../element/types";
import { rotate } from "../../math";
@@ -34,7 +33,6 @@ const moveElements = (
originalElements: readonly ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap,
scene: Scene,
) => {
for (let i = 0; i < elements.length; i++) {
const origElement = originalElements[i];
@@ -62,8 +60,6 @@ const moveElements = (
newTopLeftY,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
false,
);
@@ -75,7 +71,6 @@ const moveGroupTo = (
nextY: number,
originalElements: ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
originalElementsMap: ElementsMap,
scene: Scene,
) => {
@@ -111,8 +106,6 @@ const moveGroupTo = (
topLeftY + offsetY,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
false,
);
@@ -133,7 +126,6 @@ const handlePositionChange: DragInputCallbackType<
originalAppState,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
if (nextValue !== undefined) {
for (const atomicUnit of getAtomicUnits(
@@ -158,7 +150,6 @@ const handlePositionChange: DragInputCallbackType<
newTopLeftY,
elementsInUnit.map((el) => el.original),
elementsMap,
elements,
originalElementsMap,
scene,
);
@@ -189,8 +180,6 @@ const handlePositionChange: DragInputCallbackType<
newTopLeftY,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
false,
);
@@ -217,7 +206,6 @@ const handlePositionChange: DragInputCallbackType<
originalElements,
elementsMap,
originalElementsMap,
scene,
);
scene.triggerUpdate();
@@ -26,7 +26,6 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
scene,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const origElement = originalElements[0];
const [cx, cy] = [
origElement.x + origElement.width / 2,
@@ -48,8 +47,6 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
newTopLeftY,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
);
return;
@@ -81,8 +78,6 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
newTopLeftY,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
);
};
@@ -109,9 +104,9 @@ const Position = ({
label={property === "x" ? "X" : "Y"}
elements={[element]}
dragInputCallback={handlePositionChange}
scene={scene}
value={value}
property={property}
scene={scene}
appState={appState}
/>
);
@@ -21,7 +21,6 @@ import type Scene from "../../scene/Scene";
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
import { getAtomicUnits } from "./utils";
import { STATS_PANELS } from "../../constants";
import { isElbowArrow } from "../../element/typeChecks";
interface StatsProps {
scene: Scene;
@@ -210,14 +209,12 @@ export const StatsInner = memo(
scene={scene}
appState={appState}
/>
{!isElbowArrow(singleElement) && (
<Angle
property="angle"
element={singleElement}
scene={scene}
appState={appState}
/>
)}
<Angle
property="angle"
element={singleElement}
scene={scene}
appState={appState}
/>
<FontSize
property="fontSize"
element={singleElement}
@@ -1,5 +1,4 @@
import React from "react";
import { act, fireEvent, queryByTestId } from "@testing-library/react";
import { fireEvent, queryByTestId } from "@testing-library/react";
import { Keyboard, Pointer, UI } from "../../tests/helpers/ui";
import { getStepSizedValue } from "./utils";
import {
@@ -25,6 +24,7 @@ import { getCommonBounds, isTextElement } from "../../element";
import { API } from "../../tests/helpers/api";
import { actionGroup } from "../../actions";
import { isInGroup } from "../../groups";
import React from "react";
const { h } = window;
const mouse = new Pointer("mouse");
@@ -32,6 +32,12 @@ const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
let stats: HTMLElement | null = null;
let elementStats: HTMLElement | null | undefined = null;
const editInput = (input: HTMLInputElement, value: string) => {
input.focus();
fireEvent.change(input, { target: { value } });
input.blur();
};
const getStatsProperty = (label: string) => {
const elementStats = UI.queryStats()?.querySelector("#elementStats");
@@ -59,7 +65,7 @@ const testInputProperty = (
) as HTMLInputElement;
expect(input).toBeDefined();
expect(input.value).toBe(initialValue.toString());
UI.updateInput(input, String(nextValue));
editInput(input, String(nextValue));
if (property === "angle") {
expect(element[property]).toBe(degreeToRadian(Number(nextValue)));
} else if (property === "fontSize" && isTextElement(element)) {
@@ -104,7 +110,7 @@ describe("binding with linear elements", () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
API.setElements([]);
h.elements = [];
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
@@ -142,7 +148,7 @@ describe("binding with linear elements", () => {
expect(linear.startBinding).not.toBe(null);
expect(inputX).not.toBeNull();
UI.updateInput(inputX, String("204"));
editInput(inputX, String("204"));
expect(linear.startBinding).not.toBe(null);
});
@@ -153,7 +159,7 @@ describe("binding with linear elements", () => {
) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null);
UI.updateInput(inputAngle, String("1"));
editInput(inputAngle, String("1"));
expect(linear.startBinding).not.toBe(null);
});
@@ -165,7 +171,7 @@ describe("binding with linear elements", () => {
expect(linear.startBinding).not.toBe(null);
expect(inputX).not.toBeNull();
UI.updateInput(inputX, String("254"));
editInput(inputX, String("254"));
expect(linear.startBinding).toBe(null);
});
@@ -176,7 +182,7 @@ describe("binding with linear elements", () => {
) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null);
UI.updateInput(inputAngle, String("45"));
editInput(inputAngle, String("45"));
expect(linear.startBinding).toBe(null);
});
});
@@ -191,7 +197,7 @@ describe("stats for a generic element", () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
API.setElements([]);
h.elements = [];
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
@@ -262,13 +268,13 @@ describe("stats for a generic element", () => {
) as HTMLInputElement;
expect(input).toBeDefined();
expect(input.value).toBe(rectangle.width.toString());
UI.updateInput(input, "123.123");
editInput(input, "123.123");
expect(h.elements.length).toBe(1);
expect(rectangle.id).toBe(rectangleId);
expect(input.value).toBe("123.12");
expect(rectangle.width).toBe(123.12);
UI.updateInput(input, "88.98766");
editInput(input, "88.98766");
expect(input.value).toBe("88.99");
expect(rectangle.width).toBe(88.99);
});
@@ -381,7 +387,7 @@ describe("stats for a non-generic element", () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
API.setElements([]);
h.elements = [];
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
@@ -406,10 +412,9 @@ describe("stats for a non-generic element", () => {
mouse.clickAt(20, 30);
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
const editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello!");
act(() => {
editor.blur();
});
editor.blur();
const text = h.elements[0] as ExcalidrawTextElement;
mouse.clickOn(text);
@@ -422,7 +427,7 @@ describe("stats for a non-generic element", () => {
) as HTMLInputElement;
expect(input).toBeDefined();
expect(input.value).toBe(text.fontSize.toString());
UI.updateInput(input, "36");
editInput(input, "36");
expect(text.fontSize).toBe(36);
// cannot change width or height
@@ -432,7 +437,7 @@ describe("stats for a non-generic element", () => {
expect(height).toBeUndefined();
// min font size is 4
UI.updateInput(input, "0");
editInput(input, "0");
expect(text.fontSize).not.toBe(0);
expect(text.fontSize).toBe(4);
});
@@ -444,8 +449,8 @@ describe("stats for a non-generic element", () => {
x: 150,
width: 150,
});
API.setElements([frame]);
API.setAppState({
h.elements = [frame];
h.setState({
selectedElementIds: {
[frame.id]: true,
},
@@ -466,9 +471,9 @@ describe("stats for a non-generic element", () => {
it("image element", () => {
const image = API.createElement({ type: "image", width: 200, height: 100 });
API.setElements([image]);
h.elements = [image];
mouse.clickOn(image);
API.setAppState({
h.setState({
selectedElementIds: {
[image.id]: true,
},
@@ -503,7 +508,7 @@ describe("stats for a non-generic element", () => {
mutateElement(container, {
boundElements: [{ type: "text", id: text.id }],
});
API.setElements([container, text]);
h.elements = [container, text];
API.setSelectedElements([container]);
const fontSize = getStatsProperty("F")?.querySelector(
@@ -511,7 +516,7 @@ describe("stats for a non-generic element", () => {
) as HTMLInputElement;
expect(fontSize).toBeDefined();
UI.updateInput(fontSize, "40");
editInput(fontSize, "40");
expect(text.fontSize).toBe(40);
});
@@ -528,7 +533,7 @@ describe("stats for multiple elements", () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
API.setElements([]);
h.elements = [];
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
@@ -561,7 +566,7 @@ describe("stats for multiple elements", () => {
mouse.down(-100, -100);
mouse.up(125, 145);
API.setAppState({
h.setState({
selectedElementIds: h.elements.reduce((acc, el) => {
acc[el.id] = true;
return acc;
@@ -583,12 +588,12 @@ describe("stats for multiple elements", () => {
) as HTMLInputElement;
expect(angle.value).toBe("0");
UI.updateInput(width, "250");
editInput(width, "250");
h.elements.forEach((el) => {
expect(el.width).toBe(250);
});
UI.updateInput(height, "450");
editInput(height, "450");
h.elements.forEach((el) => {
expect(el.height).toBe(450);
});
@@ -600,10 +605,9 @@ describe("stats for multiple elements", () => {
mouse.clickAt(20, 30);
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
const editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello!");
act(() => {
editor.blur();
});
editor.blur();
UI.clickTool("rectangle");
mouse.down();
@@ -615,12 +619,12 @@ describe("stats for multiple elements", () => {
width: 150,
});
API.setElements([...h.elements, frame]);
h.elements = [...h.elements, frame];
const text = h.elements.find((el) => el.type === "text");
const rectangle = h.elements.find((el) => el.type === "rectangle");
API.setAppState({
h.setState({
selectedElementIds: h.elements.reduce((acc, el) => {
acc[el.id] = true;
return acc;
@@ -653,13 +657,13 @@ describe("stats for multiple elements", () => {
expect(fontSize).toBeDefined();
// changing width does not affect text
UI.updateInput(width, "200");
editInput(width, "200");
expect(rectangle?.width).toBe(200);
expect(frame.width).toBe(200);
expect(text?.width).not.toBe(200);
UI.updateInput(angle, "40");
editInput(angle, "40");
const angleInRadian = degreeToRadian(40);
expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4);
@@ -682,7 +686,7 @@ describe("stats for multiple elements", () => {
mouse.click();
});
API.executeAction(actionGroup);
h.app.actionManager.executeAction(actionGroup);
};
createAndSelectGroup();
@@ -699,7 +703,7 @@ describe("stats for multiple elements", () => {
expect(x).toBeDefined();
expect(Number(x.value)).toBe(x1);
UI.updateInput(x, "300");
editInput(x, "300");
expect(h.elements[0].x).toBe(300);
expect(h.elements[1].x).toBe(400);
@@ -712,7 +716,7 @@ describe("stats for multiple elements", () => {
expect(y).toBeDefined();
expect(Number(y.value)).toBe(y1);
UI.updateInput(y, "200");
editInput(y, "200");
expect(h.elements[0].y).toBe(200);
expect(h.elements[1].y).toBe(300);
@@ -730,20 +734,20 @@ describe("stats for multiple elements", () => {
expect(height).toBeDefined();
expect(Number(height.value)).toBe(200);
UI.updateInput(width, "400");
editInput(width, "400");
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
let newGroupWidth = x2 - x1;
expect(newGroupWidth).toBeCloseTo(400, 4);
UI.updateInput(width, "300");
editInput(width, "300");
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
newGroupWidth = x2 - x1;
expect(newGroupWidth).toBeCloseTo(300, 4);
UI.updateInput(height, "500");
editInput(height, "500");
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
const newGroupHeight = y2 - y1;
+3 -23
View File
@@ -31,7 +31,6 @@ import {
isInGroup,
} from "../../groups";
import { rotate } from "../../math";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
import { getFontString } from "../../utils";
@@ -125,8 +124,6 @@ export const resizeElement = (
keepAspectRatio: boolean,
origElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
shouldInformMutation = true,
) => {
const latestElement = elementsMap.get(origElement.id);
@@ -149,8 +146,6 @@ export const resizeElement = (
nextHeight = Math.max(nextHeight, minHeight);
}
const { width: oldWidth, height: oldHeight } = latestElement;
mutateElement(
latestElement,
{
@@ -169,7 +164,7 @@ export const resizeElement = (
},
shouldInformMutation,
);
updateBindings(latestElement, elementsMap, elements, scene, {
updateBindings(latestElement, elementsMap, {
newSize: {
width: nextWidth,
height: nextHeight,
@@ -198,10 +193,6 @@ export const resizeElement = (
}
}
updateBoundElements(latestElement, elementsMap, {
oldSize: { width: oldWidth, height: oldHeight },
});
if (boundTextElement && boundTextFont) {
mutateElement(boundTextElement, {
fontSize: boundTextFont.fontSize,
@@ -215,8 +206,6 @@ export const moveElement = (
newTopLeftY: number,
originalElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
originalElementsMap: ElementsMap,
shouldInformMutation = true,
) => {
@@ -255,7 +244,7 @@ export const moveElement = (
},
shouldInformMutation,
);
updateBindings(latestElement, elementsMap, elements, scene);
updateBindings(latestElement, elementsMap);
const boundTextElement = getBoundTextElement(
originalElement,
@@ -299,22 +288,13 @@ export const getAtomicUnits = (
export const updateBindings = (
latestElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
},
) => {
if (isLinearElement(latestElement)) {
bindOrUnbindLinearElements(
[latestElement],
elementsMap,
elements,
scene,
true,
[],
);
bindOrUnbindLinearElements([latestElement], elementsMap, true, []);
} else {
updateBoundElements(latestElement, elementsMap, options);
}
@@ -1,13 +1,7 @@
import React from "react";
import { Excalidraw } from "../../index";
import { KEYS } from "../../keys";
import { Keyboard } from "../../tests/helpers/ui";
import {
render,
waitFor,
getByTestId,
fireEvent,
} from "../../tests/test-utils";
import { render, waitFor, getByTestId } from "../../tests/test-utils";
describe("Test <DropdownMenu/>", () => {
it("should", async () => {
@@ -15,7 +9,7 @@ describe("Test <DropdownMenu/>", () => {
expect(window.h.state.openMenu).toBe(null);
fireEvent.click(getByTestId(container, "main-menu-trigger"));
getByTestId(container, "main-menu-trigger").click();
expect(window.h.state.openMenu).toBe("canvas");
await waitFor(() => {
@@ -1,4 +1,3 @@
import React from "react";
import { render, queryAllByTestId } from "../../tests/test-utils";
import { Excalidraw, MainMenu } from "../../index";
@@ -211,7 +211,7 @@ export const Hyperlink = ({
const { x, y } = getCoordsForPopover(element, appState, elementsMap);
if (
appState.contextMenu ||
appState.selectedElementsAreBeingDragged ||
appState.draggingElement ||
appState.resizingElement ||
appState.isRotating ||
appState.openMenu ||
-29
View File
@@ -2095,35 +2095,6 @@ export const lineEditorIcon = createIcon(
tablerIconProps,
);
// arrow-up-right (modified)
export const sharpArrowIcon = createIcon(
<g>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M6 18l12 -12" />
<path d="M18 10v-4h-4" />
</g>,
tablerIconProps,
);
// arrow-guide (modified)
export const elbowArrowIcon = createIcon(
<g>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4,19L10,19C11.097,19 12,18.097 12,17L12,9C12,7.903 12.903,7 14,7L21,7" />
<path d="M18 4l3 3l-3 3" />
</g>,
tablerIconProps,
);
// arrow-ramp-right-2 (heavily modified)
export const roundArrowIcon = createIcon(
<g>
<path d="M16,12L20,9L16,6" />
<path d="M6 20c0 -6.075 4.925 -11 11 -11h3" />
</g>,
tablerIconProps,
);
export const collapseDownIcon = createIcon(
<g>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
+1 -7
View File
@@ -1,5 +1,5 @@
import cssVariables from "./css/variables.module.scss";
import type { AppProps, AppState } from "./types";
import type { AppProps } from "./types";
import type { ExcalidrawElement, FontFamilyValues } from "./element/types";
import { COLOR_PALETTE } from "./colors";
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
@@ -421,9 +421,3 @@ export const DEFAULT_FILENAME = "Untitled";
export const STATS_PANELS = { generalStats: 1, elementProperties: 2 } as const;
export const MIN_WIDTH_OR_HEIGHT = 1;
export const ARROW_TYPE: { [T in AppState["currentItemArrowType"]]: T } = {
sharp: "sharp",
round: "round",
elbow: "elbow",
};
@@ -84,11 +84,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": {
"elementId": "ellipse-1",
"fixedPoint": null,
"focus": -0.008153707962747813,
"gap": 1,
},
@@ -119,7 +117,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"startArrowhead": null,
"startBinding": {
"elementId": "id47",
"fixedPoint": null,
"focus": -0.08139534883720931,
"gap": 1,
},
@@ -142,11 +139,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": {
"elementId": "ellipse-1",
"fixedPoint": null,
"focus": 0.10666666666666667,
"gap": 3.834326468444573,
},
@@ -177,7 +172,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"startArrowhead": null,
"startBinding": {
"elementId": "diamond-1",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@@ -334,11 +328,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
},
],
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": {
"elementId": "text-2",
"fixedPoint": null,
"focus": 0,
"gap": 205,
},
@@ -369,7 +361,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"startArrowhead": null,
"startBinding": {
"elementId": "text-1",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@@ -438,11 +429,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
},
],
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id40",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@@ -473,7 +462,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"startArrowhead": null,
"startBinding": {
"elementId": "id39",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@@ -616,11 +604,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
},
],
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id44",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@@ -651,7 +637,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"startArrowhead": null,
"startBinding": {
"elementId": "id43",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@@ -839,7 +824,6 @@ exports[`Test Transform > should transform linear elements 1`] = `
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
@@ -887,7 +871,6 @@ exports[`Test Transform > should transform linear elements 2`] = `
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"elbowed": false,
"endArrowhead": "triangle",
"endBinding": null,
"fillStyle": "solid",
@@ -1480,11 +1463,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
},
],
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": {
"elementId": "Alice",
"fixedPoint": null,
"focus": 0,
"gap": 5.299874999999986,
},
@@ -1517,7 +1498,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
"startArrowhead": null,
"startBinding": {
"elementId": "Bob",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@@ -1545,11 +1525,9 @@ exports[`Test Transform > should transform the elements correctly when linear el
},
],
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": {
"elementId": "B",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@@ -1578,7 +1556,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
"startArrowhead": null,
"startBinding": {
"elementId": "Bob",
"fixedPoint": null,
"focus": 0,
"gap": 1,
},
@@ -1860,7 +1837,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
},
],
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
@@ -1913,7 +1889,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
},
],
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
@@ -1966,7 +1941,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
},
],
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
@@ -2019,7 +1993,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
},
],
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
+2 -41
View File
@@ -1,11 +1,5 @@
import throttle from "lodash.throttle";
import { ENV } from "../constants";
import type { OrderedExcalidrawElement } from "../element/types";
import {
orderByFractionalIndex,
syncInvalidIndices,
validateFractionalIndices,
} from "../fractionalIndex";
import { orderByFractionalIndex, syncInvalidIndices } from "../fractionalIndex";
import type { AppState } from "../types";
import type { MakeBrand } from "../utility-types";
import { arrayToMap } from "../utils";
@@ -26,7 +20,7 @@ const shouldDiscardRemoteElement = (
// local element is being edited
(local.id === localAppState.editingElement?.id ||
local.id === localAppState.resizingElement?.id ||
local.id === localAppState.newElement?.id || // TODO: Is this still valid? As newElement is selection element, which is never part of the elements array
local.id === localAppState.draggingElement?.id || // TODO: Is this still valid? As draggingElement is selection element, which is never part of the elements array
// local element is newer
local.version > remote.version ||
// resolve conflicting edits deterministically by taking the one with
@@ -39,37 +33,6 @@ const shouldDiscardRemoteElement = (
return false;
};
const validateIndicesThrottled = throttle(
(
orderedElements: readonly OrderedExcalidrawElement[],
localElements: readonly OrderedExcalidrawElement[],
remoteElements: readonly RemoteExcalidrawElement[],
) => {
if (
import.meta.env.DEV ||
import.meta.env.MODE === ENV.TEST ||
window?.DEBUG_FRACTIONAL_INDICES
) {
// create new instances due to the mutation
const elements = syncInvalidIndices(
orderedElements.map((x) => ({ ...x })),
);
validateFractionalIndices(elements, {
// throw in dev & test only, to remain functional on `DEBUG_FRACTIONAL_INDICES`
shouldThrow: import.meta.env.DEV || import.meta.env.MODE === ENV.TEST,
includeBoundTextValidation: true,
reconciliationContext: {
localElements,
remoteElements,
},
});
}
},
1000 * 60,
{ leading: true, trailing: false },
);
export const reconcileElements = (
localElements: readonly OrderedExcalidrawElement[],
remoteElements: readonly RemoteExcalidrawElement[],
@@ -109,8 +72,6 @@ export const reconcileElements = (
const orderedElements = orderByFractionalIndex(reconciledElements);
validateIndicesThrottled(orderedElements, localElements, remoteElements);
// de-duplicate indices
syncInvalidIndices(orderedElements);
+9 -47
View File
@@ -1,5 +1,4 @@
import type {
ExcalidrawArrowElement,
ExcalidrawElement,
ExcalidrawElementType,
ExcalidrawLinearElement,
@@ -25,7 +24,6 @@ import {
} from "../element";
import {
isArrowElement,
isElbowArrow,
isLinearElement,
isTextElement,
isUsingAdaptiveRadius,
@@ -94,21 +92,11 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
return DEFAULT_FONT_FAMILY;
};
const repairBinding = (
element: ExcalidrawLinearElement,
binding: PointBinding | null,
): PointBinding | null => {
const repairBinding = (binding: PointBinding | null) => {
if (!binding) {
return null;
}
return {
...binding,
focus: binding.focus || 0,
fixedPoint: isElbowArrow(element)
? binding.fixedPoint ?? ([0, 0] as [number, number])
: null,
};
return { ...binding, focus: binding.focus || 0 };
};
const restoreElementWithProperties = <
@@ -254,7 +242,11 @@ const restoreElement = (
// @ts-ignore LEGACY type
// eslint-disable-next-line no-fallthrough
case "draw":
const { startArrowhead = null, endArrowhead = null } = element;
case "arrow": {
const {
startArrowhead = null,
endArrowhead = element.type === "arrow" ? "arrow" : null,
} = element;
let x = element.x;
let y = element.y;
let points = // migrate old arrow model to new one
@@ -274,8 +266,8 @@ const restoreElement = (
(element.type as ExcalidrawElementType | "draw") === "draw"
? "line"
: element.type,
startBinding: repairBinding(element, element.startBinding),
endBinding: repairBinding(element, element.endBinding),
startBinding: repairBinding(element.startBinding),
endBinding: repairBinding(element.endBinding),
lastCommittedPoint: null,
startArrowhead,
endArrowhead,
@@ -284,36 +276,6 @@ const restoreElement = (
y,
...getSizeFromPoints(points),
});
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
!Array.isArray(element.points) || element.points.length < 2
? [
[0, 0],
[element.width, element.height],
]
: element.points;
if (points[0][0] !== 0 || points[0][1] !== 0) {
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
}
// TODO: Separate arrow from linear element
return restoreElementWithProperties(element as ExcalidrawArrowElement, {
type: element.type,
startBinding: repairBinding(element, element.startBinding),
endBinding: repairBinding(element, element.endBinding),
lastCommittedPoint: null,
startArrowhead,
endArrowhead,
points,
x,
y,
elbowed: (element as ExcalidrawArrowElement).elbowed,
...getSizeFromPoints(points),
});
}
// generic elements
@@ -771,7 +771,6 @@ describe("Test Transform", () => {
const [arrow, rect] = excalidrawElements;
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
elementId: "rect-1",
fixedPoint: null,
focus: 0,
gap: 205,
});
+3 -6
View File
@@ -13,7 +13,6 @@ import {
import { bindLinearElement } from "../element/binding";
import type { ElementConstructorOpts } from "../element/newElement";
import {
newArrowElement,
newFrameElement,
newImageElement,
newMagicFrameElement,
@@ -52,7 +51,6 @@ import { getSizeFromPoints } from "../points";
import { randomId } from "../random";
import { syncInvalidIndices } from "../fractionalIndex";
import { getLineHeight } from "../fonts";
import { isArrowElement } from "../element/typeChecks";
export type ValidLinearElement = {
type: "arrow" | "line";
@@ -547,7 +545,7 @@ export const convertToExcalidrawElements = (
case "arrow": {
const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width;
const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height;
excalidrawElement = newArrowElement({
excalidrawElement = newLinearElement({
width,
height,
endArrowhead: "arrow",
@@ -556,7 +554,6 @@ export const convertToExcalidrawElements = (
[width, height],
],
...element,
type: "arrow",
});
Object.assign(
@@ -658,7 +655,7 @@ export const convertToExcalidrawElements = (
elementStore.add(container);
elementStore.add(text);
if (isArrowElement(container)) {
if (container.type === "arrow") {
const originalStart =
element.type === "arrow" ? element?.start : undefined;
const originalEnd =
@@ -677,7 +674,7 @@ export const convertToExcalidrawElements = (
}
const { linearElement, startBoundElement, endBoundElement } =
bindLinearElementToElement(
container,
container as ExcalidrawArrowElement,
originalStart,
originalEnd,
elementStore,
@@ -36,7 +36,7 @@ export const ElementCanvasButtons = ({
if (
appState.contextMenu ||
appState.newElement ||
appState.draggingElement ||
appState.resizingElement ||
appState.isRotating ||
appState.openMenu ||
File diff suppressed because it is too large Load Diff
+2 -9
View File
@@ -18,7 +18,6 @@ import {
isImageElement,
isTextElement,
} from "./typeChecks";
import { getBoundTextShape } from "../shapes";
export const shouldTestInside = (element: ExcalidrawElement) => {
if (element.type === "arrow") {
@@ -98,12 +97,6 @@ export const hitElementBoundingBoxOnly = (
) => {
return (
!hitElementItself(hitArgs) &&
// bound text is considered part of the element (even if it's outside the bounding box)
!hitElementBoundText(
hitArgs.x,
hitArgs.y,
getBoundTextShape(hitArgs.element, elementsMap),
) &&
hitElementBoundingBox(hitArgs.x, hitArgs.y, hitArgs.element, elementsMap)
);
};
@@ -112,6 +105,6 @@ export const hitElementBoundText = (
x: number,
y: number,
textShape: GeometricShape | null,
): boolean => {
return !!textShape && isPointInShape([x, y], textShape);
) => {
return textShape && isPointInShape([x, y], textShape);
};
+13 -29
View File
@@ -10,7 +10,6 @@ import { getGridPoint } from "../math";
import type Scene from "../scene/Scene";
import {
isArrowElement,
isElbowArrow,
isFrameLikeElement,
isTextElement,
} from "./typeChecks";
@@ -19,8 +18,9 @@ import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
export const dragSelectedElements = (
pointerDownState: PointerDownState,
_selectedElements: NonDeletedExcalidrawElement[],
selectedElements: NonDeletedExcalidrawElement[],
offset: { x: number; y: number },
appState: AppState,
scene: Scene,
snapOffset: {
x: number;
@@ -28,25 +28,6 @@ export const dragSelectedElements = (
},
gridSize: AppState["gridSize"],
) => {
if (
_selectedElements.length === 1 &&
isArrowElement(_selectedElements[0]) &&
isElbowArrow(_selectedElements[0]) &&
(_selectedElements[0].startBinding || _selectedElements[0].endBinding)
) {
return;
}
const selectedElements = _selectedElements.filter(
(el) =>
!(
isArrowElement(el) &&
isElbowArrow(el) &&
el.startBinding &&
el.endBinding
),
);
// we do not want a frame and its elements to be selected at the same time
// but when it happens (due to some bug), we want to avoid updating element
// in the frame twice, hence the use of set
@@ -155,7 +136,7 @@ export const getDragOffsetXY = (
};
export const dragNewElement = (
newElement: NonDeletedExcalidrawElement,
draggingElement: NonDeletedExcalidrawElement,
elementType: AppState["activeTool"]["type"],
originX: number,
originY: number,
@@ -174,7 +155,7 @@ export const dragNewElement = (
y: number;
} | null = null,
) => {
if (shouldMaintainAspectRatio && newElement.type !== "selection") {
if (shouldMaintainAspectRatio && draggingElement.type !== "selection") {
if (widthAspectRatio) {
height = width / widthAspectRatio;
} else {
@@ -213,14 +194,17 @@ export const dragNewElement = (
let textAutoResize = null;
if (isTextElement(newElement)) {
height = newElement.height;
// NOTE this should apply only to creating text elements, not existing
// (once we rewrite appState.draggingElement to actually mean dragging
// elements)
if (isTextElement(draggingElement)) {
height = draggingElement.height;
const minWidth = getMinTextElementWidth(
getFontString({
fontSize: newElement.fontSize,
fontFamily: newElement.fontFamily,
fontSize: draggingElement.fontSize,
fontFamily: draggingElement.fontFamily,
}),
newElement.lineHeight,
draggingElement.lineHeight,
);
width = Math.max(width, minWidth);
@@ -237,7 +221,7 @@ export const dragNewElement = (
}
if (width !== 0 && height !== 0) {
mutateElement(newElement, {
mutateElement(draggingElement, {
x: newX + (originOffset?.x ?? 0),
y: newY + (originOffset?.y ?? 0),
width,
-146
View File
@@ -1,146 +0,0 @@
import { lineAngle } from "../../utils/geometry/geometry";
import type { Point, Vector } from "../../utils/geometry/shape";
import {
getCenterForBounds,
PointInTriangle,
rotatePoint,
scalePointFromOrigin,
} from "../math";
import type { Bounds } from "./bounds";
import type { ExcalidrawBindableElement } from "./types";
export const HEADING_RIGHT = [1, 0] as Heading;
export const HEADING_DOWN = [0, 1] as Heading;
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]);
if (angle >= 315 || angle < 45) {
return HEADING_UP;
} else if (angle >= 45 && angle < 135) {
return HEADING_RIGHT;
} else if (angle >= 135 && angle < 225) {
return HEADING_DOWN;
}
return HEADING_LEFT;
};
export const vectorToHeading = (vec: Vector): Heading => {
const [x, y] = vec;
const absX = Math.abs(x);
const absY = Math.abs(y);
if (x > absY) {
return HEADING_RIGHT;
} else if (x <= -absY) {
return HEADING_LEFT;
} else if (y > absX) {
return HEADING_DOWN;
}
return HEADING_UP;
};
export const compareHeading = (a: Heading, b: Heading) =>
a[0] === b[0] && a[1] === b[1];
// 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 = (
element: Readonly<ExcalidrawBindableElement>,
aabb: Readonly<Bounds>,
point: Readonly<Point>,
): Heading => {
const SEARCH_CONE_MULTIPLIER = 2;
const midPoint = getCenterForBounds(aabb);
if (element.type === "diamond") {
if (point[0] < element.x) {
return HEADING_LEFT;
} else if (point[1] < element.y) {
return HEADING_UP;
} else if (point[0] > element.x + element.width) {
return HEADING_RIGHT;
} else if (point[1] > element.y + element.height) {
return HEADING_DOWN;
}
const top = rotatePoint(
scalePointFromOrigin(
[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],
midPoint,
SEARCH_CONE_MULTIPLIER,
),
midPoint,
element.angle,
);
const bottom = rotatePoint(
scalePointFromOrigin(
[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],
midPoint,
SEARCH_CONE_MULTIPLIER,
),
midPoint,
element.angle,
);
if (PointInTriangle(point, top, right, midPoint)) {
return headingForDiamond(top, right);
} else if (PointInTriangle(point, right, bottom, midPoint)) {
return headingForDiamond(right, bottom);
} else if (PointInTriangle(point, bottom, left, midPoint)) {
return headingForDiamond(bottom, left);
}
return headingForDiamond(left, top);
}
const topLeft = scalePointFromOrigin(
[aabb[0], aabb[1]],
midPoint,
SEARCH_CONE_MULTIPLIER,
);
const topRight = scalePointFromOrigin(
[aabb[2], aabb[1]],
midPoint,
SEARCH_CONE_MULTIPLIER,
);
const bottomLeft = scalePointFromOrigin(
[aabb[0], aabb[3]],
midPoint,
SEARCH_CONE_MULTIPLIER,
);
const bottomRight = scalePointFromOrigin(
[aabb[2], aabb[3]],
midPoint,
SEARCH_CONE_MULTIPLIER,
);
return PointInTriangle(point, topLeft, topRight, midPoint)
? HEADING_UP
: PointInTriangle(point, topRight, bottomRight, midPoint)
? HEADING_RIGHT
: PointInTriangle(point, bottomRight, bottomLeft, midPoint)
? HEADING_DOWN
: HEADING_LEFT;
};
-1
View File
@@ -11,7 +11,6 @@ export {
newTextElement,
refreshTextDimensions,
newLinearElement,
newArrowElement,
newImageElement,
duplicateElement,
} from "./newElement";
@@ -7,9 +7,6 @@ import type {
ExcalidrawTextElementWithContainer,
ElementsMap,
NonDeletedSceneElementsMap,
OrderedExcalidrawElement,
FixedPointBinding,
SceneElementsMap,
} from "./types";
import {
distance2d,
@@ -36,6 +33,7 @@ import type {
AppState,
PointerCoords,
InteractiveCanvasAppState,
AppClassProperties,
} from "../types";
import { mutateElement } from "./mutateElement";
@@ -45,19 +43,13 @@ import {
isBindingEnabled,
} from "./binding";
import { tupleToCoors } from "../utils";
import {
isBindingElement,
isElbowArrow,
isFixedPointBinding,
} from "./typeChecks";
import { isBindingElement } from "./typeChecks";
import { KEYS, shouldRotateWithDiscreteAngle } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { DRAGGING_THRESHOLD } from "../constants";
import type { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache";
import type { Store } from "../store";
import { mutateElbowArrow } from "./routing";
import type Scene from "../scene/Scene";
const editorMidPointsCache: {
version: number | null;
@@ -75,7 +67,6 @@ export class LinearElementEditor {
prevSelectedPointsIndices: readonly number[] | null;
/** index */
lastClickedPoint: number;
lastClickedIsEndPoint: boolean;
origin: Readonly<{ x: number; y: number }> | null;
segmentMidpoint: {
value: Point | null;
@@ -100,9 +91,7 @@ export class LinearElementEditor {
this.elementId = element.id as string & {
_brand: "excalidrawLinearElementId";
};
if (!arePointsEqual(element.points[0], [0, 0])) {
console.error("Linear element is not normalized", Error().stack);
}
LinearElementEditor.normalizePoints(element);
this.selectedPointsIndices = null;
this.lastUncommittedPoint = null;
@@ -113,7 +102,6 @@ export class LinearElementEditor {
this.pointerDownState = {
prevSelectedPointsIndices: null,
lastClickedPoint: -1,
lastClickedIsEndPoint: false,
origin: null,
segmentMidpoint: {
@@ -152,7 +140,10 @@ export class LinearElementEditor {
setState: React.Component<any, AppState>["setState"],
elementsMap: NonDeletedSceneElementsMap,
) {
if (!appState.editingLinearElement || !appState.selectionElement) {
if (
!appState.editingLinearElement ||
appState.draggingElement?.type !== "selection"
) {
return false;
}
const { editingLinearElement } = appState;
@@ -164,15 +155,15 @@ export class LinearElementEditor {
}
const [selectionX1, selectionY1, selectionX2, selectionY2] =
getElementAbsoluteCoords(appState.selectionElement, elementsMap);
getElementAbsoluteCoords(appState.draggingElement, elementsMap);
const pointsSceneCoords = LinearElementEditor.getPointsGlobalCoordinates(
element,
elementsMap,
);
const nextSelectedPoints = pointsSceneCoords
.reduce((acc: number[], point, index) => {
const nextSelectedPoints = pointsSceneCoords.reduce(
(acc: number[], point, index) => {
if (
(point[0] >= selectionX1 &&
point[0] <= selectionX2 &&
@@ -184,17 +175,9 @@ export class LinearElementEditor {
}
return acc;
}, [])
.filter((index) => {
if (
isElbowArrow(element) &&
index !== 0 &&
index !== element.points.length - 1
) {
return false;
}
return true;
});
},
[],
);
setState({
editingLinearElement: {
@@ -217,52 +200,21 @@ export class LinearElementEditor {
pointSceneCoords: { x: number; y: number }[],
) => void,
linearElementEditor: LinearElementEditor,
scene: Scene,
elementsMap: NonDeletedSceneElementsMap,
): boolean {
if (!linearElementEditor) {
return false;
}
const { elementId } = linearElementEditor;
const elementsMap = scene.getNonDeletedElementsMap();
const { selectedPointsIndices, elementId } = linearElementEditor;
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) {
return false;
}
if (
isElbowArrow(element) &&
!linearElementEditor.pointerDownState.lastClickedIsEndPoint &&
linearElementEditor.pointerDownState.lastClickedPoint !== 0
) {
return false;
}
const selectedPointsIndices = isElbowArrow(element)
? linearElementEditor.selectedPointsIndices
?.reduce(
(startEnd, index) =>
(index === 0
? [0, startEnd[1]]
: [startEnd[0], element.points.length - 1]) as [
boolean | number,
boolean | number,
],
[false, false] as [number | boolean, number | boolean],
)
.filter(
(idx: number | boolean): idx is number => typeof idx === "number",
)
: linearElementEditor.selectedPointsIndices;
const lastClickedPoint = isElbowArrow(element)
? linearElementEditor.pointerDownState.lastClickedPoint > 0
? element.points.length - 1
: 0
: linearElementEditor.pointerDownState.lastClickedPoint;
// point that's being dragged (out of all selected points)
const draggingPoint = element.points[lastClickedPoint] as
| [number, number]
| undefined;
const draggingPoint = element.points[
linearElementEditor.pointerDownState.lastClickedPoint
] as [number, number] | undefined;
if (selectedPointsIndices && draggingPoint) {
if (
@@ -282,17 +234,15 @@ export class LinearElementEditor {
event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize,
);
LinearElementEditor.movePoints(
element,
[
{
index: selectedIndex,
point: [width + referencePoint[0], height + referencePoint[1]],
isDragging: selectedIndex === lastClickedPoint,
},
],
elementsMap,
);
LinearElementEditor.movePoints(element, [
{
index: selectedIndex,
point: [width + referencePoint[0], height + referencePoint[1]],
isDragging:
selectedIndex ===
linearElementEditor.pointerDownState.lastClickedPoint,
},
]);
} else {
const newDraggingPointPosition = LinearElementEditor.createPointAt(
element,
@@ -309,7 +259,8 @@ export class LinearElementEditor {
element,
selectedPointsIndices.map((pointIndex) => {
const newPointPosition =
pointIndex === lastClickedPoint
pointIndex ===
linearElementEditor.pointerDownState.lastClickedPoint
? LinearElementEditor.createPointAt(
element,
elementsMap,
@@ -324,10 +275,11 @@ export class LinearElementEditor {
return {
index: pointIndex,
point: newPointPosition,
isDragging: pointIndex === lastClickedPoint,
isDragging:
pointIndex ===
linearElementEditor.pointerDownState.lastClickedPoint,
};
}),
elementsMap,
);
}
@@ -382,10 +334,9 @@ export class LinearElementEditor {
event: PointerEvent,
editingLinearElement: LinearElementEditor,
appState: AppState,
scene: Scene,
app: AppClassProperties,
): LinearElementEditor {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const elementsMap = app.scene.getNonDeletedElementsMap();
const { elementId, selectedPointsIndices, isDragging, pointerDownState } =
editingLinearElement;
@@ -410,19 +361,15 @@ export class LinearElementEditor {
selectedPoint === element.points.length - 1
) {
if (isPathALoop(element.points, appState.zoom.value)) {
LinearElementEditor.movePoints(
element,
[
{
index: selectedPoint,
point:
selectedPoint === 0
? element.points[element.points.length - 1]
: element.points[0],
},
],
elementsMap,
);
LinearElementEditor.movePoints(element, [
{
index: selectedPoint,
point:
selectedPoint === 0
? element.points[element.points.length - 1]
: element.points[0],
},
]);
}
const bindingElement = isBindingEnabled(appState)
@@ -434,7 +381,6 @@ export class LinearElementEditor {
elementsMap,
),
),
elements,
elementsMap,
)
: null;
@@ -699,14 +645,13 @@ export class LinearElementEditor {
store: Store,
scenePointer: { x: number; y: number },
linearElementEditor: LinearElementEditor,
scene: Scene,
app: AppClassProperties,
): {
didAddPoint: boolean;
hitElement: NonDeleted<ExcalidrawElement> | null;
linearElementEditor: LinearElementEditor | null;
} {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const elementsMap = app.scene.getNonDeletedElementsMap();
const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
didAddPoint: false,
@@ -740,10 +685,7 @@ export class LinearElementEditor {
);
}
if (event.altKey && appState.editingLinearElement) {
if (
linearElementEditor.lastUncommittedPoint == null ||
!isElbowArrow(element)
) {
if (linearElementEditor.lastUncommittedPoint == null) {
mutateElement(element, {
points: [
...element.points,
@@ -764,7 +706,6 @@ export class LinearElementEditor {
pointerDownState: {
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
lastClickedPoint: -1,
lastClickedIsEndPoint: false,
origin: { x: scenePointer.x, y: scenePointer.y },
segmentMidpoint: {
value: segmentMidpoint,
@@ -776,7 +717,6 @@ export class LinearElementEditor {
lastUncommittedPoint: null,
endBindingElement: getHoveredElementForBinding(
scenePointer,
elements,
elementsMap,
),
};
@@ -809,7 +749,6 @@ export class LinearElementEditor {
startBindingElement,
endBindingElement,
elementsMap,
scene,
);
}
}
@@ -842,7 +781,6 @@ export class LinearElementEditor {
pointerDownState: {
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
lastClickedPoint: clickedPointIndex,
lastClickedIsEndPoint: clickedPointIndex === element.points.length - 1,
origin: { x: scenePointer.x, y: scenePointer.y },
segmentMidpoint: {
value: segmentMidpoint,
@@ -877,7 +815,7 @@ export class LinearElementEditor {
scenePointerX: number,
scenePointerY: number,
appState: AppState,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
elementsMap: ElementsMap,
): LinearElementEditor | null {
if (!appState.editingLinearElement) {
return null;
@@ -893,11 +831,7 @@ export class LinearElementEditor {
if (!event.altKey) {
if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.deletePoints(
element,
[points.length - 1],
elementsMap,
);
LinearElementEditor.deletePoints(element, [points.length - 1]);
}
return {
...appState.editingLinearElement,
@@ -928,29 +862,19 @@ export class LinearElementEditor {
elementsMap,
scenePointerX - appState.editingLinearElement.pointerOffset.x,
scenePointerY - appState.editingLinearElement.pointerOffset.y,
event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
? null
: appState.gridSize,
event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize,
);
}
if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.movePoints(
element,
[
{
index: element.points.length - 1,
point: newPoint,
},
],
elementsMap,
);
LinearElementEditor.movePoints(element, [
{
index: element.points.length - 1,
point: newPoint,
},
]);
} else {
LinearElementEditor.addPoints(
element,
[{ point: newPoint }],
elementsMap,
);
LinearElementEditor.addPoints(element, appState, [{ point: newPoint }]);
}
return {
...appState.editingLinearElement,
@@ -1014,11 +938,6 @@ export class LinearElementEditor {
absoluteCoords: Point,
elementsMap: ElementsMap,
): Point {
if (isElbowArrow(element)) {
// No rotation for elbow arrows
return [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;
@@ -1094,7 +1013,7 @@ export class LinearElementEditor {
const offsetY = points[0][1];
return {
points: points.map((point) => {
points: points.map((point, _idx) => {
return [point[0] - offsetX, point[1] - offsetY] as const;
}),
x: element.x + offsetX,
@@ -1109,15 +1028,13 @@ export class LinearElementEditor {
mutateElement(element, LinearElementEditor.getNormalizedPoints(element));
}
static duplicateSelectedPoints(
appState: AppState,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
) {
static duplicateSelectedPoints(appState: AppState, elementsMap: ElementsMap) {
if (!appState.editingLinearElement) {
return false;
}
const { selectedPointsIndices, elementId } = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element || selectedPointsIndices === null) {
@@ -1160,16 +1077,12 @@ export class LinearElementEditor {
// potentially expanding the bounding box
if (pointAddedToEnd) {
const lastPoint = element.points[element.points.length - 1];
LinearElementEditor.movePoints(
element,
[
{
index: element.points.length - 1,
point: [lastPoint[0] + 30, lastPoint[1] + 30],
},
],
elementsMap,
);
LinearElementEditor.movePoints(element, [
{
index: element.points.length - 1,
point: [lastPoint[0] + 30, lastPoint[1] + 30],
},
]);
}
return {
@@ -1186,7 +1099,6 @@ export class LinearElementEditor {
static deletePoints(
element: NonDeleted<ExcalidrawLinearElement>,
pointIndices: readonly number[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
) {
let offsetX = 0;
let offsetY = 0;
@@ -1214,45 +1126,25 @@ export class LinearElementEditor {
return acc;
}, []);
LinearElementEditor._updatePoints(
element,
nextPoints,
offsetX,
offsetY,
elementsMap,
);
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
}
static addPoints(
element: NonDeleted<ExcalidrawLinearElement>,
appState: AppState,
targetPoints: { point: Point }[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
) {
const offsetX = 0;
const offsetY = 0;
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
LinearElementEditor._updatePoints(
element,
nextPoints,
offsetX,
offsetY,
elementsMap,
);
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
}
static movePoints(
element: NonDeleted<ExcalidrawLinearElement>,
targetPoints: { index: number; point: Point; isDragging?: boolean }[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
otherUpdates?: {
startBinding?: PointBinding | null;
endBinding?: PointBinding | null;
},
options?: {
changedElements?: Map<string, OrderedExcalidrawElement>;
isDragging?: boolean;
},
otherUpdates?: { startBinding?: PointBinding; endBinding?: PointBinding },
) {
const { points } = element;
@@ -1300,16 +1192,7 @@ export class LinearElementEditor {
nextPoints,
offsetX,
offsetY,
elementsMap,
otherUpdates,
{
isDragging: targetPoints.reduce(
(dragging, targetPoint): boolean =>
dragging || targetPoint.isDragging === true,
false,
),
changedElements: options?.changedElements,
},
);
}
@@ -1324,11 +1207,6 @@ export class LinearElementEditor {
elementsMap,
);
// Elbow arrows don't allow midpoints
if (element && isElbowArrow(element)) {
return false;
}
if (!element) {
return false;
}
@@ -1388,7 +1266,7 @@ export class LinearElementEditor {
elementsMap,
pointerCoords.x,
pointerCoords.y,
snapToGrid && !isElbowArrow(element) ? appState.gridSize : null,
snapToGrid ? appState.gridSize : null,
);
const points = [
...element.points.slice(0, segmentMidpoint.index!),
@@ -1417,61 +1295,23 @@ export class LinearElementEditor {
nextPoints: readonly Point[],
offsetX: number,
offsetY: number,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
otherUpdates?: {
startBinding?: PointBinding | null;
endBinding?: PointBinding | null;
},
options?: {
changedElements?: Map<string, OrderedExcalidrawElement>;
isDragging?: boolean;
},
otherUpdates?: { startBinding?: PointBinding; endBinding?: PointBinding },
) {
if (isElbowArrow(element)) {
const bindings: {
startBinding?: FixedPointBinding | null;
endBinding?: FixedPointBinding | null;
} = {};
if (otherUpdates?.startBinding !== undefined) {
bindings.startBinding =
otherUpdates.startBinding !== null &&
isFixedPointBinding(otherUpdates.startBinding)
? otherUpdates.startBinding
: null;
}
if (otherUpdates?.endBinding !== undefined) {
bindings.endBinding =
otherUpdates.endBinding !== null &&
isFixedPointBinding(otherUpdates.endBinding)
? otherUpdates.endBinding
: null;
}
mutateElbowArrow(
element,
elementsMap,
nextPoints,
[offsetX, offsetY],
bindings,
options,
);
} else {
const nextCoords = getElementPointsCoords(element, nextPoints);
const prevCoords = getElementPointsCoords(element, element.points);
const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
const nextCenterY = (nextCoords[1] + nextCoords[3]) / 2;
const prevCenterX = (prevCoords[0] + prevCoords[2]) / 2;
const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
const dX = prevCenterX - nextCenterX;
const dY = prevCenterY - nextCenterY;
const rotated = rotate(offsetX, offsetY, dX, dY, element.angle);
mutateElement(element, {
...otherUpdates,
points: nextPoints,
x: element.x + rotated[0],
y: element.y + rotated[1],
});
}
const nextCoords = getElementPointsCoords(element, nextPoints);
const prevCoords = getElementPointsCoords(element, element.points);
const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
const nextCenterY = (nextCoords[1] + nextCoords[3]) / 2;
const prevCenterX = (prevCoords[0] + prevCoords[2]) / 2;
const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
const dX = prevCenterX - nextCenterX;
const dY = prevCenterY - nextCenterY;
const rotated = rotate(offsetX, offsetY, dX, dY, element.angle);
mutateElement(element, {
...otherUpdates,
points: nextPoints,
x: element.x + rotated[0],
y: element.y + rotated[1],
});
}
private static _getShiftLockedDelta(
@@ -1487,13 +1327,6 @@ export class LinearElementEditor {
elementsMap,
);
if (isElbowArrow(element)) {
return [
scenePointer[0] - referencePointCoords[0],
scenePointer[1] - referencePointCoords[1],
];
}
const [gridX, gridY] = getGridPoint(
scenePointer[0],
scenePointer[1],
@@ -121,7 +121,6 @@ describe("duplicating multiple elements", () => {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
},
});
@@ -132,7 +131,6 @@ describe("duplicating multiple elements", () => {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
},
boundElements: [{ id: "text2", type: "text" }],
});
@@ -249,7 +247,6 @@ describe("duplicating multiple elements", () => {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
},
});
@@ -266,13 +263,11 @@ describe("duplicating multiple elements", () => {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
},
endBinding: {
elementId: "rectangle-not-exists",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
},
});
@@ -283,13 +278,11 @@ describe("duplicating multiple elements", () => {
elementId: "rectangle-not-exists",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
},
endBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
},
});
+2 -22
View File
@@ -17,7 +17,6 @@ import type {
ExcalidrawMagicFrameElement,
ExcalidrawIframeElement,
ElementsMap,
ExcalidrawArrowElement,
} from "./types";
import {
arrayToMap,
@@ -389,6 +388,8 @@ export const newFreeDrawElement = (
export const newLinearElement = (
opts: {
type: ExcalidrawLinearElement["type"];
startArrowhead?: Arrowhead | null;
endArrowhead?: Arrowhead | null;
points?: ExcalidrawLinearElement["points"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawLinearElement> => {
@@ -398,29 +399,8 @@ export const newLinearElement = (
lastCommittedPoint: null,
startBinding: null,
endBinding: null,
startArrowhead: null,
endArrowhead: null,
};
};
export const newArrowElement = (
opts: {
type: ExcalidrawArrowElement["type"];
startArrowhead?: Arrowhead | null;
endArrowhead?: Arrowhead | null;
points?: ExcalidrawArrowElement["points"];
elbowed?: boolean;
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawArrowElement> => {
return {
..._newElementBase<ExcalidrawArrowElement>(opts.type, opts),
points: opts.points || [],
lastCommittedPoint: null,
startBinding: null,
endBinding: null,
startArrowhead: opts.startArrowhead || null,
endArrowhead: opts.endArrowhead || null,
elbowed: opts.elbowed || false,
};
};
+25 -55
View File
@@ -11,8 +11,6 @@ import type {
ExcalidrawTextElementWithContainer,
ExcalidrawImageElement,
ElementsMap,
NonDeletedSceneElementsMap,
SceneElementsMap,
} from "./types";
import type { Mutable } from "../utility-types";
import {
@@ -24,7 +22,6 @@ import {
import {
isArrowElement,
isBoundToContainer,
isElbowArrow,
isFrameLikeElement,
isFreeDrawElement,
isImageElement,
@@ -33,7 +30,7 @@ import {
} from "./typeChecks";
import { mutateElement } from "./mutateElement";
import { getFontString } from "../utils";
import { getArrowLocalFixedPoints, updateBoundElements } from "./binding";
import { updateBoundElements } from "./binding";
import type {
MaybeTransformHandleType,
TransformHandleDirection,
@@ -54,7 +51,6 @@ import {
} from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import { isInGroup } from "../groups";
import { mutateElbowArrow } from "./routing";
export const normalizeAngle = (angle: number): number => {
if (angle < 0) {
@@ -71,7 +67,7 @@ export const transformElements = (
originalElements: PointerDownState["originalElements"],
transformHandleType: MaybeTransformHandleType,
selectedElements: readonly NonDeletedExcalidrawElement[],
elementsMap: SceneElementsMap,
elementsMap: ElementsMap,
shouldRotateWithDiscreteAngle: boolean,
shouldResizeFromCenter: boolean,
shouldMaintainAspectRatio: boolean,
@@ -83,16 +79,14 @@ export const transformElements = (
if (selectedElements.length === 1) {
const [element] = selectedElements;
if (transformHandleType === "rotation") {
if (!isElbowArrow(element)) {
rotateSingleElement(
element,
elementsMap,
pointerX,
pointerY,
shouldRotateWithDiscreteAngle,
);
updateBoundElements(element, elementsMap);
}
rotateSingleElement(
element,
elementsMap,
pointerX,
pointerY,
shouldRotateWithDiscreteAngle,
);
updateBoundElements(element, elementsMap);
} else if (isTextElement(element) && transformHandleType) {
resizeSingleTextElement(
originalElements,
@@ -432,7 +426,7 @@ export const resizeSingleElement = (
originalElements: PointerDownState["originalElements"],
shouldMaintainAspectRatio: boolean,
element: NonDeletedExcalidrawElement,
elementsMap: SceneElementsMap,
elementsMap: ElementsMap,
transformHandleDirection: TransformHandleDirection,
shouldResizeFromCenter: boolean,
pointerX: number,
@@ -708,10 +702,7 @@ export const resizeSingleElement = (
mutateElement(element, resizedElement);
updateBoundElements(element, elementsMap, {
oldSize: {
width: stateAtResizeStart.width,
height: stateAtResizeStart.height,
},
newSize: { width: resizedElement.width, height: resizedElement.height },
});
if (boundTextElement && boundTextFont != null) {
@@ -731,7 +722,7 @@ export const resizeSingleElement = (
export const resizeMultipleElements = (
originalElements: PointerDownState["originalElements"],
selectedElements: readonly NonDeletedExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
elementsMap: ElementsMap,
transformHandleType: TransformHandleDirection,
shouldResizeFromCenter: boolean,
shouldMaintainAspectRatio: boolean,
@@ -964,27 +955,13 @@ export const resizeMultipleElements = (
element,
update: { boundTextFontSize, ...update },
} of elementsAndUpdates) {
const { angle } = update;
const { width: oldWidth, height: oldHeight } = element;
const { width, height, angle } = update;
mutateElement(element, update, false);
if (isArrowElement(element) && isElbowArrow(element)) {
mutateElbowArrow(
element,
elementsMap,
element.points,
undefined,
undefined,
{
informMutation: false,
},
);
}
updateBoundElements(element, elementsMap, {
simultaneouslyUpdated: elementsToUpdate,
oldSize: { width: oldWidth, height: oldHeight },
newSize: { width, height },
});
const boundTextElement = getBoundTextElement(element, elementsMap);
@@ -1007,7 +984,7 @@ export const resizeMultipleElements = (
const rotateMultipleElements = (
originalElements: PointerDownState["originalElements"],
elements: readonly NonDeletedExcalidrawElement[],
elementsMap: SceneElementsMap,
elementsMap: ElementsMap,
pointerX: number,
pointerY: number,
shouldRotateWithDiscreteAngle: boolean,
@@ -1036,22 +1013,15 @@ const rotateMultipleElements = (
centerY,
centerAngle + origAngle - element.angle,
);
if (isArrowElement(element) && isElbowArrow(element)) {
const points = getArrowLocalFixedPoints(element, elementsMap);
mutateElbowArrow(element, elementsMap, points);
} else {
mutateElement(
element,
{
x: element.x + (rotatedCX - cx),
y: element.y + (rotatedCY - cy),
angle: normalizeAngle(centerAngle + origAngle),
},
false,
);
}
mutateElement(
element,
{
x: element.x + (rotatedCX - cx),
y: element.y + (rotatedCY - cy),
angle: normalizeAngle(centerAngle + origAngle),
},
false,
);
updateBoundElements(element, elementsMap, {
simultaneouslyUpdated: elements,
});
@@ -1,210 +0,0 @@
import React from "react";
import Scene from "../scene/Scene";
import { API } from "../tests/helpers/api";
import { Pointer, UI } from "../tests/helpers/ui";
import {
fireEvent,
GlobalTestState,
queryByTestId,
render,
} from "../tests/test-utils";
import { bindLinearElement } from "./binding";
import { Excalidraw } from "../index";
import { mutateElbowArrow } from "./routing";
import type {
ExcalidrawArrowElement,
ExcalidrawBindableElement,
ExcalidrawElbowArrowElement,
} from "./types";
import { ARROW_TYPE } from "../constants";
const { h } = window;
const mouse = new Pointer("mouse");
const getStatsProperty = (label: string) => {
const elementStats = UI.queryStats()?.querySelector("#elementStats");
if (elementStats) {
const properties = elementStats?.querySelector(".statsItem");
return (
properties?.querySelector?.(
`.drag-input-container[data-testid="${label}"]`,
) || null
);
}
return null;
};
describe("elbow arrow routing", () => {
it("can properly generate orthogonal arrow points", () => {
const scene = new Scene();
const arrow = API.createElement({
type: "arrow",
elbowed: true,
}) as ExcalidrawElbowArrowElement;
scene.insertElement(arrow);
mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [
[-45 - arrow.x, -100.1 - arrow.y],
[45 - arrow.x, 99.9 - arrow.y],
]);
expect(arrow.points).toEqual([
[0, 0],
[0, 100],
[90, 100],
[90, 200],
]);
expect(arrow.x).toEqual(-45);
expect(arrow.y).toEqual(-100.1);
expect(arrow.width).toEqual(90);
expect(arrow.height).toEqual(200);
});
it("can generate proper points for bound elbow arrow", () => {
const scene = new Scene();
const rectangle1 = API.createElement({
type: "rectangle",
x: -150,
y: -150,
width: 100,
height: 100,
}) as ExcalidrawBindableElement;
const rectangle2 = API.createElement({
type: "rectangle",
x: 50,
y: 50,
width: 100,
height: 100,
}) as ExcalidrawBindableElement;
const arrow = API.createElement({
type: "arrow",
elbowed: true,
x: -45,
y: -100.1,
width: 90,
height: 200,
points: [
[0, 0],
[90, 200],
],
}) as ExcalidrawElbowArrowElement;
scene.insertElement(rectangle1);
scene.insertElement(rectangle2);
scene.insertElement(arrow);
const elementsMap = scene.getNonDeletedElementsMap();
bindLinearElement(arrow, rectangle1, "start", elementsMap);
bindLinearElement(arrow, rectangle2, "end", elementsMap);
expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null);
mutateElbowArrow(arrow, elementsMap, [
[0, 0],
[90, 200],
]);
expect(arrow.points).toEqual([
[0, 0],
[45, 0],
[45, 200],
[90, 200],
]);
});
});
describe("elbow arrow ui", () => {
beforeEach(async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("can follow bound shapes", async () => {
UI.createElement("rectangle", {
x: -150,
y: -150,
width: 100,
height: 100,
});
UI.createElement("rectangle", {
x: 50,
y: 50,
width: 100,
height: 100,
});
UI.clickTool("arrow");
UI.clickOnTestId("elbow-arrow");
expect(h.state.currentItemArrowType).toBe(ARROW_TYPE.elbow);
mouse.reset();
mouse.moveTo(-43, -99);
mouse.click();
mouse.moveTo(43, 99);
mouse.click();
const arrow = h.scene.getSelectedElements(
h.state,
)[0] as ExcalidrawArrowElement;
expect(arrow.type).toBe("arrow");
expect(arrow.elbowed).toBe(true);
expect(arrow.points).toEqual([
[0, 0],
[35, 0],
[35, 200],
[90, 200],
]);
});
it("can follow bound rotated shapes", async () => {
UI.createElement("rectangle", {
x: -150,
y: -150,
width: 100,
height: 100,
});
UI.createElement("rectangle", {
x: 50,
y: 50,
width: 100,
height: 100,
});
UI.clickTool("arrow");
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.moveTo(-43, -99);
mouse.click();
mouse.moveTo(43, 99);
mouse.click();
const arrow = h.scene.getSelectedElements(
h.state,
)[0] as ExcalidrawArrowElement;
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
mouse.click(51, 51);
const inputAngle = getStatsProperty("A")?.querySelector(
".drag-input",
) as HTMLInputElement;
UI.updateInput(inputAngle, String("40"));
expect(arrow.points.map((point) => point.map(Math.round))).toEqual([
[0, 0],
[35, 0],
[35, 90],
[25, 90],
[25, 165],
[103, 165],
]);
});
});
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -635,7 +635,8 @@ export const getMaxCharWidth = (font: FontString) => {
export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
return container?.boundElements?.length
? container?.boundElements?.find((ele) => ele.type === "text")?.id || null
? container?.boundElements?.filter((ele) => ele.type === "text")[0]?.id ||
null
: null;
};
+176 -118
View File
@@ -1,4 +1,3 @@
import React from "react";
import ReactDOM from "react-dom";
import { Excalidraw } from "../index";
import { GlobalTestState, render, screen } from "../tests/test-utils";
@@ -17,6 +16,7 @@ import type {
ExcalidrawTextElementWithContainer,
} from "./types";
import { API } from "../tests/helpers/api";
import { mutateElement } from "./mutateElement";
import { getOriginalContainerHeightFromCache } from "./containerCache";
import { getTextEditor, updateTextEditor } from "../tests/queries/dom";
@@ -33,7 +33,7 @@ describe("textWysiwyg", () => {
const { h } = window;
beforeEach(async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
API.setElements([]);
h.elements = [];
});
it("should prefer editing selected text element (non-bindable container present)", async () => {
@@ -55,7 +55,7 @@ describe("textWysiwyg", () => {
width: textSize,
height: textSize,
});
API.setElements([text, line]);
h.elements = [text, line];
API.setSelectedElements([text]);
@@ -95,9 +95,9 @@ describe("textWysiwyg", () => {
containerId: container.id,
});
API.setElements([container, boundText, boundText2]);
h.elements = [container, boundText, boundText2];
API.updateElement(container, {
mutateElement(container, {
boundElements: [{ type: "text", id: boundText.id }],
});
@@ -123,11 +123,11 @@ describe("textWysiwyg", () => {
height: textSize,
containerId: container.id,
});
API.updateElement(container, {
mutateElement(container, {
boundElements: [{ type: "text", id: text.id }],
});
API.setElements([container, text]);
h.elements = [container, text];
API.setSelectedElements([container]);
@@ -164,9 +164,9 @@ describe("textWysiwyg", () => {
containerId: container.id,
});
API.setElements([container, boundText, boundText2]);
h.elements = [container, boundText, boundText2];
API.updateElement(container, {
mutateElement(container, {
boundElements: [{ type: "text", id: boundText.id }],
});
@@ -187,7 +187,7 @@ describe("textWysiwyg", () => {
height: 100,
});
API.setElements([text]);
h.elements = [text];
UI.clickTool("text");
mouse.clickAt(text.x + 50, text.y + 50);
@@ -209,7 +209,7 @@ describe("textWysiwyg", () => {
height: 100,
});
API.setElements([text]);
h.elements = [text];
UI.clickTool("selection");
mouse.doubleClickAt(text.x + 50, text.y + 50);
@@ -251,7 +251,7 @@ describe("textWysiwyg", () => {
// @ts-ignore
h.app.refreshEditorBreakpoints();
API.setElements([]);
h.elements = [];
});
afterAll(() => {
@@ -264,7 +264,7 @@ describe("textWysiwyg", () => {
text: "Excalidraw\nEditor",
});
API.setElements([text]);
h.elements = [text];
const prevWidth = text.width;
const prevHeight = text.height;
@@ -291,15 +291,18 @@ describe("textWysiwyg", () => {
const nextText = `${wrappedText} is great!`;
updateTextEditor(editor, nextText);
Keyboard.exitTextEditor(editor);
await new Promise((cb) => setTimeout(cb, 0));
editor.blur();
expect(h.elements[0].width).toEqual(wrappedWidth);
expect(h.elements[0].height).toBeGreaterThan(wrappedHeight);
// remove all texts and then add it back editing
updateTextEditor(editor, "");
await new Promise((cb) => setTimeout(cb, 0));
updateTextEditor(editor, nextText);
Keyboard.exitTextEditor(editor);
await new Promise((cb) => setTimeout(cb, 0));
editor.blur();
expect(h.elements[0].width).toEqual(wrappedWidth);
});
@@ -310,7 +313,7 @@ describe("textWysiwyg", () => {
type: "text",
text: originalText,
});
API.setElements([text]);
h.elements = [text];
// wrap
UI.resize(text, "e", [-40, 0]);
@@ -318,7 +321,7 @@ describe("textWysiwyg", () => {
UI.clickTool("selection");
mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
const editor = await getTextEditor(textEditorSelector);
Keyboard.exitTextEditor(editor);
editor.blur();
// restore after unwrapping
UI.resize(text, "e", [40, 0]);
expect((h.elements[0] as ExcalidrawTextElement).text).toBe(originalText);
@@ -329,12 +332,14 @@ describe("textWysiwyg", () => {
UI.clickTool("selection");
mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
updateTextEditor(editor, `${wrappedText}\nA new line!`);
Keyboard.exitTextEditor(editor);
await new Promise((cb) => setTimeout(cb, 0));
editor.blur();
// remove the newly added line
UI.clickTool("selection");
mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
updateTextEditor(editor, wrappedText);
Keyboard.exitTextEditor(editor);
await new Promise((cb) => setTimeout(cb, 0));
editor.blur();
// unwrap
UI.resize(text, "e", [30, 0]);
// expect the text to be restored the same
@@ -371,11 +376,12 @@ describe("textWysiwyg", () => {
});
it("should add a tab at the start of the first line", () => {
const event = new KeyboardEvent("keydown", { key: KEYS.TAB });
textarea.value = "Line#1\nLine#2";
// cursor: "|Line#1\nLine#2"
textarea.selectionStart = 0;
textarea.selectionEnd = 0;
fireEvent.keyDown(textarea, { key: KEYS.TAB });
textarea.dispatchEvent(event);
expect(textarea.value).toEqual(`${tab}Line#1\nLine#2`);
// cursor: " |Line#1\nLine#2"
@@ -384,12 +390,13 @@ describe("textWysiwyg", () => {
});
it("should add a tab at the start of the second line", () => {
const event = new KeyboardEvent("keydown", { key: KEYS.TAB });
textarea.value = "Line#1\nLine#2";
// cursor: "Line#1\nLin|e#2"
textarea.selectionStart = 10;
textarea.selectionEnd = 10;
fireEvent.keyDown(textarea, { key: KEYS.TAB });
textarea.dispatchEvent(event);
expect(textarea.value).toEqual(`Line#1\n${tab}Line#2`);
@@ -399,12 +406,13 @@ describe("textWysiwyg", () => {
});
it("should add a tab at the start of the first and second line", () => {
const event = new KeyboardEvent("keydown", { key: KEYS.TAB });
textarea.value = "Line#1\nLine#2\nLine#3";
// cursor: "Li|ne#1\nLi|ne#2\nLine#3"
textarea.selectionStart = 2;
textarea.selectionEnd = 9;
fireEvent.keyDown(textarea, { key: KEYS.TAB });
textarea.dispatchEvent(event);
expect(textarea.value).toEqual(`${tab}Line#1\n${tab}Line#2\nLine#3`);
@@ -414,15 +422,16 @@ describe("textWysiwyg", () => {
});
it("should remove a tab at the start of the first line", () => {
const event = new KeyboardEvent("keydown", {
key: KEYS.TAB,
shiftKey: true,
});
textarea.value = `${tab}Line#1\nLine#2`;
// cursor: "| Line#1\nLine#2"
textarea.selectionStart = 0;
textarea.selectionEnd = 0;
fireEvent.keyDown(textarea, {
key: KEYS.TAB,
shiftKey: true,
});
textarea.dispatchEvent(event);
expect(textarea.value).toEqual(`Line#1\nLine#2`);
@@ -432,15 +441,16 @@ describe("textWysiwyg", () => {
});
it("should remove a tab at the start of the second line", () => {
const event = new KeyboardEvent("keydown", {
key: KEYS.TAB,
shiftKey: true,
});
// cursor: "Line#1\n Lin|e#2"
textarea.value = `Line#1\n${tab}Line#2`;
textarea.selectionStart = 15;
textarea.selectionEnd = 15;
fireEvent.keyDown(textarea, {
key: KEYS.TAB,
shiftKey: true,
});
textarea.dispatchEvent(event);
expect(textarea.value).toEqual(`Line#1\nLine#2`);
// cursor: "Line#1\nLin|e#2"
@@ -449,15 +459,16 @@ describe("textWysiwyg", () => {
});
it("should remove a tab at the start of the first and second line", () => {
const event = new KeyboardEvent("keydown", {
key: KEYS.TAB,
shiftKey: true,
});
// cursor: " Li|ne#1\n Li|ne#2\nLine#3"
textarea.value = `${tab}Line#1\n${tab}Line#2\nLine#3`;
textarea.selectionStart = 6;
textarea.selectionEnd = 17;
fireEvent.keyDown(textarea, {
key: KEYS.TAB,
shiftKey: true,
});
textarea.dispatchEvent(event);
expect(textarea.value).toEqual(`Line#1\nLine#2\nLine#3`);
// cursor: "Li|ne#1\nLi|ne#2\nLine#3"
@@ -466,41 +477,45 @@ describe("textWysiwyg", () => {
});
it("should remove a tab at the start of the second line and cursor stay on this line", () => {
const event = new KeyboardEvent("keydown", {
key: KEYS.TAB,
shiftKey: true,
});
// cursor: "Line#1\n | Line#2"
textarea.value = `Line#1\n${tab}Line#2`;
textarea.selectionStart = 9;
textarea.selectionEnd = 9;
fireEvent.keyDown(textarea, {
key: KEYS.TAB,
shiftKey: true,
});
textarea.dispatchEvent(event);
// cursor: "Line#1\n|Line#2"
expect(textarea.selectionStart).toEqual(7);
// expect(textarea.selectionEnd).toEqual(7);
});
it("should remove partial tabs", () => {
const event = new KeyboardEvent("keydown", {
key: KEYS.TAB,
shiftKey: true,
});
// cursor: "Line#1\n Line#|2"
textarea.value = `Line#1\n Line#2`;
textarea.selectionStart = 15;
textarea.selectionEnd = 15;
fireEvent.keyDown(textarea, {
key: KEYS.TAB,
shiftKey: true,
});
textarea.dispatchEvent(event);
expect(textarea.value).toEqual(`Line#1\nLine#2`);
});
it("should remove nothing", () => {
const event = new KeyboardEvent("keydown", {
key: KEYS.TAB,
shiftKey: true,
});
// cursor: "Line#1\n Li|ne#2"
textarea.value = `Line#1\nLine#2`;
textarea.selectionStart = 9;
textarea.selectionEnd = 9;
fireEvent.keyDown(textarea, {
key: KEYS.TAB,
shiftKey: true,
});
textarea.dispatchEvent(event);
expect(textarea.value).toEqual(`Line#1\nLine#2`);
});
@@ -508,42 +523,54 @@ describe("textWysiwyg", () => {
it("should resize text via shortcuts while in wysiwyg", () => {
textarea.value = "abc def";
const origFontSize = textElement.fontSize;
fireEvent.keyDown(textarea, {
key: KEYS.CHEVRON_RIGHT,
ctrlKey: true,
shiftKey: true,
});
textarea.dispatchEvent(
new KeyboardEvent("keydown", {
key: KEYS.CHEVRON_RIGHT,
ctrlKey: true,
shiftKey: true,
}),
);
expect(textElement.fontSize).toBe(origFontSize * 1.1);
fireEvent.keyDown(textarea, {
key: KEYS.CHEVRON_LEFT,
ctrlKey: true,
shiftKey: true,
});
textarea.dispatchEvent(
new KeyboardEvent("keydown", {
key: KEYS.CHEVRON_LEFT,
ctrlKey: true,
shiftKey: true,
}),
);
expect(textElement.fontSize).toBe(origFontSize);
});
it("zooming via keyboard should zoom canvas", () => {
expect(h.state.zoom.value).toBe(1);
fireEvent.keyDown(textarea, {
code: CODES.MINUS,
ctrlKey: true,
});
textarea.dispatchEvent(
new KeyboardEvent("keydown", {
code: CODES.MINUS,
ctrlKey: true,
}),
);
expect(h.state.zoom.value).toBe(0.9);
fireEvent.keyDown(textarea, {
code: CODES.NUM_SUBTRACT,
ctrlKey: true,
});
textarea.dispatchEvent(
new KeyboardEvent("keydown", {
code: CODES.NUM_SUBTRACT,
ctrlKey: true,
}),
);
expect(h.state.zoom.value).toBe(0.8);
fireEvent.keyDown(textarea, {
code: CODES.NUM_ADD,
ctrlKey: true,
});
textarea.dispatchEvent(
new KeyboardEvent("keydown", {
code: CODES.NUM_ADD,
ctrlKey: true,
}),
);
expect(h.state.zoom.value).toBe(0.9);
fireEvent.keyDown(textarea, {
code: CODES.EQUAL,
ctrlKey: true,
});
textarea.dispatchEvent(
new KeyboardEvent("keydown", {
code: CODES.EQUAL,
ctrlKey: true,
}),
);
expect(h.state.zoom.value).toBe(1);
});
@@ -556,8 +583,8 @@ describe("textWysiwyg", () => {
textarea,
"Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!",
);
Keyboard.exitTextEditor(textarea);
await new Promise((cb) => setTimeout(cb, 0));
textarea.blur();
expect(textarea.style.width).toBe("792px");
expect(h.elements[0].width).toBe(1000);
});
@@ -569,7 +596,7 @@ describe("textWysiwyg", () => {
beforeEach(async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
API.setElements([]);
h.elements = [];
rectangle = UI.createElement("rectangle", {
x: 10,
@@ -588,7 +615,7 @@ describe("textWysiwyg", () => {
height: 75,
backgroundColor: "red",
});
API.setElements([rectangle]);
h.elements = [rectangle];
expect(h.elements.length).toBe(1);
expect(h.elements[0].id).toBe(rectangle.id);
@@ -607,7 +634,8 @@ describe("textWysiwyg", () => {
updateTextEditor(editor, "Hello World!");
Keyboard.exitTextEditor(editor);
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
]);
@@ -620,7 +648,7 @@ describe("textWysiwyg", () => {
height: 75,
angle: 45,
});
API.setElements([rectangle]);
h.elements = [rectangle];
mouse.doubleClickAt(rectangle.x + 10, rectangle.y + 10);
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
@@ -634,7 +662,8 @@ describe("textWysiwyg", () => {
updateTextEditor(editor, "Hello World!");
Keyboard.exitTextEditor(editor);
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
]);
@@ -648,7 +677,7 @@ describe("textWysiwyg", () => {
width: 90,
height: 75,
});
API.setElements([diamond]);
h.elements = [diamond];
expect(h.elements.length).toBe(1);
expect(h.elements[0].id).toBe(diamond.id);
@@ -658,6 +687,7 @@ describe("textWysiwyg", () => {
const editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
const value = new Array(1000).fill("1").join("\n");
// Pasting large text to simulate height increase
@@ -682,7 +712,7 @@ describe("textWysiwyg", () => {
height: 75,
backgroundColor: "transparent",
});
API.setElements([rectangle]);
h.elements = [rectangle];
mouse.doubleClickAt(rectangle.x + 10, rectangle.y + 10);
expect(h.elements.length).toBe(2);
@@ -691,7 +721,8 @@ describe("textWysiwyg", () => {
expect(text.containerId).toBe(null);
mouse.down();
let editor = await getTextEditor(textEditorSelector, true);
Keyboard.exitTextEditor(editor);
await new Promise((r) => setTimeout(r, 0));
editor.blur();
mouse.doubleClickAt(
rectangle.x + rectangle.width / 2,
@@ -707,7 +738,8 @@ describe("textWysiwyg", () => {
editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello World!");
Keyboard.exitTextEditor(editor);
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
@@ -727,8 +759,10 @@ describe("textWysiwyg", () => {
expect(text.containerId).toBe(rectangle.id);
const editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello World!");
Keyboard.exitTextEditor(editor);
editor.blur();
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
]);
@@ -743,7 +777,7 @@ describe("textWysiwyg", () => {
height: 75,
strokeWidth: 4,
});
API.setElements([rectangle]);
h.elements = [rectangle];
expect(h.elements.length).toBe(1);
expect(h.elements[0].id).toBe(rectangle.id);
@@ -761,7 +795,8 @@ describe("textWysiwyg", () => {
const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello World!");
Keyboard.exitTextEditor(editor);
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
]);
@@ -773,7 +808,7 @@ describe("textWysiwyg", () => {
width: 100,
height: 0,
});
API.setElements([freedraw]);
h.elements = [freedraw];
UI.clickTool("text");
@@ -784,7 +819,7 @@ describe("textWysiwyg", () => {
const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello World!");
Keyboard.exitTextEditor(editor);
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
expect(freedraw.boundElements).toBe(null);
expect(h.elements[1].type).toBe("text");
@@ -793,7 +828,7 @@ describe("textWysiwyg", () => {
["freedraw", "line"].forEach((type: any) => {
it(`shouldn't create text element when pressing 'Enter' key on ${type} `, async () => {
API.setElements([]);
h.elements = [];
const element = UI.createElement(type, {
width: 100,
height: 50,
@@ -820,7 +855,8 @@ describe("textWysiwyg", () => {
updateTextEditor(editor, "Hello World!");
Keyboard.exitTextEditor(editor);
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect(rectangle.boundElements).toBe(null);
});
@@ -836,6 +872,7 @@ describe("textWysiwyg", () => {
editor,
"Excalidraw is an opensource virtual collaborative whiteboard",
);
await new Promise((cb) => setTimeout(cb, 0));
expect(h.elements.length).toBe(2);
expect(h.elements[1].type).toBe("text");
@@ -871,18 +908,14 @@ describe("textWysiwyg", () => {
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
);
mouse.down();
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
const editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello World!");
Keyboard.exitTextEditor(editor);
expect(await getTextEditor(textEditorSelector, false)).toBe(null);
expect(h.state.editingElement).toBe(null);
editor.blur();
expect(text.fontFamily).toEqual(FONT_FAMILY.Excalifont);
fireEvent.click(screen.getByTitle(/code/i));
@@ -917,7 +950,8 @@ describe("textWysiwyg", () => {
updateTextEditor(editor, "Hello World!");
Keyboard.exitTextEditor(editor);
await new Promise((cb) => setTimeout(cb, 0));
editor.blur();
text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.text).toBe("Hello \nWorld!");
expect(text.originalText).toBe("Hello World!");
@@ -936,7 +970,9 @@ describe("textWysiwyg", () => {
editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello");
Keyboard.exitTextEditor(editor);
await new Promise((r) => setTimeout(r, 0));
editor.blur();
text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.text).toBe("Hello");
@@ -962,8 +998,10 @@ describe("textWysiwyg", () => {
const editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello World!");
Keyboard.exitTextEditor(editor);
editor.blur();
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
]);
@@ -996,8 +1034,9 @@ describe("textWysiwyg", () => {
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.containerId).toBe(rectangle.id);
const editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello World!");
Keyboard.exitTextEditor(editor);
editor.blur();
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
]);
@@ -1016,8 +1055,9 @@ describe("textWysiwyg", () => {
Keyboard.keyPress(KEYS.ENTER);
let editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello");
Keyboard.exitTextEditor(editor);
editor.blur();
// should center align horizontally and vertically by default
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
@@ -1036,8 +1076,12 @@ describe("textWysiwyg", () => {
editor.select();
fireEvent.click(screen.getByTitle("Left"));
await new Promise((r) => setTimeout(r, 0));
fireEvent.click(screen.getByTitle("Align bottom"));
Keyboard.exitTextEditor(editor);
await new Promise((r) => setTimeout(r, 0));
editor.blur();
// should left align horizontally and bottom vertically after resize
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
@@ -1057,7 +1101,9 @@ describe("textWysiwyg", () => {
fireEvent.click(screen.getByTitle("Right"));
fireEvent.click(screen.getByTitle("Align top"));
Keyboard.exitTextEditor(editor);
await new Promise((r) => setTimeout(r, 0));
editor.blur();
// should right align horizontally and top vertically after resize
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
@@ -1090,7 +1136,8 @@ describe("textWysiwyg", () => {
updateTextEditor(editor, "Hello World!");
Keyboard.exitTextEditor(editor);
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect(rectangle2.boundElements).toBeNull();
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
@@ -1101,8 +1148,9 @@ describe("textWysiwyg", () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello");
Keyboard.exitTextEditor(editor);
editor.blur();
const textElement = h.elements[1] as ExcalidrawTextElement;
expect(rectangle.width).toBe(90);
expect(rectangle.height).toBe(75);
@@ -1120,8 +1168,9 @@ describe("textWysiwyg", () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello");
Keyboard.exitTextEditor(editor);
editor.blur();
expect(h.elements.length).toBe(2);
mouse.select(rectangle);
@@ -1151,8 +1200,9 @@ describe("textWysiwyg", () => {
it("undo should work", async () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello");
Keyboard.exitTextEditor(editor);
editor.blur();
expect(rectangle.boundElements).toStrictEqual([
{ id: h.elements[1].id, type: "text" },
]);
@@ -1187,9 +1237,10 @@ describe("textWysiwyg", () => {
it("should not allow bound text with only whitespaces", async () => {
Keyboard.keyPress(KEYS.ENTER);
const editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, " ");
Keyboard.exitTextEditor(editor);
editor.blur();
expect(rectangle.boundElements).toStrictEqual([]);
expect(h.elements[1].isDeleted).toBe(true);
});
@@ -1208,7 +1259,7 @@ describe("textWysiwyg", () => {
text: "Online whiteboard collaboration made easy",
});
API.setElements([container, text]);
h.elements = [container, text];
API.setSelectedElements([container, text]);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
@@ -1241,8 +1292,9 @@ describe("textWysiwyg", () => {
Keyboard.keyPress(KEYS.ENTER);
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
let editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello");
Keyboard.exitTextEditor(editor);
editor.blur();
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
expect(rectangle.height).toBeCloseTo(155, 8);
@@ -1253,7 +1305,8 @@ describe("textWysiwyg", () => {
editor = await getTextEditor(textEditorSelector, true);
Keyboard.exitTextEditor(editor);
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect(rectangle.height).toBeCloseTo(155, 8);
// cache updated again
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBeCloseTo(
@@ -1268,7 +1321,7 @@ describe("textWysiwyg", () => {
const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello World!");
Keyboard.exitTextEditor(editor);
editor.blur();
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
@@ -1293,7 +1346,7 @@ describe("textWysiwyg", () => {
const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello World!");
Keyboard.exitTextEditor(editor);
editor.blur();
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
).toEqual(1.25);
@@ -1325,7 +1378,7 @@ describe("textWysiwyg", () => {
Keyboard.keyPress(KEYS.ENTER);
editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello");
Keyboard.exitTextEditor(editor);
editor.blur();
mouse.select(rectangle);
Keyboard.keyPress(KEYS.ENTER);
editor = await getTextEditor(textEditorSelector, true);
@@ -1445,11 +1498,13 @@ describe("textWysiwyg", () => {
editor,
"Excalidraw is an opensource virtual collaborative whiteboard",
);
await new Promise((cb) => setTimeout(cb, 0));
editor.select();
fireEvent.click(screen.getByTitle("Left"));
await new Promise((r) => setTimeout(r, 0));
Keyboard.exitTextEditor(editor);
editor.blur();
const textElement = h.elements[1] as ExcalidrawTextElement;
expect(textElement.width).toBe(600);
@@ -1526,14 +1581,16 @@ describe("textWysiwyg", () => {
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.containerId).toBe(rectangle.id);
let editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Hello!");
expect(
(h.elements[1] as ExcalidrawTextElementWithContainer).verticalAlign,
).toBe(VERTICAL_ALIGN.MIDDLE);
fireEvent.click(screen.getByTitle("Align bottom"));
await new Promise((r) => setTimeout(r, 0));
Keyboard.exitTextEditor(editor);
editor.blur();
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
@@ -1549,8 +1606,9 @@ describe("textWysiwyg", () => {
rectangle.y + rectangle.height / 2,
);
editor = await getTextEditor(textEditorSelector, true);
await new Promise((r) => setTimeout(r, 0));
updateTextEditor(editor, "Excalidraw");
Keyboard.exitTextEditor(editor);
editor.blur();
expect(h.elements.length).toBe(3);
expect(rectangle.boundElements).toStrictEqual([
@@ -9,11 +9,7 @@ import type { Bounds } from "./bounds";
import { getElementAbsoluteCoords } from "./bounds";
import { rotate } from "../math";
import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
import {
isElbowArrow,
isFrameLikeElement,
isLinearElement,
} from "./typeChecks";
import { isFrameLikeElement, isLinearElement } from "./typeChecks";
import {
DEFAULT_TRANSFORM_HANDLE_SPACING,
isAndroid,
@@ -266,11 +262,7 @@ export const getTransformHandles = (
// so that when locked element is selected (especially when you toggle lock
// via keyboard) the locked element is visually distinct, indicating
// you can't move/resize
if (
element.locked ||
// Elbow arrows cannot be rotated
isElbowArrow(element)
) {
if (element.locked) {
return {};
}
@@ -320,10 +312,6 @@ export const shouldShowBoundingBox = (
return true;
}
const element = elements[0];
if (isElbowArrow(element)) {
// Elbow arrows cannot be resized as single selected elements
return false;
}
if (!isLinearElement(element)) {
return true;
}
-31
View File
@@ -21,9 +21,6 @@ import type {
ExcalidrawIframeLikeElement,
ExcalidrawMagicFrameElement,
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
PointBinding,
FixedPointBinding,
} from "./types";
export const isInitializedImageElement = (
@@ -109,12 +106,6 @@ export const isArrowElement = (
return element != null && element.type === "arrow";
};
export const isElbowArrow = (
element?: ExcalidrawElement,
): element is ExcalidrawElbowArrowElement => {
return isArrowElement(element) && element.elbowed;
};
export const isLinearElementType = (
elementType: ElementOrToolType,
): boolean => {
@@ -159,22 +150,6 @@ export const isBindableElement = (
);
};
export const isRectanguloidElement = (
element?: ExcalidrawElement | null,
): element is ExcalidrawBindableElement => {
return (
element != null &&
(element.type === "rectangle" ||
element.type === "diamond" ||
element.type === "image" ||
element.type === "iframe" ||
element.type === "embeddable" ||
element.type === "frame" ||
element.type === "magicframe" ||
(element.type === "text" && !element.containerId))
);
};
export const isTextBindableContainer = (
element: ExcalidrawElement | null,
includeLocked = true,
@@ -288,9 +263,3 @@ export const getDefaultRoundnessTypeForElement = (
return null;
};
export const isFixedPointBinding = (
binding: PointBinding,
): binding is FixedPointBinding => {
return binding.fixedPoint != null;
};
+1 -31
View File
@@ -6,12 +6,7 @@ import type {
THEME,
VERTICAL_ALIGN,
} from "../constants";
import type {
MakeBrand,
MarkNonNullable,
Merge,
ValueOf,
} from "../utility-types";
import type { MakeBrand, MarkNonNullable, ValueOf } from "../utility-types";
import type { MagicCacheData } from "../data/magic";
export type ChartType = "bar" | "line";
@@ -176,11 +171,6 @@ export type ExcalidrawElement =
| ExcalidrawIframeElement
| ExcalidrawEmbeddableElement;
export type ExcalidrawNonSelectionElement = Exclude<
ExcalidrawElement,
ExcalidrawSelectionElement
>;
export type Ordered<TElement extends ExcalidrawElement> = TElement & {
index: FractionalIndex;
};
@@ -238,22 +228,12 @@ export type ExcalidrawTextElementWithContainer = {
containerId: ExcalidrawTextContainer["id"];
} & ExcalidrawTextElement;
export type FixedPoint = [number, number];
export type PointBinding = {
elementId: ExcalidrawBindableElement["id"];
focus: number;
gap: number;
// Represents the fixed point binding information in form of a vertical and
// horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
// gives the user selected fixed point by multiplying the bound element width
// with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
// bound element-local point coordinate.
fixedPoint: FixedPoint | null;
};
export type FixedPointBinding = Merge<PointBinding, { fixedPoint: FixedPoint }>;
export type Arrowhead =
| "arrow"
| "bar"
@@ -279,18 +259,8 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
export type ExcalidrawArrowElement = ExcalidrawLinearElement &
Readonly<{
type: "arrow";
elbowed: boolean;
}>;
export type ExcalidrawElbowArrowElement = Merge<
ExcalidrawArrowElement,
{
elbowed: true;
startBinding: FixedPointBinding | null;
endBinding: FixedPointBinding | null;
}
>;
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
Readonly<{
type: "freedraw";
+44 -177
View File
@@ -1,32 +1,41 @@
import { LOCAL_FONT_PROTOCOL } from "./metadata";
import { stringToBase64, toByteString } from "../data/encode";
export interface Font {
urls: URL[];
url: URL;
fontFace: FontFace;
getContent(codePoints: ReadonlySet<number>): Promise<string>;
getContent(): Promise<string>;
}
export const UNPKG_PROD_URL = `https://unpkg.com/${
import.meta.env.VITE_PKG_NAME
? `${import.meta.env.VITE_PKG_NAME}@${import.meta.env.PKG_VERSION}` // should be provided by vite during package build
: "@excalidraw/excalidraw" // fallback to latest package version (i.e. for app)
}/dist/prod/`;
/** caches for lazy loaded chunks, reused across concurrent calls and separate editor instances */
let fontEditorCache: Promise<typeof import("fonteditor-core")> | null = null;
let brotliCache: Promise<typeof import("fonteditor-core").woff2> | null = null;
}@${import.meta.env.PKG_VERSION}/dist/prod/`;
export class ExcalidrawFont implements Font {
public readonly urls: URL[];
public readonly url: URL;
public readonly fontFace: FontFace;
constructor(family: string, uri: string, descriptors?: FontFaceDescriptors) {
this.urls = ExcalidrawFont.createUrls(uri);
// absolute assets paths, which are found in tests and excalidraw-app build, won't work with base url, so we are stripping initial slash away
const assetUrl: string = uri.replace(/^\/+/, "");
let baseUrl: string | undefined = undefined;
const sources = this.urls
.map((url) => `url(${url}) ${ExcalidrawFont.getFormat(url)}`)
.join(", ");
// fallback to unpkg to form a valid URL in case of a passed relative assetUrl
let baseUrlBuilder = window.EXCALIDRAW_ASSET_PATH || UNPKG_PROD_URL;
this.fontFace = new FontFace(family, sources, {
// in case user passed a root-relative url (~absolute path),
// like "/" or "/some/path", or relative (starts with "./"),
// prepend it with `location.origin`
if (/^\.?\//.test(baseUrlBuilder)) {
baseUrlBuilder = new URL(
baseUrlBuilder.replace(/^\.?\/+/, ""),
window?.location?.origin,
).toString();
}
// ensure there is a trailing slash, otherwise url won't be correctly concatenated
baseUrl = `${baseUrlBuilder.replace(/\/+$/, "")}/`;
this.url = new URL(assetUrl, baseUrl);
this.fontFace = new FontFace(family, `url(${this.url})`, {
display: "swap",
style: "normal",
weight: "400",
@@ -35,177 +44,35 @@ export class ExcalidrawFont implements Font {
}
/**
* Tries to fetch woff2 content, based on the registered urls.
* Fetches woff2 content based on the registered url (browser).
*
* NOTE: assumes usage of `dataurl` outside the browser environment
*
* @returns base64 with subsetted glyphs based on the passed codepoint, last defined url otherwise
* Use dataurl outside the browser environment.
*/
public async getContent(codePoints: ReadonlySet<number>): Promise<string> {
let i = 0;
const errorMessages = [];
while (i < this.urls.length) {
const url = this.urls[i];
public async getContent(): Promise<string> {
if (this.url.protocol === "data:") {
// it's dataurl, the font is inlined as base64, no need to fetch
if (url.protocol === "data:") {
const arrayBuffer = Buffer.from(
url.toString().split(",")[1],
"base64",
).buffer;
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
arrayBuffer,
codePoints,
);
return base64;
}
try {
const response = await fetch(url, {
headers: {
Accept: "font/woff2",
},
});
if (response.ok) {
const arrayBuffer = await response.arrayBuffer();
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
arrayBuffer,
codePoints,
);
return base64;
}
// response not ok, try to continue
errorMessages.push(
`"${url.toString()}" returned status "${response.status}"`,
);
} catch (e) {
errorMessages.push(`"${url.toString()}" returned error "${e}"`);
}
i++;
return this.url.toString();
}
console.error(
`Failed to fetch font "${
this.fontFace.family
}" from urls "${this.urls.toString()}`,
JSON.stringify(errorMessages, undefined, 2),
);
// in case of issues, at least return the last url as a content
// defaults to unpkg for bundled fonts (so that we don't have to host them forever) and http url for others
return this.urls.length ? this.urls[this.urls.length - 1].toString() : "";
}
/**
* Converts a font data as arraybuffer into a dataurl (base64) with subsetted glyphs based on the specified `codePoints`.
*
* NOTE: only glyphs are subsetted, other metadata as GPOS tables stay, consider filtering those as well in the future
*
* @param arrayBuffer font data buffer, preferrably in the woff2 format, though others should work as well
* @param codePoints codepoints used to subset the glyphs
*
* @returns font with subsetted glyphs converted into a dataurl
*/
private static async subsetGlyphsByCodePoints(
arrayBuffer: ArrayBuffer,
codePoints: ReadonlySet<number>,
): Promise<string> {
// checks for the cache first to avoid triggering the import multiple times in case of concurrent calls
if (!fontEditorCache) {
fontEditorCache = import("fonteditor-core");
}
const { Font, woff2 } = await fontEditorCache;
// checks for the cache first to avoid triggering the init multiple times in case of concurrent calls
if (!brotliCache) {
brotliCache = woff2.init("/wasm/woff2.wasm");
}
await brotliCache;
const font = Font.create(arrayBuffer, {
type: "woff2",
kerning: true,
hinting: true,
// subset the glyhs based on the specified codepoints!
subset: [...codePoints],
const response = await fetch(this.url, {
headers: {
Accept: "font/woff2",
},
});
return font.toBase64({ type: "woff2", hinting: true });
}
private static createUrls(uri: string): URL[] {
if (uri.startsWith(LOCAL_FONT_PROTOCOL)) {
// no url for local fonts
return [];
}
if (uri.startsWith("http") || uri.startsWith("data")) {
// one url for http imports or data url
return [new URL(uri)];
}
// absolute assets paths, which are found in tests and excalidraw-app build, won't work with base url, so we are stripping initial slash away
const assetUrl: string = uri.replace(/^\/+/, "");
const urls: URL[] = [];
if (typeof window.EXCALIDRAW_ASSET_PATH === "string") {
const normalizedBaseUrl = this.normalizeBaseUrl(
window.EXCALIDRAW_ASSET_PATH,
if (!response.ok) {
console.error(
`Couldn't fetch font-family "${this.fontFace.family}" from url "${this.url}"`,
response,
);
urls.push(new URL(assetUrl, normalizedBaseUrl));
} else if (Array.isArray(window.EXCALIDRAW_ASSET_PATH)) {
window.EXCALIDRAW_ASSET_PATH.forEach((path) => {
const normalizedBaseUrl = this.normalizeBaseUrl(path);
urls.push(new URL(assetUrl, normalizedBaseUrl));
});
}
// fallback url for bundled fonts
urls.push(new URL(assetUrl, UNPKG_PROD_URL));
const mimeType = await response.headers.get("Content-Type");
const buffer = await response.arrayBuffer();
return urls;
}
private static getFormat(url: URL) {
try {
const pathname = new URL(url).pathname;
const parts = pathname.split(".");
if (parts.length === 1) {
return "";
}
return `format('${parts.pop()}')`;
} catch (error) {
return "";
}
}
private static normalizeBaseUrl(baseUrl: string) {
let result = baseUrl;
// in case user passed a root-relative url (~absolute path),
// like "/" or "/some/path", or relative (starts with "./"),
// prepend it with `location.origin`
if (/^\.?\//.test(result)) {
result = new URL(
result.replace(/^\.?\/+/, ""),
window?.location?.origin,
).toString();
}
// ensure there is a trailing slash, otherwise url won't be correctly concatenated
result = `${result.replace(/\/+$/, "")}/`;
return result;
return `data:${mimeType};base64,${await stringToBase64(
await toByteString(buffer),
true,
)}`;
}
}
+1 -2
View File
@@ -1,6 +1,5 @@
/* Only UI fonts here, which are needed before the editor initializes. */
/* These cannot be dynamically prepended with `EXCALIDRAW_ASSET_PATH`. */
/* WARN: The following content is replaced during excalidraw-app build */
/* These also cannot be preprended with `EXCALIDRAW_ASSET_PATH`. */
@font-face {
font-family: "Assistant";
+32 -83
View File
@@ -1,10 +1,6 @@
import type Scene from "../scene/Scene";
import type { ValueOf } from "../utility-types";
import type {
ExcalidrawElement,
ExcalidrawTextElement,
FontFamilyValues,
} from "../element/types";
import type { ExcalidrawTextElement, FontFamilyValues } from "../element/types";
import { ShapeCache } from "../scene/ShapeCache";
import { isTextElement } from "../element";
import { getFontString } from "../utils";
@@ -20,7 +16,7 @@ import { getContainerElement } from "../element/textElement";
import Virgil from "./assets/Virgil-Regular.woff2";
import Excalifont from "./assets/Excalifont-Regular.woff2";
import Cascadia from "./assets/CascadiaCode-Regular.woff2";
import Cascadia from "./assets/CascadiaMono-Regular.woff2";
import ComicShanns from "./assets/ComicShanns-Regular.woff2";
import LiberationSans from "./assets/LiberationSans-Regular.woff2";
@@ -43,24 +39,15 @@ export class Fonts {
number,
{
metadata: FontMetadata;
fonts: Font[];
fontFaces: Font[];
}
>
| undefined;
private static _initialized: boolean = false;
public static get registered() {
// lazy load the font registration
if (!Fonts._registered) {
// lazy load the fonts
Fonts._registered = Fonts.init();
} else if (!Fonts._initialized) {
// case when host app register fonts before they are lazy loaded
// don't override whatever has been previously registered
Fonts._registered = new Map([
...Fonts.init().entries(),
...Fonts._registered.entries(),
]);
}
return Fonts._registered;
@@ -72,6 +59,17 @@ export class Fonts {
private readonly scene: Scene;
public get sceneFamilies() {
return Array.from(
this.scene.getNonDeletedElements().reduce((families, element) => {
if (isTextElement(element)) {
families.add(element.fontFamily);
}
return families;
}, new Set<number>()),
);
}
constructor({ scene }: { scene: Scene }) {
this.scene = scene;
}
@@ -121,52 +119,21 @@ export class Fonts {
}
};
/**
* Load font faces for a given scene and trigger scene update.
*/
public loadSceneFonts = async (): Promise<FontFace[]> => {
const sceneFamilies = this.getSceneFontFamilies();
const loaded = await Fonts.loadFontFaces(sceneFamilies);
this.onLoaded(loaded);
return loaded;
};
/**
* Gets all the font families for the given scene.
*/
public getSceneFontFamilies = () => {
return Fonts.getFontFamilies(this.scene.getNonDeletedElements());
};
/**
* Load font faces for passed elements - use when the scene is unavailable (i.e. export).
*/
public static loadFontsForElements = async (
elements: readonly ExcalidrawElement[],
): Promise<FontFace[]> => {
const fontFamilies = Fonts.getFontFamilies(elements);
return await Fonts.loadFontFaces(fontFamilies);
};
private static async loadFontFaces(
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
) {
// add all registered font faces into the `document.fonts` (if not added already)
for (const { fonts, metadata } of Fonts.registered.values()) {
// skip registering font faces for local fonts (i.e. Helvetica)
if (metadata.local) {
continue;
}
for (const { fontFace } of fonts) {
if (!window.document.fonts.has(fontFace)) {
public load = async () => {
// Add all registered font faces into the `document.fonts` (if not added already)
for (const { fontFaces } of Fonts.registered.values()) {
for (const { fontFace, url } of fontFaces) {
if (
url.protocol !== LOCAL_FONT_PROTOCOL &&
!window.document.fonts.has(fontFace)
) {
window.document.fonts.add(fontFace);
}
}
}
const loadedFontFaces = await Promise.all(
fontFamilies.map(async (fontFamily) => {
const loaded = await Promise.all(
this.sceneFamilies.map(async (fontFamily) => {
const fontString = getFontString({
fontFamily,
fontSize: 16,
@@ -181,10 +148,8 @@ export class Fonts {
} catch (e) {
// don't let it all fail if just one font fails to load
console.error(
`Failed to load font "${fontString}" from urls "${Fonts.registered
.get(fontFamily)
?.fonts.map((x) => x.urls)}"`,
e,
`Failed to load font: "${fontString}" with error "${e}", given the following registered font:`,
JSON.stringify(Fonts.registered.get(fontFamily), undefined, 2),
);
}
}
@@ -193,8 +158,8 @@ export class Fonts {
}),
);
return loadedFontFaces.flat().filter(Boolean) as FontFace[];
}
this.onLoaded(loaded.flat().filter(Boolean) as FontFace[]);
};
/**
* WARN: should be called just once on init, even across multiple instances.
@@ -203,11 +168,10 @@ export class Fonts {
const fonts = {
registered: new Map<
ValueOf<typeof FONT_FAMILY>,
{ metadata: FontMetadata; fonts: Font[] }
{ metadata: FontMetadata; fontFaces: Font[] }
>(),
};
// TODO: let's tweak this once we know how `register` will be exposed as part of the custom fonts API
const _register = register.bind(fonts);
_register("Virgil", FONT_METADATA[FONT_FAMILY.Virgil], {
@@ -272,23 +236,8 @@ export class Fonts {
},
);
Fonts._initialized = true;
return fonts.registered;
}
private static getFontFamilies(
elements: ReadonlyArray<ExcalidrawElement>,
): Array<ExcalidrawTextElement["fontFamily"]> {
return Array.from(
elements.reduce((families, element) => {
if (isTextElement(element)) {
families.add(element.fontFamily);
}
return families;
}, new Set<number>()),
);
}
}
/**
@@ -304,7 +253,7 @@ function register(
| {
registered: Map<
ValueOf<typeof FONT_FAMILY>,
{ metadata: FontMetadata; fonts: Font[] }
{ metadata: FontMetadata; fontFaces: Font[] }
>;
},
family: string,
@@ -318,7 +267,7 @@ function register(
if (!registeredFamily) {
this.registered.set(familyId, {
metadata,
fonts: params.map(
fontFaces: params.map(
({ uri, descriptors }) => new ExcalidrawFont(family, uri, descriptors),
),
});
-3
View File
@@ -27,8 +27,6 @@ export interface FontMetadata {
deprecated?: true;
/** flag to indicate a server-side only font */
serverSide?: true;
/** flag to indiccate a local-only font */
local?: true;
}
export const FONT_METADATA: Record<number, FontMetadata> = {
@@ -87,7 +85,6 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
},
icon: FontFamilyNormalIcon,
deprecated: true,
local: true,
},
[FONT_FAMILY.Cascadia]: {
metrics: {
+4 -83
View File
@@ -6,9 +6,6 @@ import type {
OrderedExcalidrawElement,
} from "./element/types";
import { InvalidFractionalIndexError } from "./errors";
import { hasBoundTextElement } from "./element/typeChecks";
import { getBoundTextElement } from "./element/textElement";
import { arrayToMap } from "./utils";
/**
* Envisioned relation between array order and fractional indices:
@@ -33,84 +30,17 @@ import { arrayToMap } from "./utils";
* @throws `InvalidFractionalIndexError` if invalid index is detected.
*/
export const validateFractionalIndices = (
elements: readonly ExcalidrawElement[],
{
shouldThrow = false,
includeBoundTextValidation = false,
ignoreLogs,
reconciliationContext,
}: {
shouldThrow: boolean;
includeBoundTextValidation: boolean;
ignoreLogs?: true;
reconciliationContext?: {
localElements: ReadonlyArray<ExcalidrawElement>;
remoteElements: ReadonlyArray<ExcalidrawElement>;
};
},
indices: (ExcalidrawElement["index"] | undefined)[],
) => {
const errorMessages = [];
const stringifyElement = (element: ExcalidrawElement | void) =>
`${element?.index}:${element?.id}:${element?.type}:${element?.isDeleted}:${element?.version}:${element?.versionNonce}`;
const indices = elements.map((x) => x.index);
for (const [i, index] of indices.entries()) {
const predecessorIndex = indices[i - 1];
const successorIndex = indices[i + 1];
if (!isValidFractionalIndex(index, predecessorIndex, successorIndex)) {
errorMessages.push(
`Fractional indices invariant has been compromised: "${stringifyElement(
elements[i - 1],
)}", "${stringifyElement(elements[i])}", "${stringifyElement(
elements[i + 1],
)}"`,
throw new InvalidFractionalIndexError(
`Fractional indices invariant for element has been compromised - ["${predecessorIndex}", "${index}", "${successorIndex}"] [predecessor, current, successor]`,
);
}
// disabled by default, as we don't fix it
if (includeBoundTextValidation && hasBoundTextElement(elements[i])) {
const container = elements[i];
const text = getBoundTextElement(container, arrayToMap(elements));
if (text && text.index! <= container.index!) {
errorMessages.push(
`Fractional indices invariant for bound elements has been compromised: "${stringifyElement(
text,
)}", "${stringifyElement(container)}"`,
);
}
}
}
if (errorMessages.length) {
const error = new InvalidFractionalIndexError();
const additionalContext = [];
if (reconciliationContext) {
additionalContext.push("Additional reconciliation context:");
additionalContext.push(
reconciliationContext.localElements.map((x) => stringifyElement(x)),
);
additionalContext.push(
reconciliationContext.remoteElements.map((x) => stringifyElement(x)),
);
}
if (!ignoreLogs) {
// report just once and with the stacktrace
console.error(
errorMessages.join("\n\n"),
error.stack,
elements.map((x) => stringifyElement(x)),
...additionalContext,
);
}
if (shouldThrow) {
// if enabled, gather all the errors first, throw once
throw error;
}
}
};
@@ -153,19 +83,10 @@ export const syncMovedIndices = (
// try generatating indices, throws on invalid movedElements
const elementsUpdates = generateIndices(elements, indicesGroups);
const elementsCandidates = elements.map((x) =>
elementsUpdates.has(x) ? { ...x, ...elementsUpdates.get(x) } : x,
);
// ensure next indices are valid before mutation, throws on invalid ones
validateFractionalIndices(
elementsCandidates,
// we don't autofix invalid bound text indices, hence don't include it in the validation
{
includeBoundTextValidation: false,
shouldThrow: true,
ignoreLogs: true,
},
elements.map((x) => elementsUpdates.get(x)?.index || x.index),
);
// split mutation so we don't end up in an incosistent state
+36 -39
View File
@@ -1,4 +1,3 @@
import React from "react";
import type { ExcalidrawElement } from "./element/types";
import { convertToExcalidrawElements, Excalidraw } from "./index";
import { API } from "./tests/helpers/api";
@@ -123,7 +122,7 @@ describe("adding elements to frames", () => {
) => {
describe.skip("when frame is in a layer below", async () => {
it("should add an element", async () => {
API.setElements([frame, rect2]);
h.elements = [frame, rect2];
func(frame, rect2);
@@ -132,7 +131,7 @@ describe("adding elements to frames", () => {
});
it("should add elements", async () => {
API.setElements([frame, rect2, rect3]);
h.elements = [frame, rect2, rect3];
func(frame, rect2);
func(frame, rect3);
@@ -143,7 +142,7 @@ describe("adding elements to frames", () => {
});
it("should add elements when there are other other elements in between", async () => {
API.setElements([frame, rect1, rect2, rect4, rect3]);
h.elements = [frame, rect1, rect2, rect4, rect3];
func(frame, rect2);
func(frame, rect3);
@@ -154,7 +153,7 @@ describe("adding elements to frames", () => {
});
it("should add elements when there are other elements in between and the order is reversed", async () => {
API.setElements([frame, rect3, rect4, rect2, rect1]);
h.elements = [frame, rect3, rect4, rect2, rect1];
func(frame, rect2);
func(frame, rect3);
@@ -167,7 +166,7 @@ describe("adding elements to frames", () => {
describe.skip("when frame is in a layer above", async () => {
it("should add an element", async () => {
API.setElements([rect2, frame]);
h.elements = [rect2, frame];
func(frame, rect2);
@@ -176,7 +175,7 @@ describe("adding elements to frames", () => {
});
it("should add elements", async () => {
API.setElements([rect2, rect3, frame]);
h.elements = [rect2, rect3, frame];
func(frame, rect2);
func(frame, rect3);
@@ -187,7 +186,7 @@ describe("adding elements to frames", () => {
});
it("should add elements when there are other other elements in between", async () => {
API.setElements([rect1, rect2, rect4, rect3, frame]);
h.elements = [rect1, rect2, rect4, rect3, frame];
func(frame, rect2);
func(frame, rect3);
@@ -198,7 +197,7 @@ describe("adding elements to frames", () => {
});
it("should add elements when there are other elements in between and the order is reversed", async () => {
API.setElements([rect3, rect4, rect2, rect1, frame]);
h.elements = [rect3, rect4, rect2, rect1, frame];
func(frame, rect2);
func(frame, rect3);
@@ -211,7 +210,7 @@ describe("adding elements to frames", () => {
describe("when frame is in an inner layer", async () => {
it.skip("should add elements", async () => {
API.setElements([rect2, frame, rect3]);
h.elements = [rect2, frame, rect3];
func(frame, rect2);
func(frame, rect3);
@@ -222,7 +221,7 @@ describe("adding elements to frames", () => {
});
it.skip("should add elements when there are other other elements in between", async () => {
API.setElements([rect2, rect1, frame, rect4, rect3]);
h.elements = [rect2, rect1, frame, rect4, rect3];
func(frame, rect2);
func(frame, rect3);
@@ -233,7 +232,7 @@ describe("adding elements to frames", () => {
});
it.skip("should add elements when there are other elements in between and the order is reversed", async () => {
API.setElements([rect3, rect4, frame, rect2, rect1]);
h.elements = [rect3, rect4, frame, rect2, rect1];
func(frame, rect2);
func(frame, rect3);
@@ -254,22 +253,20 @@ describe("adding elements to frames", () => {
const frame = API.createElement({ type: "frame", x: 0, y: 0 });
API.setElements(
reorderElements(
[
frame,
...convertToExcalidrawElements([
{
type: containerType,
x: 100,
y: 100,
height: 10,
label: { text: "xx" },
},
]),
],
initialOrder,
),
h.elements = reorderElements(
[
frame,
...convertToExcalidrawElements([
{
type: containerType,
x: 100,
y: 100,
height: 10,
label: { text: "xx" },
},
]),
],
initialOrder,
);
assertOrder(h.elements, initialOrder);
@@ -340,7 +337,7 @@ describe("adding elements to frames", () => {
});
it.skip("should add arrow bound with text when frame is in a layer below", async () => {
API.setElements([frame, arrow, text]);
h.elements = [frame, arrow, text];
resizeFrameOverElement(frame, arrow);
@@ -350,7 +347,7 @@ describe("adding elements to frames", () => {
});
it("should add arrow bound with text when frame is in a layer above", async () => {
API.setElements([arrow, text, frame]);
h.elements = [arrow, text, frame];
resizeFrameOverElement(frame, arrow);
@@ -360,7 +357,7 @@ describe("adding elements to frames", () => {
});
it.skip("should add arrow bound with text when frame is in an inner layer", async () => {
API.setElements([arrow, frame, text]);
h.elements = [arrow, frame, text];
resizeFrameOverElement(frame, arrow);
@@ -372,7 +369,7 @@ describe("adding elements to frames", () => {
describe("resizing frame over elements but downwards", async () => {
it.skip("should add elements when frame is in a layer below", async () => {
API.setElements([frame, rect1, rect2, rect3, rect4]);
h.elements = [frame, rect1, rect2, rect3, rect4];
resizeFrameOverElement(frame, rect4);
resizeFrameOverElement(frame, rect3);
@@ -383,7 +380,7 @@ describe("adding elements to frames", () => {
});
it.skip("should add elements when frame is in a layer above", async () => {
API.setElements([rect1, rect2, rect3, rect4, frame]);
h.elements = [rect1, rect2, rect3, rect4, frame];
resizeFrameOverElement(frame, rect4);
resizeFrameOverElement(frame, rect3);
@@ -394,7 +391,7 @@ describe("adding elements to frames", () => {
});
it.skip("should add elements when frame is in an inner layer", async () => {
API.setElements([rect1, rect2, frame, rect3, rect4]);
h.elements = [rect1, rect2, frame, rect3, rect4];
resizeFrameOverElement(frame, rect4);
resizeFrameOverElement(frame, rect3);
@@ -409,7 +406,7 @@ describe("adding elements to frames", () => {
await commonTestCases(dragElementIntoFrame);
it.skip("should drag element inside, duplicate it and keep it in frame", () => {
API.setElements([frame, rect2]);
h.elements = [frame, rect2];
dragElementIntoFrame(frame, rect2);
@@ -423,7 +420,7 @@ describe("adding elements to frames", () => {
});
it.skip("should drag element inside, duplicate it and remove it from frame", () => {
API.setElements([frame, rect2]);
h.elements = [frame, rect2];
dragElementIntoFrame(frame, rect2);
@@ -493,7 +490,7 @@ describe("adding elements to frames", () => {
frameId: frame3.id,
});
API.setElements([
h.elements = [
frame1,
rectangle4,
rectangle1,
@@ -501,7 +498,7 @@ describe("adding elements to frames", () => {
frame3,
rectangle2,
frame2,
]);
];
API.setSelectedElements([rectangle2]);
@@ -544,7 +541,7 @@ describe("adding elements to frames", () => {
frameId: frame2.id,
});
API.setElements([rectangle1, rectangle2, frame1, frame2]);
h.elements = [rectangle1, rectangle2, frame1, frame2];
API.setSelectedElements([rectangle2]);
+6 -3
View File
@@ -755,12 +755,15 @@ export const isElementInFrame = (
return false;
};
export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
export const getFrameLikeTitle = (
element: ExcalidrawFrameLikeElement,
frameIdx: number,
) => {
// TODO name frames "AI" only if specific to AI frames
return element.name === null
? isFrameElement(element)
? "Frame"
: "AI Frame"
? `Frame ${frameIdx}`
: `AI Frame $${frameIdx}`
: element.name;
};
+1 -2
View File
@@ -1,10 +1,9 @@
interface Window {
ClipboardItem: any;
__EXCALIDRAW_SHA__: string | undefined;
EXCALIDRAW_ASSET_PATH: string | string[] | undefined;
EXCALIDRAW_ASSET_PATH: string | undefined;
EXCALIDRAW_EXPORT_SOURCE: string;
EXCALIDRAW_THROTTLE_RENDER: boolean | undefined;
DEBUG_FRACTIONAL_INDICES: boolean | undefined;
gtag: Function;
sa_event: Function;
fathom: { trackEvent: Function };
-5
View File
@@ -46,10 +46,6 @@
"arrowhead_triangle_outline": "Triangle (outline)",
"arrowhead_diamond": "Diamond",
"arrowhead_diamond_outline": "Diamond (outline)",
"arrowtypes": "Arrow type",
"arrowtype_sharp": "Sharp arrow",
"arrowtype_round": "Curved arrow",
"arrowtype_elbowed": "Elbow arrow",
"fontSize": "Font size",
"fontFamily": "Font family",
"addWatermark": "Add \"Made with Excalidraw\"",
@@ -299,7 +295,6 @@
"hints": {
"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.",
"freeDraw": "Click and drag, release when you're finished",
"text": "Tip: you can also add text by double-clicking anywhere with the selection tool",
"embeddable": "Click-drag to create a website embed",
+1 -45
View File
@@ -1,9 +1,4 @@
import {
isPointOnSymmetricArc,
rangeIntersection,
rangesOverlap,
rotate,
} from "./math";
import { rangeIntersection, rangesOverlap, rotate } from "./math";
describe("rotate", () => {
it("should rotate over (x2, y2) and return the rotated coordinates for (x1, y1)", () => {
@@ -58,42 +53,3 @@ describe("range intersection", () => {
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);
});
});
-184
View File
@@ -10,11 +10,9 @@ import type {
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
@@ -155,12 +153,6 @@ export const distance2d = (x1: number, y1: number, x2: number, y2: number) => {
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];
};
@@ -527,179 +519,3 @@ export const rangeIntersection = (
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);
};
-15
View File
@@ -1,15 +0,0 @@
import { isMaybeMermaidDefinition } from "./mermaid";
describe("isMaybeMermaidDefinition", () => {
it("should return true for a valid mermaid definition", () => {
expect(isMaybeMermaidDefinition("flowchart")).toBe(true);
expect(isMaybeMermaidDefinition("flowchart LR")).toBe(true);
expect(isMaybeMermaidDefinition("flowchart LR\nola")).toBe(true);
expect(isMaybeMermaidDefinition("%%{}%%flowchart")).toBe(true);
expect(isMaybeMermaidDefinition("%%{}%% flowchart")).toBe(true);
expect(isMaybeMermaidDefinition("graphs")).toBe(false);
expect(isMaybeMermaidDefinition("this flowchart")).toBe(false);
expect(isMaybeMermaidDefinition("this\nflowchart")).toBe(false);
});
});
+3 -4
View File
@@ -2,7 +2,6 @@
export const isMaybeMermaidDefinition = (text: string) => {
const chartTypes = [
"flowchart",
"graph",
"sequenceDiagram",
"classDiagram",
"stateDiagram",
@@ -24,9 +23,9 @@ export const isMaybeMermaidDefinition = (text: string) => {
];
const re = new RegExp(
`^(?:%%{.*?}%%[\\s\\n]*)?\\b(?:${chartTypes
.map((x) => `\\s*${x}(-beta)?`)
.join("|")})\\b`,
`^(?:%%{.*?}%%[\\s\\n]*)?\\b${chartTypes
.map((x) => `${x}(-beta)?`)
.join("|")}\\b`,
);
return re.test(text.trim());
+11 -11
View File
@@ -67,7 +67,6 @@
"canvas-roundrect-polyfill": "0.0.1",
"clsx": "1.1.1",
"cross-env": "7.0.3",
"fonteditor-core": "2.4.1",
"fractional-indexing": "3.2.0",
"fuzzy": "0.1.3",
"image-blob-reduce": "3.0.1",
@@ -88,18 +87,17 @@
"tunnel-rat": "0.1.2"
},
"devDependencies": {
"@babel/core": "7.24.5",
"@babel/plugin-transform-arrow-functions": "7.24.1",
"@babel/plugin-transform-async-to-generator": "7.24.1",
"@babel/plugin-transform-runtime": "7.24.3",
"@babel/plugin-transform-typescript": "7.24.5",
"@babel/preset-env": "7.24.5",
"@babel/preset-react": "7.24.1",
"@babel/preset-typescript": "7.24.1",
"@babel/core": "7.18.9",
"@babel/plugin-transform-arrow-functions": "7.18.6",
"@babel/plugin-transform-async-to-generator": "7.18.6",
"@babel/plugin-transform-runtime": "7.18.9",
"@babel/plugin-transform-typescript": "7.18.8",
"@babel/preset-env": "7.18.6",
"@babel/preset-react": "7.18.6",
"@babel/preset-typescript": "7.18.6",
"@size-limit/preset-big-lib": "9.0.0",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "5.16.2",
"@testing-library/react": "16.0.0",
"@testing-library/react": "12.1.5",
"@types/pako": "1.0.3",
"@types/pica": "5.1.3",
"@types/resize-observer-browser": "0.1.7",
@@ -117,6 +115,8 @@
"import-meta-loader": "1.1.0",
"mini-css-extract-plugin": "2.6.1",
"postcss-loader": "7.0.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"sass-loader": "13.0.2",
"size-limit": "9.0.0",
"style-loader": "3.3.3",
@@ -48,8 +48,6 @@ import {
} from "./helpers";
import oc from "open-color";
import {
isArrowElement,
isElbowArrow,
isFrameLikeElement,
isLinearElement,
isTextElement,
@@ -69,7 +67,6 @@ import type {
InteractiveSceneRenderConfig,
RenderableElementsMap,
} from "../scene/types";
import { getCornerRadius } from "../math";
const renderLinearElementPointHighlight = (
context: CanvasRenderingContext2D,
@@ -215,18 +212,13 @@ const renderBindingHighlightForBindableElement = (
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const width = x2 - x1;
const height = y2 - y1;
const thickness = 10;
const threshold = maxBindingGap(element, width, height);
// So that we don't overlap the element itself
const strokeOffset = 4;
context.strokeStyle = "rgba(0,0,0,.05)";
context.lineWidth = thickness - strokeOffset;
const padding = strokeOffset / 2 + thickness / 2;
const radius = getCornerRadius(
Math.min(element.width, element.height),
element,
);
context.lineWidth = threshold - strokeOffset;
const padding = strokeOffset / 2 + threshold / 2;
switch (element.type) {
case "rectangle":
@@ -245,8 +237,6 @@ const renderBindingHighlightForBindableElement = (
x1 + width / 2,
y1 + height / 2,
element.angle,
undefined,
radius,
);
break;
case "diamond":
@@ -484,10 +474,6 @@ const renderLinearPointHandles = (
? POINT_HANDLE_SIZE
: POINT_HANDLE_SIZE / 2;
points.forEach((point, idx) => {
if (isElbowArrow(element) && idx !== 0 && idx !== points.length - 1) {
return;
}
const isSelected =
!!appState.editingLinearElement?.selectedPointsIndices?.includes(idx);
@@ -741,13 +727,7 @@ const _renderInteractiveScene = ({
if (
appState.selectedLinearElement &&
appState.selectedLinearElement.hoverPointIndex >= 0 &&
!(
isElbowArrow(selectedElements[0]) &&
appState.selectedLinearElement.hoverPointIndex > 0 &&
appState.selectedLinearElement.hoverPointIndex <
selectedElements[0].points.length - 1
)
appState.selectedLinearElement.hoverPointIndex >= 0
) {
renderLinearElementPointHighlight(context, appState, elementsMap);
}
@@ -791,39 +771,27 @@ const _renderInteractiveScene = ({
for (const element of elementsMap.values()) {
const selectionColors = [];
// local user
if (
locallySelectedIds.has(element.id) &&
!isSelectedViaGroup(appState, element)
) {
selectionColors.push(selectionColor);
}
// remote users
const remoteClients = renderConfig.remoteSelectedElementIds.get(
element.id,
);
if (
!(
// Elbow arrow elements cannot be selected when bound on either end
(
isSingleLinearElementSelected &&
isArrowElement(element) &&
isElbowArrow(element) &&
(element.startBinding || element.endBinding)
)
)
) {
// local user
if (
locallySelectedIds.has(element.id) &&
!isSelectedViaGroup(appState, element)
) {
selectionColors.push(selectionColor);
}
// remote users
if (remoteClients) {
selectionColors.push(
...remoteClients.map((socketId) => {
const background = getClientColor(
socketId,
appState.collaborators.get(socketId),
);
return background;
}),
);
}
if (remoteClients) {
selectionColors.push(
...remoteClients.map((socketId) => {
const background = getClientColor(
socketId,
appState.collaborators.get(socketId),
);
return background;
}),
);
}
if (selectionColors.length) {
+4 -20
View File
@@ -1,4 +1,3 @@
import throttle from "lodash.throttle";
import type {
ExcalidrawElement,
NonDeletedExcalidrawElement,
@@ -51,24 +50,6 @@ const getNonDeletedElements = <T extends ExcalidrawElement>(
return { elementsMap, elements };
};
const validateIndicesThrottled = throttle(
(elements: readonly ExcalidrawElement[]) => {
if (
import.meta.env.DEV ||
import.meta.env.MODE === ENV.TEST ||
window?.DEBUG_FRACTIONAL_INDICES
) {
validateFractionalIndices(elements, {
// throw only in dev & test, to remain functional on `DEBUG_FRACTIONAL_INDICES`
shouldThrow: import.meta.env.DEV || import.meta.env.MODE === ENV.TEST,
includeBoundTextValidation: true,
});
}
},
1000 * 60,
{ leading: true, trailing: false },
);
const hashSelectionOpts = (
opts: Parameters<InstanceType<typeof Scene>["getSelectedElements"]>[0],
) => {
@@ -293,7 +274,10 @@ class Scene {
: Array.from(nextElements.values());
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
validateIndicesThrottled(_nextElements);
if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) {
// throw on invalid indices in test / dev to potentially detect cases were we forgot to sync moved elements
validateFractionalIndices(_nextElements.map((x) => x.index));
}
this.elements = syncInvalidIndices(_nextElements);
this.elementsMap.clear();
+4 -69
View File
@@ -9,13 +9,12 @@ import type {
ExcalidrawLinearElement,
Arrowhead,
} from "../element/types";
import { isPathALoop, getCornerRadius, distanceSq2d } from "../math";
import { isPathALoop, getCornerRadius } from "../math";
import { generateFreeDrawShape } from "../renderer/renderElement";
import { isTransparent, assertNever } from "../utils";
import { simplify } from "points-on-curve";
import { ROUGHNESS } from "../constants";
import {
isElbowArrow,
isEmbeddableElement,
isIframeElement,
isIframeLikeElement,
@@ -401,16 +400,9 @@ export const _generateElementShape = (
// initial position to it
const points = element.points.length ? element.points : [[0, 0]];
if (isElbowArrow(element)) {
shape = [
generator.path(
generateElbowArrowShape(points as [number, number][], 16),
generateRoughOptions(element, true),
),
];
} else if (!element.roundness) {
// curve is always the first element
// this simplifies finding the curve for an element
// curve is always the first element
// this simplifies finding the curve for an element
if (!element.roundness) {
if (options.fill) {
shape = [generator.polygon(points as [number, number][], options)];
} else {
@@ -490,60 +482,3 @@ export const _generateElementShape = (
}
}
};
const generateElbowArrowShape = (
points: [number, number][],
radius: number,
) => {
const subpoints = [] as [number, number][];
for (let i = 1; i < points.length - 1; i += 1) {
const prev = points[i - 1];
const next = points[i + 1];
const corner = Math.min(
radius,
Math.sqrt(distanceSq2d(points[i], next)) / 2,
Math.sqrt(distanceSq2d(points[i], prev)) / 2,
);
if (prev[0] < points[i][0] && prev[1] === points[i][1]) {
// LEFT
subpoints.push([points[i][0] - corner, points[i][1]]);
} else if (prev[0] === points[i][0] && prev[1] < points[i][1]) {
// UP
subpoints.push([points[i][0], points[i][1] - corner]);
} else if (prev[0] > points[i][0] && prev[1] === points[i][1]) {
// RIGHT
subpoints.push([points[i][0] + corner, points[i][1]]);
} else {
subpoints.push([points[i][0], points[i][1] + corner]);
}
subpoints.push(points[i] as [number, number]);
if (next[0] < points[i][0] && next[1] === points[i][1]) {
// LEFT
subpoints.push([points[i][0] - corner, points[i][1]]);
} else if (next[0] === points[i][0] && next[1] < points[i][1]) {
// UP
subpoints.push([points[i][0], points[i][1] - corner]);
} else if (next[0] > points[i][0] && next[1] === points[i][1]) {
// RIGHT
subpoints.push([points[i][0] + corner, points[i][1]]);
} else {
subpoints.push([points[i][0], points[i][1] + corner]);
}
}
const d = [`M ${points[0][0]} ${points[0][1]}`];
for (let i = 0; i < subpoints.length; i += 3) {
d.push(`L ${subpoints[i][0]} ${subpoints[i][1]}`);
d.push(
`Q ${subpoints[i + 1][0]} ${subpoints[i + 1][1]}, ${
subpoints[i + 2][0]
} ${subpoints[i + 2][1]}`,
);
}
d.push(`L ${points[points.length - 1][0]} ${points[points.length - 1][1]}`);
return d.join(" ");
};
+1 -2
View File
@@ -40,12 +40,11 @@ export const canChangeRoundness = (type: ElementOrToolType) =>
type === "rectangle" ||
type === "iframe" ||
type === "embeddable" ||
type === "arrow" ||
type === "line" ||
type === "diamond" ||
type === "image";
export const toolIsArrow = (type: ElementOrToolType) => type === "arrow";
export const canHaveArrowheads = (type: ElementOrToolType) => type === "arrow";
export const getElementAtPosition = (
+62 -63
View File
@@ -34,11 +34,16 @@ import {
import { newTextElement } from "../element";
import { type Mutable } from "../utility-types";
import { newElementWith } from "../element/mutateElement";
import { isFrameLikeElement, isTextElement } from "../element/typeChecks";
import {
isFrameElement,
isFrameLikeElement,
isTextElement,
} from "../element/typeChecks";
import type { RenderableElementsMap } from "./types";
import { syncInvalidIndices } from "../fractionalIndex";
import { renderStaticScene } from "../renderer/staticScene";
import { Fonts } from "../fonts";
import { LOCAL_FONT_PROTOCOL } from "../fonts/metadata";
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
@@ -84,8 +89,15 @@ const addFrameLabelsAsTextElements = (
opts: Pick<AppState, "exportWithDarkMode">,
) => {
const nextElements: NonDeletedExcalidrawElement[] = [];
let frameIndex = 0;
let magicFrameIndex = 0;
for (const element of elements) {
if (isFrameLikeElement(element)) {
if (isFrameElement(element)) {
frameIndex++;
} else {
magicFrameIndex++;
}
let textElement: Mutable<ExcalidrawTextElement> = newTextElement({
x: element.x,
y: element.y - FRAME_STYLE.nameOffsetY,
@@ -96,7 +108,10 @@ const addFrameLabelsAsTextElements = (
strokeColor: opts.exportWithDarkMode
? FRAME_STYLE.nameColorDarkTheme
: FRAME_STYLE.nameColorLightTheme,
text: getFrameLikeTitle(element),
text: getFrameLikeTitle(
element,
isFrameElement(element) ? frameIndex : magicFrameIndex,
),
});
textElement.y -= textElement.height;
@@ -173,13 +188,7 @@ export const exportToCanvas = async (
canvas.height = height * appState.exportScale;
return { canvas, scale: appState.exportScale };
},
loadFonts: () => Promise<void> = async () => {
await Fonts.loadFontsForElements(elements);
},
) => {
// load font faces before continuing, by default leverages browsers' [FontFace API](https://developer.mozilla.org/en-US/docs/Web/API/FontFace)
await loadFonts();
const frameRendering = getFrameRenderingConfig(
exportingFrame ?? null,
appState.frameRendering ?? null,
@@ -354,14 +363,57 @@ export const exportToSvg = async (
</clipPath>`;
}
const fontFaces = opts?.skipInliningFonts ? [] : await getFontFaces(elements);
const fontFamilies = elements.reduce((acc, element) => {
if (isTextElement(element)) {
acc.add(element.fontFamily);
}
return acc;
}, new Set<number>());
const fontFaces = opts?.skipInliningFonts
? []
: await Promise.all(
Array.from(fontFamilies).map(async (x) => {
const { fontFaces } = Fonts.registered.get(x) ?? {};
if (!Array.isArray(fontFaces)) {
console.error(
`Couldn't find registered font-faces for font-family "${x}"`,
Fonts.registered,
);
return;
}
return Promise.all(
fontFaces
.filter((font) => font.url.protocol !== LOCAL_FONT_PROTOCOL)
.map(async (font) => {
try {
const content = await font.getContent();
return `@font-face {
font-family: ${font.fontFace.family};
src: url(${content});
}`;
} catch (e) {
console.error(
`Skipped inlining font with URL "${font.url.toString()}"`,
e,
);
return "";
}
}),
);
}),
);
svgRoot.innerHTML = `
${SVG_EXPORT_TAG}
${metadata}
<defs>
<style class="style-fonts">
${fontFaces.join("\n")}
${fontFaces.flat().filter(Boolean).join("\n")}
</style>
${exportingFrameClipPath}
</defs>
@@ -432,56 +484,3 @@ export const getExportSize = (
return [width, height];
};
const getFontFaces = async (
elements: readonly ExcalidrawElement[],
): Promise<string[]> => {
const fontFamilies = new Set<number>();
const codePoints = new Set<number>();
for (const element of elements) {
if (!isTextElement(element)) {
continue;
}
fontFamilies.add(element.fontFamily);
for (const codePoint of Array.from(element.originalText, (u) =>
u.codePointAt(0),
)) {
if (codePoint) {
codePoints.add(codePoint);
}
}
}
const fontFaces = await Promise.all(
Array.from(fontFamilies).map(async (x) => {
const { fonts, metadata } = Fonts.registered.get(x) ?? {};
if (!Array.isArray(fonts)) {
console.error(
`Couldn't find registered fonts for font-family "${x}"`,
Fonts.registered,
);
return [];
}
if (metadata?.local) {
// don't inline local fonts
return [];
}
return Promise.all(
fonts.map(
async (font) => `@font-face {
font-family: ${font.fontFace.family};
src: url(${await font.getContent(codePoints)});
}`,
),
);
}),
);
return fontFaces.flat();
};
-30
View File
@@ -20,8 +20,6 @@ import {
} from "./components/icons";
import { getElementAbsoluteCoords } from "./element";
import { shouldTestInside } from "./element/collision";
import { LinearElementEditor } from "./element/linearElementEditor";
import { getBoundTextElement } from "./element/textElement";
import type { ElementsMap, ExcalidrawElement } from "./element/types";
import { KEYS } from "./keys";
import { ShapeCache } from "./scene/ShapeCache";
@@ -161,31 +159,3 @@ export const getElementShape = (
}
}
};
export const getBoundTextShape = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
): GeometricShape | null => {
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
if (element.type === "arrow") {
return getElementShape(
{
...boundTextElement,
// arrow's bound text accurate position is not stored in the element's property
// but rather calculated and returned from the following static method
...LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElement,
elementsMap,
),
},
elementsMap,
);
}
return getElementShape(boundTextElement, elementsMap);
}
return null;
};
+7 -5
View File
@@ -1209,14 +1209,16 @@ export const snapResizingElements = (
};
export const snapNewElement = (
newElement: ExcalidrawElement,
draggingElement: ExcalidrawElement,
appState: AppState,
event: KeyboardModifiersObject,
origin: Vector2D,
dragOffset: Vector2D,
elementsMap: ElementsMap,
) => {
if (!isSnappingEnabled({ event, selectedElements: [newElement], appState })) {
if (
!isSnappingEnabled({ event, selectedElements: [draggingElement], appState })
) {
return {
snapOffset: { x: 0, y: 0 },
snapLines: [],
@@ -1238,7 +1240,7 @@ export const snapNewElement = (
const nearestSnapsY: Snaps = [];
getPointSnaps(
[newElement],
[draggingElement],
selectionSnapPoints,
appState,
event,
@@ -1257,13 +1259,13 @@ export const snapNewElement = (
nearestSnapsX.length = 0;
nearestSnapsY.length = 0;
const corners = getElementsCorners([newElement], elementsMap, {
const corners = getElementsCorners([draggingElement], elementsMap, {
boundingBoxCorners: true,
omitCenter: true,
});
getPointSnaps(
[newElement],
[draggingElement],
corners,
appState,
event,
-1
View File
@@ -1,4 +1,3 @@
import React from "react";
import ReactDOM from "react-dom";
import * as StaticScene from "../renderer/staticScene";
import { reseed } from "../random";
@@ -1,5 +1,4 @@
import React from "react";
import { render, waitFor } from "./test-utils";
import { act, render, waitFor } from "./test-utils";
import { Excalidraw } from "../index";
import { expect } from "vitest";
import { getTextEditor, updateTextEditor } from "./queries/dom";
@@ -90,7 +89,7 @@ describe("Test <MermaidToExcalidraw/>", () => {
it("should open mermaid popup when active tool is mermaid", async () => {
const dialog = document.querySelector(".ttd-dialog")!;
await waitFor(() => expect(dialog.querySelector("canvas")).not.toBeNull());
await waitFor(() => dialog.querySelector("canvas"));
expect(dialog.outerHTML).toMatchSnapshot();
});
@@ -104,9 +103,19 @@ describe("Test <MermaidToExcalidraw/>", () => {
expect(dialog.querySelector('[data-testid="mermaid-error"]')).toBeNull();
expect(editor.textContent).toMatchSnapshot();
expect(editor.textContent).toMatchInlineSnapshot(`
"flowchart TD
A[Christmas] -->|Get money| B(Go shopping)
B --> C{Let me think}
C -->|One| D[Laptop]
C -->|Two| E[iPhone]
C -->|Three| F[Car]"
`);
updateTextEditor(editor, "flowchart TD1");
await act(async () => {
updateTextEditor(editor, "flowchart TD1");
await new Promise((cb) => setTimeout(cb, 0));
});
editor = await getTextEditor(selector, false);
expect(editor.textContent).toBe("flowchart TD1");
@@ -1,19 +1,10 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test <MermaidToExcalidraw/> > should open mermaid popup when active tool is mermaid 1`] = `
"<div class="Modal Dialog ttd-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title" data-prevent-outside-click="true"><div class="Modal__background"></div><div class="Modal__content" style="--max-width: 1200px;" tabindex="0"><div class="Island"><div class="Dialog__content"><div dir="ltr" data-orientation="horizontal" class="ttd-dialog-tabs-root"><p class="dialog-mermaid-title">Mermaid to Excalidraw</p><div data-state="active" data-orientation="horizontal" role="tabpanel" aria-labelledby="radix-:r0:-trigger-mermaid" id="radix-:r0:-content-mermaid" tabindex="0" class="ttd-dialog-content" style=""><div class="ttd-dialog-desc">Currently only <a href="https://mermaid.js.org/syntax/flowchart.html">Flowchart</a>,<a href="https://mermaid.js.org/syntax/sequenceDiagram.html"> Sequence, </a> and <a href="https://mermaid.js.org/syntax/classDiagram.html">Class </a>Diagrams are supported. The other types will be rendered as image in Excalidraw.</div><div class="ttd-dialog-panels"><div class="ttd-dialog-panel"><div class="ttd-dialog-panel__header"><label>Mermaid Syntax</label></div><textarea class="ttd-dialog-input" placeholder="Write Mermaid diagram defintion here...">flowchart TD
"<div class="Modal Dialog ttd-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title" data-prevent-outside-click="true"><div class="Modal__background"></div><div class="Modal__content" style="--max-width: 1200px;" tabindex="0"><div class="Island"><div class="Dialog__content"><div dir="ltr" data-orientation="horizontal" class="ttd-dialog-tabs-root"><p class="dialog-mermaid-title">Mermaid to Excalidraw</p><div data-state="active" data-orientation="horizontal" role="tabpanel" aria-labelledby="radix-:r0:-trigger-mermaid" id="radix-:r0:-content-mermaid" tabindex="0" class="ttd-dialog-content" style="animation-duration: 0s;"><div class="ttd-dialog-desc">Currently only <a href="https://mermaid.js.org/syntax/flowchart.html">Flowchart</a>,<a href="https://mermaid.js.org/syntax/sequenceDiagram.html"> Sequence, </a> and <a href="https://mermaid.js.org/syntax/classDiagram.html">Class </a>Diagrams are supported. The other types will be rendered as image in Excalidraw.</div><div class="ttd-dialog-panels"><div class="ttd-dialog-panel"><div class="ttd-dialog-panel__header"><label>Mermaid Syntax</label></div><textarea class="ttd-dialog-input" placeholder="Write Mermaid diagram defintion here...">flowchart TD
A[Christmas] --&gt;|Get money| B(Go shopping)
B --&gt; C{Let me think}
C --&gt;|One| D[Laptop]
C --&gt;|Two| E[iPhone]
C --&gt;|Three| F[Car]</textarea><div class="ttd-dialog-panel-button-container invisible" style="display: flex; align-items: center;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class=""></div></button></div></div><div class="ttd-dialog-panel"><div class="ttd-dialog-panel__header"><label>Preview</label></div><div class="ttd-dialog-output-wrapper"><div style="opacity: 1;" class="ttd-dialog-output-canvas-container"><canvas width="89" height="158" dir="ltr"></canvas></div></div><div class="ttd-dialog-panel-button-container" style="display: flex; align-items: center;"><button type="button" class="excalidraw-button ttd-dialog-panel-button"><div class="">Insert<span><svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 20 20" class="" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><g stroke-width="1.25"><path d="M4.16602 10H15.8327"></path><path d="M12.5 13.3333L15.8333 10"></path><path d="M12.5 6.66666L15.8333 9.99999"></path></g></svg></span></div></button><div class="ttd-dialog-submit-shortcut"><div class="ttd-dialog-submit-shortcut__key">Ctrl</div><div class="ttd-dialog-submit-shortcut__key">Enter</div></div></div></div></div></div></div></div></div></div></div>"
`;
exports[`Test <MermaidToExcalidraw/> > should show error in preview when mermaid library throws error 1`] = `
"flowchart TD
A[Christmas] -->|Get money| B(Go shopping)
B --> C{Let me think}
C -->|One| D[Laptop]
C -->|Two| E[iPhone]
C -->|Three| F[Car]"
`;
@@ -796,7 +796,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
},
"currentChartType": "bar",
"currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
@@ -812,6 +811,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"draggingElement": null,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
@@ -840,7 +840,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"newElement": null,
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
@@ -999,7 +998,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"contextMenu": null,
"currentChartType": "bar",
"currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
@@ -1015,6 +1013,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"draggingElement": null,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
@@ -1043,7 +1042,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"newElement": null,
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
@@ -1212,7 +1210,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"contextMenu": null,
"currentChartType": "bar",
"currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
@@ -1228,6 +1225,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"draggingElement": null,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
@@ -1256,7 +1254,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"newElement": null,
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
@@ -1540,7 +1537,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"contextMenu": null,
"currentChartType": "bar",
"currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
@@ -1556,6 +1552,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"draggingElement": null,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
@@ -1584,7 +1581,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"newElement": null,
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
@@ -1868,7 +1864,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"contextMenu": null,
"currentChartType": "bar",
"currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
@@ -1884,6 +1879,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"draggingElement": null,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
@@ -1912,7 +1908,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"newElement": null,
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
@@ -2081,7 +2076,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"contextMenu": null,
"currentChartType": "bar",
"currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
@@ -2097,6 +2091,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"draggingElement": null,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
@@ -2125,7 +2120,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"newElement": null,
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
@@ -2318,7 +2312,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"contextMenu": null,
"currentChartType": "bar",
"currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
@@ -2334,6 +2327,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"draggingElement": null,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
@@ -2362,7 +2356,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"newElement": null,
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
@@ -2616,7 +2609,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"contextMenu": null,
"currentChartType": "bar",
"currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
@@ -2632,6 +2624,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"draggingElement": null,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
@@ -2660,7 +2653,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"newElement": null,
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
@@ -2982,7 +2974,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"contextMenu": null,
"currentChartType": "bar",
"currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "#a5d8ff",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "cross-hatch",
@@ -2998,6 +2989,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"draggingElement": null,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
@@ -3026,7 +3018,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"newElement": null,
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
@@ -3454,7 +3445,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"contextMenu": null,
"currentChartType": "bar",
"currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
@@ -3470,6 +3460,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"draggingElement": null,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
@@ -3498,7 +3489,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"newElement": null,
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
@@ -3774,7 +3764,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"contextMenu": null,
"currentChartType": "bar",
"currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
@@ -3790,6 +3779,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"draggingElement": null,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
@@ -3818,7 +3808,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"newElement": null,
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
@@ -4094,7 +4083,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"contextMenu": null,
"currentChartType": "bar",
"currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
@@ -4110,6 +4098,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"draggingElement": null,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
@@ -4138,7 +4127,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"newElement": null,
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
@@ -5277,7 +5265,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
},
"currentChartType": "bar",
"currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
@@ -5293,6 +5280,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"draggingElement": null,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
@@ -5321,7 +5309,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"newElement": null,
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
@@ -6401,7 +6388,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
},
"currentChartType": "bar",
"currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
@@ -6417,6 +6403,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"draggingElement": null,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
@@ -6445,7 +6432,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"newElement": null,
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
@@ -7333,7 +7319,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
},
"currentChartType": "bar",
"currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
@@ -7349,6 +7334,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"draggingElement": null,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
@@ -7377,7 +7363,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"newElement": null,
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
@@ -8242,7 +8227,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
},
"currentChartType": "bar",
"currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
@@ -8258,6 +8242,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"draggingElement": null,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
@@ -8286,7 +8271,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"newElement": null,
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
@@ -9133,7 +9117,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
},
"currentChartType": "bar",
"currentHoveredFontFamily": null,
"currentItemArrowType": "round",
"currentItemBackgroundColor": "transparent",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
@@ -9149,6 +9132,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"draggingElement": null,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
@@ -9177,7 +9161,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"newElement": null,
"objectsSnapModeEnabled": false,
"offsetLeft": 20,
"offsetTop": 10,
@@ -8,7 +8,6 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
File diff suppressed because it is too large Load Diff
@@ -1,17 +1,5 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test Linear Elements > Test bound text element > should bind text to arrow when clicked on arrow and enter pressed 1`] = `
"Online whiteboard
collaboration made
easy"
`;
exports[`Test Linear Elements > Test bound text element > should bind text to arrow when double clicked 1`] = `
"Online whiteboard
collaboration made
easy"
`;
exports[`Test Linear Elements > Test bound text element > should match styles for text editor 1`] = `
<textarea
class="excalidraw-wysiwyg"
@@ -22,36 +10,3 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
wrap="off"
/>
`;
exports[`Test Linear Elements > Test bound text element > should resize and position the bound text and bounding box correctly when 3 pointer arrow element resized 2`] = `
"Online whiteboard
collaboration made
easy"
`;
exports[`Test Linear Elements > Test bound text element > should resize and position the bound text and bounding box correctly when 3 pointer arrow element resized 6`] = `
"Online whiteboard
collaboration made easy"
`;
exports[`Test Linear Elements > Test bound text element > should resize and position the bound text correctly when 2 pointer linear element resized 2`] = `
"Online whiteboard
collaboration made
easy"
`;
exports[`Test Linear Elements > Test bound text element > should resize and position the bound text correctly when 2 pointer linear element resized 5`] = `
"Online whiteboard
collaboration made easy"
`;
exports[`Test Linear Elements > Test bound text element > should wrap the bound text when arrow bound container moves 1`] = `
"Online whiteboard
collaboration made easy"
`;
exports[`Test Linear Elements > Test bound text element > should wrap the bound text when arrow bound container moves 2`] = `
"Online whiteboard
collaboration made
easy"
`;

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