Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa1f6890c4 | |||
| c641860cb1 | |||
| 84d89b9a8a | |||
| e63dd025c9 | |||
| 15e019706d | |||
| a133a70e87 | |||
| 80ea7ca23f | |||
| e844580b14 | |||
| 5a0771ad9c | |||
| adcdbe2907 | |||
| 230d0edc44 | |||
| d0a380758e |
@@ -23,8 +23,8 @@
|
||||
"clsx": "^1.2.1",
|
||||
"docusaurus-plugin-sass": "0.2.3",
|
||||
"prism-react-renderer": "^1.3.5",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"sass": "1.57.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+28
-23
@@ -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==
|
||||
@@ -6190,14 +6190,13 @@ react-dev-utils@^12.0.1:
|
||||
strip-ansi "^6.0.1"
|
||||
text-table "^0.2.0"
|
||||
|
||||
react-dom@^17.0.2:
|
||||
version "17.0.2"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"
|
||||
integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==
|
||||
react-dom@18.2.0:
|
||||
version "18.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"
|
||||
integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==
|
||||
dependencies:
|
||||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.1"
|
||||
scheduler "^0.20.2"
|
||||
scheduler "^0.23.0"
|
||||
|
||||
react-error-overlay@^6.0.11:
|
||||
version "6.0.11"
|
||||
@@ -6260,6 +6259,14 @@ react-loadable-ssr-addon-v5-slorber@^1.0.1:
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.10.3"
|
||||
|
||||
"react-loadable@npm:@docusaurus/react-loadable@5.5.2":
|
||||
version "5.5.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce"
|
||||
integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
prop-types "^15.6.2"
|
||||
|
||||
react-router-config@^5.1.1:
|
||||
version "5.1.1"
|
||||
resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988"
|
||||
@@ -6310,13 +6317,12 @@ react-textarea-autosize@^8.3.2:
|
||||
use-composed-ref "^1.3.0"
|
||||
use-latest "^1.2.1"
|
||||
|
||||
react@^17.0.2:
|
||||
version "17.0.2"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
|
||||
integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==
|
||||
react@18.2.0:
|
||||
version "18.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
|
||||
integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==
|
||||
dependencies:
|
||||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.1"
|
||||
|
||||
readable-stream@^2.0.1:
|
||||
version "2.3.7"
|
||||
@@ -6664,13 +6670,12 @@ sax@^1.2.4:
|
||||
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
|
||||
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
|
||||
|
||||
scheduler@^0.20.2:
|
||||
version "0.20.2"
|
||||
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91"
|
||||
integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==
|
||||
scheduler@^0.23.0:
|
||||
version "0.23.2"
|
||||
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3"
|
||||
integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==
|
||||
dependencies:
|
||||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.1"
|
||||
|
||||
schema-utils@2.7.0:
|
||||
version "2.7.0"
|
||||
@@ -7801,14 +7806,14 @@ write-file-atomic@^3.0.0:
|
||||
typedarray-to-buffer "^3.1.5"
|
||||
|
||||
ws@^7.3.1:
|
||||
version "7.5.9"
|
||||
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591"
|
||||
integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==
|
||||
version "7.5.10"
|
||||
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9"
|
||||
integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==
|
||||
|
||||
ws@^8.4.2:
|
||||
version "8.8.1"
|
||||
resolved "https://registry.yarnpkg.com/ws/-/ws-8.8.1.tgz#5dbad0feb7ade8ecc99b830c1d77c913d4955ff0"
|
||||
integrity sha512-bGy2JzvzkPowEJV++hF07hAD6niYSr0JzBNo/J29WsB57A2r7Wlc1UFcTR9IzrPvuNVO4B8LGqF8qcpsVOhJCA==
|
||||
version "8.18.0"
|
||||
resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc"
|
||||
integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==
|
||||
|
||||
xdg-basedir@^4.0.0:
|
||||
version "4.0.0"
|
||||
|
||||
@@ -13,13 +13,13 @@
|
||||
"dependencies": {
|
||||
"@excalidraw/excalidraw": "*",
|
||||
"next": "14.1",
|
||||
"react": "^18",
|
||||
"react-dom": "^18"
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/react": "18.2.0",
|
||||
"@types/react-dom": "18.2.0",
|
||||
"path2d-polyfill": "2.0.1",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
+17
-41
@@ -95,6 +95,11 @@
|
||||
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>
|
||||
@@ -114,52 +119,16 @@
|
||||
) {
|
||||
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"
|
||||
@@ -184,7 +153,7 @@
|
||||
/>
|
||||
<% } %>
|
||||
|
||||
<!-- For Nunito only preload the latin range, which should be enough for now -->
|
||||
<!-- For Nunito only preload the latin range, which should be good enough for now -->
|
||||
<link
|
||||
rel="preload"
|
||||
href="https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2"
|
||||
@@ -200,6 +169,13 @@
|
||||
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>
|
||||
|
||||
@@ -26,7 +26,17 @@
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"vite-plugin-html": "3.2.2"
|
||||
"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"
|
||||
},
|
||||
"prettier": "@excalidraw/prettier-config",
|
||||
"scripts": {
|
||||
|
||||
+5
-13
@@ -9,19 +9,8 @@
|
||||
"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",
|
||||
@@ -51,7 +40,7 @@
|
||||
"vite-plugin-ejs": "1.7.0",
|
||||
"vite-plugin-pwa": "0.17.4",
|
||||
"vite-plugin-svgr": "2.4.0",
|
||||
"vitest": "1.5.3",
|
||||
"vitest": "1.6.0",
|
||||
"vitest-canvas-mock": "0.3.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -88,5 +77,8 @@
|
||||
"prerelease:excalidraw": "node scripts/prerelease.js",
|
||||
"build:preview": "yarn build && vite preview --port 5000",
|
||||
"release:excalidraw": "node scripts/release.js"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "18.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,19 +5,25 @@ import { t } from "../i18n";
|
||||
import { register } from "./register";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import type { AppState } from "../types";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||
import { getElementsInGroup } from "../groups";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
|
||||
import {
|
||||
isBoundToContainer,
|
||||
isElbowArrow,
|
||||
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 framesToBeDeleted = new Set(
|
||||
getSelectedElements(
|
||||
@@ -29,6 +35,26 @@ 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, app.scene, bound.points);
|
||||
}
|
||||
});
|
||||
}
|
||||
return newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
|
||||
@@ -130,7 +156,11 @@ export const actionDeleteSelected = register({
|
||||
: endBindingElement,
|
||||
};
|
||||
|
||||
LinearElementEditor.deletePoints(element, selectedPointsIndices);
|
||||
LinearElementEditor.deletePoints(
|
||||
element,
|
||||
selectedPointsIndices,
|
||||
app.scene,
|
||||
);
|
||||
|
||||
return {
|
||||
elements,
|
||||
@@ -149,7 +179,7 @@ export const actionDeleteSelected = register({
|
||||
};
|
||||
}
|
||||
let { elements: nextElements, appState: nextAppState } =
|
||||
deleteSelectedElements(elements, appState);
|
||||
deleteSelectedElements(elements, appState, app);
|
||||
fixBindingsAfterDeletion(
|
||||
nextElements,
|
||||
elements.filter(({ id }) => appState.selectedElementIds[id]),
|
||||
|
||||
@@ -40,12 +40,11 @@ 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,
|
||||
elementsMap,
|
||||
app.scene,
|
||||
);
|
||||
|
||||
if (!ret) {
|
||||
|
||||
@@ -38,6 +38,7 @@ export const actionFinalize = register({
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
elementsMap,
|
||||
scene,
|
||||
);
|
||||
}
|
||||
return {
|
||||
@@ -136,6 +137,7 @@ export const actionFinalize = register({
|
||||
appState,
|
||||
{ x, y },
|
||||
elementsMap,
|
||||
elements,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,11 +120,14 @@ const flipElements = (
|
||||
true,
|
||||
flipDirection === "horizontal" ? maxX : minX,
|
||||
flipDirection === "horizontal" ? minY : maxY,
|
||||
app.scene,
|
||||
);
|
||||
|
||||
bindOrUnbindLinearElements(
|
||||
selectedElements.filter(isLinearElement),
|
||||
elementsMap,
|
||||
app.scene.getNonDeletedElements(),
|
||||
app.scene,
|
||||
isBindingEnabled(appState),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -50,12 +50,13 @@ export const createUndoAction: ActionCreator = (history, store) => ({
|
||||
icon: UndoIcon,
|
||||
trackEvent: { category: "history" },
|
||||
viewMode: false,
|
||||
perform: (elements, appState) =>
|
||||
perform: (elements, appState, value, app) =>
|
||||
writeData(appState, () =>
|
||||
history.undo(
|
||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
||||
appState,
|
||||
store.snapshot,
|
||||
app.scene,
|
||||
),
|
||||
),
|
||||
keyTest: (event) =>
|
||||
@@ -91,12 +92,13 @@ export const createRedoAction: ActionCreator = (history, store) => ({
|
||||
icon: RedoIcon,
|
||||
trackEvent: { category: "history" },
|
||||
viewMode: false,
|
||||
perform: (elements, appState) =>
|
||||
perform: (elements, appState, _, app) =>
|
||||
writeData(appState, () =>
|
||||
history.redo(
|
||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
||||
appState,
|
||||
store.snapshot,
|
||||
app.scene,
|
||||
),
|
||||
),
|
||||
keyTest: (event) =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { isElbowArrow, isLinearElement } from "../element/typeChecks";
|
||||
import type { ExcalidrawLinearElement } from "../element/types";
|
||||
import { StoreAction } from "../store";
|
||||
import { register } from "./register";
|
||||
@@ -29,7 +29,8 @@ export const actionToggleLinearEditor = register({
|
||||
if (
|
||||
!appState.editingLinearElement &&
|
||||
selectedElements.length === 1 &&
|
||||
isLinearElement(selectedElements[0])
|
||||
isLinearElement(selectedElements[0]) &&
|
||||
!isElbowArrow(selectedElements[0])
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AppClassProperties, AppState, Primitive } from "../types";
|
||||
import type { AppClassProperties, AppState, Point, Primitive } from "../types";
|
||||
import type { StoreActionType } from "../store";
|
||||
import {
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
||||
@@ -50,8 +50,12 @@ import {
|
||||
ArrowheadDiamondIcon,
|
||||
ArrowheadDiamondOutlineIcon,
|
||||
fontSizeIcon,
|
||||
sharpArrowIcon,
|
||||
roundArrowIcon,
|
||||
elbowArrowIcon,
|
||||
} from "../components/icons";
|
||||
import {
|
||||
ARROW_TYPE,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
FONT_FAMILY,
|
||||
@@ -67,12 +71,15 @@ 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,
|
||||
@@ -91,10 +98,23 @@ import {
|
||||
isSomeElementSelected,
|
||||
} from "../scene";
|
||||
import { hasStrokeColor } from "../scene/comparisons";
|
||||
import { arrayToMap, getFontFamilyString, getShortcutKey } from "../utils";
|
||||
import {
|
||||
arrayToMap,
|
||||
getFontFamilyString,
|
||||
getShortcutKey,
|
||||
tupleToCoors,
|
||||
} 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;
|
||||
|
||||
@@ -1304,8 +1324,12 @@ export const actionChangeRoundness = register({
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
elements: changeProperty(elements, appState, (el) => {
|
||||
if (isElbowArrow(el)) {
|
||||
return el;
|
||||
}
|
||||
|
||||
return newElementWith(el, {
|
||||
roundness:
|
||||
value === "round"
|
||||
? {
|
||||
@@ -1314,8 +1338,8 @@ export const actionChangeRoundness = register({
|
||||
: ROUNDNESS.PROPORTIONAL_RADIUS,
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
),
|
||||
});
|
||||
}),
|
||||
appState: {
|
||||
...appState,
|
||||
currentItemRoundness: value,
|
||||
@@ -1355,7 +1379,8 @@ export const actionChangeRoundness = register({
|
||||
appState,
|
||||
(element) =>
|
||||
hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
|
||||
(element) => element.hasOwnProperty("roundness"),
|
||||
(element) =>
|
||||
!isArrowElement(element) && element.hasOwnProperty("roundness"),
|
||||
(hasSelection) =>
|
||||
hasSelection ? null : appState.currentItemRoundness,
|
||||
)}
|
||||
@@ -1518,3 +1543,219 @@ 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,
|
||||
app.scene,
|
||||
[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>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -70,6 +70,7 @@ export type ActionName =
|
||||
| "changeSloppiness"
|
||||
| "changeStrokeStyle"
|
||||
| "changeArrowhead"
|
||||
| "changeArrowType"
|
||||
| "changeOpacity"
|
||||
| "changeFontSize"
|
||||
| "toggleCanvasMenu"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { COLOR_PALETTE } from "./colors";
|
||||
import {
|
||||
ARROW_TYPE,
|
||||
DEFAULT_ELEMENT_PROPS,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
@@ -33,6 +34,7 @@ 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,
|
||||
@@ -143,6 +145,11 @@ 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 },
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
} from "./element/types";
|
||||
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
|
||||
import { getNonDeletedGroupIds } from "./groups";
|
||||
import type Scene from "./scene/Scene";
|
||||
import { getObservedAppState } from "./store";
|
||||
import type {
|
||||
AppState,
|
||||
@@ -1053,6 +1054,7 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||
public applyTo(
|
||||
elements: SceneElementsMap,
|
||||
snapshot: Map<string, OrderedExcalidrawElement>,
|
||||
scene: Scene,
|
||||
): [SceneElementsMap, boolean] {
|
||||
let nextElements = toBrandedType<SceneElementsMap>(new Map(elements));
|
||||
let changedElements: Map<string, OrderedExcalidrawElement>;
|
||||
@@ -1100,7 +1102,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);
|
||||
ElementsChange.redrawBoundArrows(nextElements, changedElements, scene);
|
||||
|
||||
// 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)
|
||||
@@ -1457,10 +1459,13 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||
private static redrawBoundArrows(
|
||||
elements: SceneElementsMap,
|
||||
changed: Map<string, OrderedExcalidrawElement>,
|
||||
scene: Scene,
|
||||
) {
|
||||
for (const element of changed.values()) {
|
||||
if (!element.isDeleted && isBindableElement(element)) {
|
||||
updateBoundElements(element, elements);
|
||||
updateBoundElements(element, elements, scene, {
|
||||
changedElements: changed,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,8 +257,6 @@ const chartLines = (
|
||||
type: "line",
|
||||
x,
|
||||
y,
|
||||
startArrowhead: null,
|
||||
endArrowhead: null,
|
||||
width: chartWidth,
|
||||
points: [
|
||||
[0, 0],
|
||||
@@ -273,8 +271,6 @@ const chartLines = (
|
||||
type: "line",
|
||||
x,
|
||||
y,
|
||||
startArrowhead: null,
|
||||
endArrowhead: null,
|
||||
height: chartHeight,
|
||||
points: [
|
||||
[0, 0],
|
||||
@@ -289,8 +285,6 @@ const chartLines = (
|
||||
type: "line",
|
||||
x,
|
||||
y: y - BAR_HEIGHT - BAR_GAP,
|
||||
startArrowhead: null,
|
||||
endArrowhead: null,
|
||||
strokeStyle: "dotted",
|
||||
width: chartWidth,
|
||||
opacity: GRID_OPACITY,
|
||||
@@ -418,8 +412,6 @@ 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,
|
||||
@@ -453,8 +445,6 @@ 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,
|
||||
|
||||
@@ -21,10 +21,11 @@ import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
|
||||
import { capitalizeString, isTransparent } from "../utils";
|
||||
import Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { hasStrokeColor } from "../scene/comparisons";
|
||||
import { hasStrokeColor, toolIsArrow } from "../scene/comparisons";
|
||||
import { trackEvent } from "../analytics";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isElbowArrow,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
} from "../element/typeChecks";
|
||||
@@ -121,7 +122,8 @@ export const SelectedShapeActions = ({
|
||||
const showLineEditorAction =
|
||||
!appState.editingLinearElement &&
|
||||
targetElements.length === 1 &&
|
||||
isLinearElement(targetElements[0]);
|
||||
isLinearElement(targetElements[0]) &&
|
||||
!isElbowArrow(targetElements[0]);
|
||||
|
||||
return (
|
||||
<div className="panelColumn">
|
||||
@@ -155,6 +157,11 @@ export const SelectedShapeActions = ({
|
||||
<>{renderAction("changeRoundness")}</>
|
||||
)}
|
||||
|
||||
{(toolIsArrow(appState.activeTool.type) ||
|
||||
targetElements.some((element) => toolIsArrow(element.type))) && (
|
||||
<>{renderAction("changeArrowType")}</>
|
||||
)}
|
||||
|
||||
{(appState.activeTool.type === "text" ||
|
||||
targetElements.some(isTextElement)) && (
|
||||
<>
|
||||
|
||||
@@ -48,7 +48,7 @@ import {
|
||||
} from "../appState";
|
||||
import type { PastedMixedContent } from "../clipboard";
|
||||
import { copyTextToSystemClipboard, parseClipboard } from "../clipboard";
|
||||
import type { EXPORT_IMAGE_TYPES } from "../constants";
|
||||
import { ARROW_TYPE, type EXPORT_IMAGE_TYPES } from "../constants";
|
||||
import {
|
||||
APP_NAME,
|
||||
CURSOR_TYPE,
|
||||
@@ -142,6 +142,7 @@ import {
|
||||
newEmbeddableElement,
|
||||
newMagicFrameElement,
|
||||
newIframeElement,
|
||||
newArrowElement,
|
||||
} from "../element/newElement";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
@@ -156,11 +157,11 @@ import {
|
||||
isLinearElement,
|
||||
isLinearElementType,
|
||||
isUsingAdaptiveRadius,
|
||||
isFrameElement,
|
||||
isIframeElement,
|
||||
isIframeLikeElement,
|
||||
isMagicFrameElement,
|
||||
isTextBindableContainer,
|
||||
isElbowArrow,
|
||||
} from "../element/typeChecks";
|
||||
import type {
|
||||
ExcalidrawBindableElement,
|
||||
@@ -182,6 +183,7 @@ import type {
|
||||
ExcalidrawIframeElement,
|
||||
ExcalidrawEmbeddableElement,
|
||||
Ordered,
|
||||
ExcalidrawArrowElement,
|
||||
} from "../element/types";
|
||||
import { getCenter, getDistance } from "../gesture";
|
||||
import {
|
||||
@@ -426,6 +428,7 @@ import { getShortcutFromShortcutName } from "../actions/shortcuts";
|
||||
import { actionTextAutoResize } from "../actions/actionTextAutoResize";
|
||||
import { getVisibleSceneBounds } from "../element/bounds";
|
||||
import { isMaybeMermaidDefinition } from "../mermaid";
|
||||
import { mutateElbowArrow } from "../element/routing";
|
||||
|
||||
const AppContext = React.createContext<AppClassProperties>(null!);
|
||||
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||
@@ -1288,15 +1291,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
const isDarkTheme = this.state.theme === THEME.DARK;
|
||||
|
||||
let frameIndex = 0;
|
||||
let magicFrameIndex = 0;
|
||||
|
||||
return this.scene.getNonDeletedFramesLikes().map((f) => {
|
||||
if (isFrameElement(f)) {
|
||||
frameIndex++;
|
||||
} else {
|
||||
magicFrameIndex++;
|
||||
}
|
||||
if (
|
||||
!isElementInViewport(
|
||||
f,
|
||||
@@ -1330,10 +1325,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
let frameNameJSX;
|
||||
|
||||
const frameName = getFrameLikeTitle(
|
||||
f,
|
||||
isFrameElement(f) ? frameIndex : magicFrameIndex,
|
||||
);
|
||||
const frameName = getFrameLikeTitle(f);
|
||||
|
||||
if (f.id === this.state.editingFrame) {
|
||||
const frameNameInEdit = frameName;
|
||||
@@ -2124,6 +2116,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
};
|
||||
|
||||
public dismissLinearEditor = () => {
|
||||
setTimeout(() => {
|
||||
this.setState({
|
||||
editingLinearElement: null,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
public syncActionResult = withBatchedUpdates((actionResult: ActionResult) => {
|
||||
if (this.unmounted || actionResult === false) {
|
||||
return;
|
||||
@@ -2345,7 +2345,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// fire (looking at you Safari), so on init we manually load all
|
||||
// fonts and rerender scene text elements once done. This also
|
||||
// seems faster even in browsers that do fire the loadingdone event.
|
||||
this.fonts.load();
|
||||
this.fonts.loadSceneFonts();
|
||||
};
|
||||
|
||||
private isMobileBreakpoint = (width: number, height: number) => {
|
||||
@@ -2815,6 +2815,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
),
|
||||
),
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this.scene.getNonDeletedElements(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3959,14 +3960,27 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
if (isArrowKey(event.key)) {
|
||||
const step =
|
||||
(this.state.gridSize &&
|
||||
const selectedElements = this.scene.getSelectedElements({
|
||||
selectedElementIds: this.state.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
});
|
||||
|
||||
const elbowArrow = selectedElements.find(isElbowArrow) as
|
||||
| ExcalidrawArrowElement
|
||||
| undefined;
|
||||
|
||||
const step = elbowArrow
|
||||
? elbowArrow.startBinding || elbowArrow.endBinding
|
||||
? 0
|
||||
: ELEMENT_TRANSLATE_AMOUNT
|
||||
: (this.state.gridSize &&
|
||||
(event.shiftKey
|
||||
? ELEMENT_TRANSLATE_AMOUNT
|
||||
: this.state.gridSize)) ||
|
||||
(event.shiftKey
|
||||
? ELEMENT_TRANSLATE_AMOUNT
|
||||
: this.state.gridSize)) ||
|
||||
(event.shiftKey
|
||||
? ELEMENT_SHIFT_TRANSLATE_AMOUNT
|
||||
: ELEMENT_TRANSLATE_AMOUNT);
|
||||
? ELEMENT_SHIFT_TRANSLATE_AMOUNT
|
||||
: ELEMENT_TRANSLATE_AMOUNT);
|
||||
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
@@ -3981,26 +3995,27 @@ class App extends React.Component<AppProps, AppState> {
|
||||
offsetY = step;
|
||||
}
|
||||
|
||||
const selectedElements = this.scene.getSelectedElements({
|
||||
selectedElementIds: this.state.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
});
|
||||
|
||||
selectedElements.forEach((element) => {
|
||||
mutateElement(element, {
|
||||
x: element.x + offsetX,
|
||||
y: element.y + offsetY,
|
||||
});
|
||||
|
||||
updateBoundElements(element, this.scene.getNonDeletedElementsMap(), {
|
||||
simultaneouslyUpdated: selectedElements,
|
||||
});
|
||||
updateBoundElements(
|
||||
element,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this.scene,
|
||||
{
|
||||
simultaneouslyUpdated: selectedElements,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
this.setState({
|
||||
suggestedBindings: getSuggestedBindingsForArrows(
|
||||
selectedElements,
|
||||
selectedElements.filter(
|
||||
(element) => element.id !== elbowArrow?.id || step !== 0,
|
||||
),
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
});
|
||||
@@ -4018,11 +4033,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
selectedElements[0].id
|
||||
) {
|
||||
this.store.shouldCaptureIncrement();
|
||||
this.setState({
|
||||
editingLinearElement: new LinearElementEditor(
|
||||
selectedElement,
|
||||
),
|
||||
});
|
||||
if (!isElbowArrow(selectedElement)) {
|
||||
this.setState({
|
||||
editingLinearElement: new LinearElementEditor(
|
||||
selectedElement,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
@@ -4070,6 +4087,16 @@ class App extends React.Component<AppProps, AppState> {
|
||||
})`,
|
||||
);
|
||||
}
|
||||
if (shape === "arrow" && this.state.activeTool.type === "arrow") {
|
||||
this.setState((prevState) => ({
|
||||
currentItemArrowType:
|
||||
prevState.currentItemArrowType === ARROW_TYPE.sharp
|
||||
? ARROW_TYPE.round
|
||||
: prevState.currentItemArrowType === ARROW_TYPE.round
|
||||
? ARROW_TYPE.elbow
|
||||
: ARROW_TYPE.sharp,
|
||||
}));
|
||||
}
|
||||
this.setActiveTool({ type: shape });
|
||||
event.stopPropagation();
|
||||
} else if (event.key === KEYS.Q) {
|
||||
@@ -4203,6 +4230,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
bindOrUnbindLinearElements(
|
||||
this.scene.getSelectedElements(this.state).filter(isLinearElement),
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this.scene.getNonDeletedElements(),
|
||||
this.scene,
|
||||
isBindingEnabled(this.state),
|
||||
this.state.selectedLinearElement?.selectedPointsIndices ?? [],
|
||||
);
|
||||
@@ -4434,7 +4463,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
onChange: withBatchedUpdates((nextOriginalText) => {
|
||||
updateElement(nextOriginalText, false);
|
||||
if (isNonDeletedElement(element)) {
|
||||
updateBoundElements(element, elementsMap);
|
||||
updateBoundElements(element, elementsMap, this.scene);
|
||||
}
|
||||
}),
|
||||
onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => {
|
||||
@@ -4883,7 +4912,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
(!this.state.editingLinearElement ||
|
||||
this.state.editingLinearElement.elementId !== selectedElements[0].id)
|
||||
this.state.editingLinearElement.elementId !==
|
||||
selectedElements[0].id) &&
|
||||
!isElbowArrow(selectedElements[0])
|
||||
) {
|
||||
this.store.shouldCaptureIncrement();
|
||||
this.setState({
|
||||
@@ -5226,7 +5257,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
scenePointerX,
|
||||
scenePointerY,
|
||||
this.state,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this.scene,
|
||||
);
|
||||
|
||||
if (
|
||||
@@ -5313,7 +5344,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
scenePointerX,
|
||||
scenePointerY,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
|
||||
event[KEYS.CTRL_OR_CMD] || isElbowArrow(multiElement)
|
||||
? null
|
||||
: this.state.gridSize,
|
||||
);
|
||||
|
||||
const [lastCommittedX, lastCommittedY] =
|
||||
@@ -5337,16 +5370,35 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (isPathALoop(points, this.state.zoom.value)) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
|
||||
}
|
||||
// update last uncommitted point
|
||||
mutateElement(multiElement, {
|
||||
points: [
|
||||
...points.slice(0, -1),
|
||||
if (isElbowArrow(multiElement)) {
|
||||
mutateElbowArrow(
|
||||
multiElement,
|
||||
this.scene,
|
||||
[
|
||||
lastCommittedX + dxFromLastCommitted,
|
||||
lastCommittedY + dyFromLastCommitted,
|
||||
...points.slice(0, -1),
|
||||
[
|
||||
lastCommittedX + dxFromLastCommitted,
|
||||
lastCommittedY + dyFromLastCommitted,
|
||||
],
|
||||
],
|
||||
],
|
||||
});
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
isDragging: true,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// update last uncommitted point
|
||||
mutateElement(multiElement, {
|
||||
points: [
|
||||
...points.slice(0, -1),
|
||||
[
|
||||
lastCommittedX + dxFromLastCommitted,
|
||||
lastCommittedY + dyFromLastCommitted,
|
||||
],
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -5381,8 +5433,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
if (
|
||||
!this.state.selectedLinearElement ||
|
||||
this.state.selectedLinearElement.hoverPointIndex === -1
|
||||
(!this.state.selectedLinearElement ||
|
||||
this.state.selectedLinearElement.hoverPointIndex === -1) &&
|
||||
!(selectedElements.length === 1 && isElbowArrow(selectedElements[0]))
|
||||
) {
|
||||
const elementWithTransformHandleType =
|
||||
getElementWithTransformHandleType(
|
||||
@@ -5670,7 +5723,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
||||
}
|
||||
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
||||
if (
|
||||
!isElbowArrow(element) ||
|
||||
!(element.startBinding || element.endBinding)
|
||||
) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -6244,6 +6302,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const origin = viewportCoordsToSceneCoords(event, this.state);
|
||||
const selectedElements = this.scene.getSelectedElements(this.state);
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(selectedElements);
|
||||
const isElbowArrowOnly = selectedElements.findIndex(isElbowArrow) === 0;
|
||||
|
||||
return {
|
||||
origin,
|
||||
@@ -6252,7 +6311,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
getGridPoint(
|
||||
origin.x,
|
||||
origin.y,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
|
||||
event[KEYS.CTRL_OR_CMD] || isElbowArrowOnly
|
||||
? null
|
||||
: this.state.gridSize,
|
||||
),
|
||||
),
|
||||
scrollbars: isOverScrollBars(
|
||||
@@ -6433,7 +6494,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.store,
|
||||
pointerDownState.origin,
|
||||
linearElementEditor,
|
||||
this,
|
||||
this.scene,
|
||||
);
|
||||
if (ret.hitElement) {
|
||||
pointerDownState.hit.element = ret.hitElement;
|
||||
@@ -6765,6 +6826,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
const boundElement = getHoveredElementForBinding(
|
||||
pointerDownState.origin,
|
||||
this.scene.getNonDeletedElements(),
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
this.scene.insertElement(element);
|
||||
@@ -6935,6 +6997,17 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Elbow arrows cannot be created by putting down points
|
||||
// only the start and end points can be defined
|
||||
if (isElbowArrow(multiElement) && multiElement.points.length > 1) {
|
||||
mutateElement(multiElement, {
|
||||
lastCommittedPoint:
|
||||
multiElement.points[multiElement.points.length - 1],
|
||||
});
|
||||
this.actionManager.executeAction(actionFinalize);
|
||||
return;
|
||||
}
|
||||
|
||||
const { x: rx, y: ry, lastCommittedPoint } = multiElement;
|
||||
|
||||
// clicking inside commit zone → finalize arrow
|
||||
@@ -6990,26 +7063,50 @@ class App extends React.Component<AppProps, AppState> {
|
||||
? [currentItemStartArrowhead, currentItemEndArrowhead]
|
||||
: [null, null];
|
||||
|
||||
const element = newLinearElement({
|
||||
type: elementType,
|
||||
x: gridX,
|
||||
y: gridY,
|
||||
strokeColor: this.state.currentItemStrokeColor,
|
||||
backgroundColor: this.state.currentItemBackgroundColor,
|
||||
fillStyle: this.state.currentItemFillStyle,
|
||||
strokeWidth: this.state.currentItemStrokeWidth,
|
||||
strokeStyle: this.state.currentItemStrokeStyle,
|
||||
roughness: this.state.currentItemRoughness,
|
||||
opacity: this.state.currentItemOpacity,
|
||||
roundness:
|
||||
this.state.currentItemRoundness === "round"
|
||||
? { type: ROUNDNESS.PROPORTIONAL_RADIUS }
|
||||
: null,
|
||||
startArrowhead,
|
||||
endArrowhead,
|
||||
locked: false,
|
||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||
});
|
||||
const element =
|
||||
elementType === "arrow"
|
||||
? newArrowElement({
|
||||
type: elementType,
|
||||
x: gridX,
|
||||
y: gridY,
|
||||
strokeColor: this.state.currentItemStrokeColor,
|
||||
backgroundColor: this.state.currentItemBackgroundColor,
|
||||
fillStyle: this.state.currentItemFillStyle,
|
||||
strokeWidth: this.state.currentItemStrokeWidth,
|
||||
strokeStyle: this.state.currentItemStrokeStyle,
|
||||
roughness: this.state.currentItemRoughness,
|
||||
opacity: this.state.currentItemOpacity,
|
||||
roundness:
|
||||
this.state.currentItemArrowType === ARROW_TYPE.round
|
||||
? { type: ROUNDNESS.PROPORTIONAL_RADIUS }
|
||||
: // note, roundness doesn't have any effect for elbow arrows,
|
||||
// but it's best to set it to null as well
|
||||
null,
|
||||
startArrowhead,
|
||||
endArrowhead,
|
||||
locked: false,
|
||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||
elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow,
|
||||
})
|
||||
: newLinearElement({
|
||||
type: elementType,
|
||||
x: gridX,
|
||||
y: gridY,
|
||||
strokeColor: this.state.currentItemStrokeColor,
|
||||
backgroundColor: this.state.currentItemBackgroundColor,
|
||||
fillStyle: this.state.currentItemFillStyle,
|
||||
strokeWidth: this.state.currentItemStrokeWidth,
|
||||
strokeStyle: this.state.currentItemStrokeStyle,
|
||||
roughness: this.state.currentItemRoughness,
|
||||
opacity: this.state.currentItemOpacity,
|
||||
roundness:
|
||||
this.state.currentItemRoundness === "round"
|
||||
? { type: ROUNDNESS.PROPORTIONAL_RADIUS }
|
||||
: null,
|
||||
locked: false,
|
||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||
});
|
||||
|
||||
this.setState((prevState) => {
|
||||
const nextSelectedElementIds = {
|
||||
...prevState.selectedElementIds,
|
||||
@@ -7027,7 +7124,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
const boundElement = getHoveredElementForBinding(
|
||||
pointerDownState.origin,
|
||||
this.scene.getNonDeletedElements(),
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
isElbowArrow(element),
|
||||
);
|
||||
|
||||
this.scene.insertElement(element);
|
||||
@@ -7364,7 +7463,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
);
|
||||
},
|
||||
linearElementEditor,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this.scene,
|
||||
);
|
||||
if (didDrag) {
|
||||
pointerDownState.lastCoords.x = pointerCoords.x;
|
||||
@@ -7488,18 +7587,24 @@ class App extends React.Component<AppProps, AppState> {
|
||||
pointerDownState,
|
||||
selectedElements,
|
||||
dragOffset,
|
||||
this.state,
|
||||
this.scene,
|
||||
snapOffset,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
|
||||
);
|
||||
|
||||
this.setState({
|
||||
suggestedBindings: getSuggestedBindingsForArrows(
|
||||
selectedElements,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
});
|
||||
if (
|
||||
selectedElements.length !== 1 ||
|
||||
!isElbowArrow(selectedElements[0])
|
||||
) {
|
||||
this.setState({
|
||||
suggestedBindings: getSuggestedBindingsForArrows(
|
||||
selectedElements,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
//}
|
||||
|
||||
// We duplicate the selected element if alt is pressed on pointer move
|
||||
if (event.altKey && !pointerDownState.hit.hasBeenDuplicated) {
|
||||
@@ -7639,6 +7744,17 @@ class App extends React.Component<AppProps, AppState> {
|
||||
mutateElement(draggingElement, {
|
||||
points: [...points, [dx, dy]],
|
||||
});
|
||||
} else if (points.length > 1 && isElbowArrow(draggingElement)) {
|
||||
mutateElbowArrow(
|
||||
draggingElement,
|
||||
this.scene,
|
||||
[...points.slice(0, -1), [dx, dy]],
|
||||
[0, 0],
|
||||
undefined,
|
||||
{
|
||||
isDragging: true,
|
||||
},
|
||||
);
|
||||
} else if (points.length === 2) {
|
||||
mutateElement(draggingElement, {
|
||||
points: [...points.slice(0, -1), [dx, dy]],
|
||||
@@ -7844,7 +7960,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
childEvent,
|
||||
this.state.editingLinearElement,
|
||||
this.state,
|
||||
this,
|
||||
this.scene,
|
||||
);
|
||||
if (editingLinearElement !== this.state.editingLinearElement) {
|
||||
this.setState({
|
||||
@@ -7868,7 +7984,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
childEvent,
|
||||
this.state.selectedLinearElement,
|
||||
this.state,
|
||||
this,
|
||||
this.scene,
|
||||
);
|
||||
|
||||
const { startBindingElement, endBindingElement } =
|
||||
@@ -7880,6 +7996,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
elementsMap,
|
||||
this.scene,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8019,6 +8136,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state,
|
||||
pointerCoords,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this.scene.getNonDeletedElements(),
|
||||
);
|
||||
}
|
||||
this.setState({ suggestedBindings: [], startBoundElement: null });
|
||||
@@ -8580,6 +8698,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
bindOrUnbindLinearElements(
|
||||
linearElements,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this.scene.getNonDeletedElements(),
|
||||
this.scene,
|
||||
isBindingEnabled(this.state),
|
||||
this.state.selectedLinearElement?.selectedPointsIndices ?? [],
|
||||
);
|
||||
@@ -9067,6 +9187,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}): void => {
|
||||
const hoveredBindableElement = getHoveredElementForBinding(
|
||||
pointerCoords,
|
||||
this.scene.getNonDeletedElements(),
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
this.setState({
|
||||
@@ -9094,7 +9215,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
(acc: NonDeleted<ExcalidrawBindableElement>[], coords) => {
|
||||
const hoveredBindableElement = getHoveredElementForBinding(
|
||||
coords,
|
||||
this.scene.getNonDeletedElements(),
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
isArrowElement(linearElement) && isElbowArrow(linearElement),
|
||||
);
|
||||
if (
|
||||
hoveredBindableElement != null &&
|
||||
@@ -9622,6 +9745,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
resizeY,
|
||||
pointerDownState.resize.center.x,
|
||||
pointerDownState.resize.center.y,
|
||||
this.scene,
|
||||
)
|
||||
) {
|
||||
const suggestedBindings = getSuggestedBindingsForArrows(
|
||||
@@ -9938,6 +10062,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
declare global {
|
||||
interface Window {
|
||||
h: {
|
||||
scene: Scene;
|
||||
elements: readonly ExcalidrawElement[];
|
||||
state: AppState;
|
||||
setState: React.Component<any, AppState>["setState"];
|
||||
@@ -9964,6 +10089,12 @@ export const createTestHook = () => {
|
||||
);
|
||||
},
|
||||
},
|
||||
scene: {
|
||||
configurable: true,
|
||||
get() {
|
||||
return this.app?.scene;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -63,15 +63,15 @@ export const FontPickerList = React.memo(
|
||||
() =>
|
||||
Array.from(Fonts.registered.entries())
|
||||
.filter(([_, { metadata }]) => !metadata.serverSide)
|
||||
.map(([familyId, { metadata, fontFaces }]) => {
|
||||
const font = {
|
||||
.map(([familyId, { metadata, fonts }]) => {
|
||||
const fontDescriptor = {
|
||||
value: familyId,
|
||||
icon: metadata.icon,
|
||||
text: fontFaces[0].fontFace.family,
|
||||
text: fonts[0].fontFace.family,
|
||||
};
|
||||
|
||||
if (metadata.deprecated) {
|
||||
Object.assign(font, {
|
||||
Object.assign(fontDescriptor, {
|
||||
deprecated: metadata.deprecated,
|
||||
badge: {
|
||||
type: DropDownMenuItemBadgeType.RED,
|
||||
@@ -80,7 +80,7 @@ export const FontPickerList = React.memo(
|
||||
});
|
||||
}
|
||||
|
||||
return font as FontDescriptor;
|
||||
return fontDescriptor 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.sceneFamilies),
|
||||
() => new Set(fonts.getSceneFontFamilies()),
|
||||
// cache per selected font family, so hover re-render won't mess it up
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[selectedFontFamily],
|
||||
|
||||
@@ -30,10 +30,13 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
|
||||
return t("hints.eraserRevert");
|
||||
}
|
||||
if (activeTool.type === "arrow" || activeTool.type === "line") {
|
||||
if (!multiMode) {
|
||||
return t("hints.linearElement");
|
||||
if (multiMode) {
|
||||
return t("hints.linearElementMulti");
|
||||
}
|
||||
return t("hints.linearElementMulti");
|
||||
if (activeTool.type === "arrow") {
|
||||
return t("hints.arrowTool", { arrowShortcut: getShortcutKey("A") });
|
||||
}
|
||||
return t("hints.linearElement");
|
||||
}
|
||||
|
||||
if (activeTool.type === "freedraw") {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { mutateElement } from "../../element/mutateElement";
|
||||
import { getBoundTextElement } from "../../element/textElement";
|
||||
import { isArrowElement } from "../../element/typeChecks";
|
||||
import { isArrowElement, isElbowArrow } from "../../element/typeChecks";
|
||||
import type { ExcalidrawElement } from "../../element/types";
|
||||
import { degreeToRadian, radianToDegree } from "../../math";
|
||||
import { angleIcon } from "../icons";
|
||||
@@ -27,8 +27,9 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
|
||||
scene,
|
||||
}) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const elements = scene.getNonDeletedElements();
|
||||
const origElement = originalElements[0];
|
||||
if (origElement) {
|
||||
if (origElement && !isElbowArrow(origElement)) {
|
||||
const latestElement = elementsMap.get(origElement.id);
|
||||
if (!latestElement) {
|
||||
return;
|
||||
@@ -39,7 +40,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
|
||||
mutateElement(latestElement, {
|
||||
angle: nextAngle,
|
||||
});
|
||||
updateBindings(latestElement, elementsMap);
|
||||
updateBindings(latestElement, elementsMap, elements, scene);
|
||||
|
||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||
if (boundTextElement && !isArrowElement(latestElement)) {
|
||||
@@ -65,7 +66,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
|
||||
mutateElement(latestElement, {
|
||||
angle: nextAngle,
|
||||
});
|
||||
updateBindings(latestElement, elementsMap);
|
||||
updateBindings(latestElement, elementsMap, elements, scene);
|
||||
|
||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||
if (boundTextElement && !isArrowElement(latestElement)) {
|
||||
|
||||
@@ -31,6 +31,7 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
scene,
|
||||
}) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const elements = scene.getNonDeletedElements();
|
||||
const origElement = originalElements[0];
|
||||
if (origElement) {
|
||||
const keepAspectRatio =
|
||||
@@ -61,6 +62,8 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
keepAspectRatio,
|
||||
origElement,
|
||||
elementsMap,
|
||||
elements,
|
||||
scene,
|
||||
);
|
||||
|
||||
return;
|
||||
@@ -103,6 +106,8 @@ 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 });
|
||||
|
||||
@@ -66,8 +66,10 @@ const resizeElementInGroup = (
|
||||
origElement: ExcalidrawElement,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
originalElementsMap: ElementsMap,
|
||||
scene: Scene,
|
||||
) => {
|
||||
const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
|
||||
const { width: oldWidth, height: oldHeight } = latestElement;
|
||||
|
||||
mutateElement(latestElement, updates, false);
|
||||
const boundTextElement = getBoundTextElement(
|
||||
@@ -76,8 +78,8 @@ const resizeElementInGroup = (
|
||||
);
|
||||
if (boundTextElement) {
|
||||
const newFontSize = boundTextElement.fontSize * scale;
|
||||
updateBoundElements(latestElement, elementsMap, {
|
||||
newSize: { width: updates.width, height: updates.height },
|
||||
updateBoundElements(latestElement, elementsMap, scene, {
|
||||
oldSize: { width: oldWidth, height: oldHeight },
|
||||
});
|
||||
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
|
||||
if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
|
||||
@@ -109,6 +111,7 @@ const resizeGroup = (
|
||||
originalElements: ExcalidrawElement[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
originalElementsMap: ElementsMap,
|
||||
scene: Scene,
|
||||
) => {
|
||||
// keep aspect ratio for groups
|
||||
if (property === "width") {
|
||||
@@ -132,6 +135,7 @@ const resizeGroup = (
|
||||
origElement,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
scene,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -149,6 +153,7 @@ 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) {
|
||||
@@ -185,6 +190,7 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
originalElements,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
scene,
|
||||
);
|
||||
} else {
|
||||
const [el] = elementsInUnit;
|
||||
@@ -227,6 +233,8 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
false,
|
||||
origElement,
|
||||
elementsMap,
|
||||
elements,
|
||||
scene,
|
||||
false,
|
||||
);
|
||||
}
|
||||
@@ -288,6 +296,7 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
originalElements,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
scene,
|
||||
);
|
||||
} else {
|
||||
const [el] = elementsInUnit;
|
||||
@@ -320,7 +329,15 @@ 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);
|
||||
resizeElement(
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
false,
|
||||
origElement,
|
||||
elementsMap,
|
||||
elements,
|
||||
scene,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "../../element/types";
|
||||
import { rotate } from "../../math";
|
||||
@@ -33,6 +34,7 @@ const moveElements = (
|
||||
originalElements: readonly ExcalidrawElement[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
originalElementsMap: ElementsMap,
|
||||
scene: Scene,
|
||||
) => {
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const origElement = originalElements[i];
|
||||
@@ -60,6 +62,8 @@ const moveElements = (
|
||||
newTopLeftY,
|
||||
origElement,
|
||||
elementsMap,
|
||||
elements,
|
||||
scene,
|
||||
originalElementsMap,
|
||||
false,
|
||||
);
|
||||
@@ -71,6 +75,7 @@ const moveGroupTo = (
|
||||
nextY: number,
|
||||
originalElements: ExcalidrawElement[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
originalElementsMap: ElementsMap,
|
||||
scene: Scene,
|
||||
) => {
|
||||
@@ -106,6 +111,8 @@ const moveGroupTo = (
|
||||
topLeftY + offsetY,
|
||||
origElement,
|
||||
elementsMap,
|
||||
elements,
|
||||
scene,
|
||||
originalElementsMap,
|
||||
false,
|
||||
);
|
||||
@@ -126,6 +133,7 @@ const handlePositionChange: DragInputCallbackType<
|
||||
originalAppState,
|
||||
}) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const elements = scene.getNonDeletedElements();
|
||||
|
||||
if (nextValue !== undefined) {
|
||||
for (const atomicUnit of getAtomicUnits(
|
||||
@@ -150,6 +158,7 @@ const handlePositionChange: DragInputCallbackType<
|
||||
newTopLeftY,
|
||||
elementsInUnit.map((el) => el.original),
|
||||
elementsMap,
|
||||
elements,
|
||||
originalElementsMap,
|
||||
scene,
|
||||
);
|
||||
@@ -180,6 +189,8 @@ const handlePositionChange: DragInputCallbackType<
|
||||
newTopLeftY,
|
||||
origElement,
|
||||
elementsMap,
|
||||
elements,
|
||||
scene,
|
||||
originalElementsMap,
|
||||
false,
|
||||
);
|
||||
@@ -206,6 +217,7 @@ const handlePositionChange: DragInputCallbackType<
|
||||
originalElements,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
scene,
|
||||
);
|
||||
|
||||
scene.triggerUpdate();
|
||||
|
||||
@@ -26,6 +26,7 @@ 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,
|
||||
@@ -47,6 +48,8 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
|
||||
newTopLeftY,
|
||||
origElement,
|
||||
elementsMap,
|
||||
elements,
|
||||
scene,
|
||||
originalElementsMap,
|
||||
);
|
||||
return;
|
||||
@@ -78,6 +81,8 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
|
||||
newTopLeftY,
|
||||
origElement,
|
||||
elementsMap,
|
||||
elements,
|
||||
scene,
|
||||
originalElementsMap,
|
||||
);
|
||||
};
|
||||
@@ -104,9 +109,9 @@ const Position = ({
|
||||
label={property === "x" ? "X" : "Y"}
|
||||
elements={[element]}
|
||||
dragInputCallback={handlePositionChange}
|
||||
scene={scene}
|
||||
value={value}
|
||||
property={property}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -21,6 +21,7 @@ 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;
|
||||
@@ -209,12 +210,14 @@ export const StatsInner = memo(
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
<Angle
|
||||
property="angle"
|
||||
element={singleElement}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
{!isElbowArrow(singleElement) && (
|
||||
<Angle
|
||||
property="angle"
|
||||
element={singleElement}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
)}
|
||||
<FontSize
|
||||
property="fontSize"
|
||||
element={singleElement}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
isInGroup,
|
||||
} from "../../groups";
|
||||
import { rotate } from "../../math";
|
||||
import type Scene from "../../scene/Scene";
|
||||
import type { AppState } from "../../types";
|
||||
import { getFontString } from "../../utils";
|
||||
|
||||
@@ -124,6 +125,8 @@ export const resizeElement = (
|
||||
keepAspectRatio: boolean,
|
||||
origElement: ExcalidrawElement,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
scene: Scene,
|
||||
shouldInformMutation = true,
|
||||
) => {
|
||||
const latestElement = elementsMap.get(origElement.id);
|
||||
@@ -146,6 +149,8 @@ export const resizeElement = (
|
||||
nextHeight = Math.max(nextHeight, minHeight);
|
||||
}
|
||||
|
||||
const { width: oldWidth, height: oldHeight } = latestElement;
|
||||
|
||||
mutateElement(
|
||||
latestElement,
|
||||
{
|
||||
@@ -164,7 +169,7 @@ export const resizeElement = (
|
||||
},
|
||||
shouldInformMutation,
|
||||
);
|
||||
updateBindings(latestElement, elementsMap, {
|
||||
updateBindings(latestElement, elementsMap, elements, scene, {
|
||||
newSize: {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
@@ -193,6 +198,10 @@ export const resizeElement = (
|
||||
}
|
||||
}
|
||||
|
||||
updateBoundElements(latestElement, elementsMap, scene, {
|
||||
oldSize: { width: oldWidth, height: oldHeight },
|
||||
});
|
||||
|
||||
if (boundTextElement && boundTextFont) {
|
||||
mutateElement(boundTextElement, {
|
||||
fontSize: boundTextFont.fontSize,
|
||||
@@ -206,6 +215,8 @@ export const moveElement = (
|
||||
newTopLeftY: number,
|
||||
originalElement: ExcalidrawElement,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
scene: Scene,
|
||||
originalElementsMap: ElementsMap,
|
||||
shouldInformMutation = true,
|
||||
) => {
|
||||
@@ -244,7 +255,7 @@ export const moveElement = (
|
||||
},
|
||||
shouldInformMutation,
|
||||
);
|
||||
updateBindings(latestElement, elementsMap);
|
||||
updateBindings(latestElement, elementsMap, elements, scene);
|
||||
|
||||
const boundTextElement = getBoundTextElement(
|
||||
originalElement,
|
||||
@@ -288,14 +299,23 @@ 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, true, []);
|
||||
bindOrUnbindLinearElements(
|
||||
[latestElement],
|
||||
elementsMap,
|
||||
elements,
|
||||
scene,
|
||||
true,
|
||||
[],
|
||||
);
|
||||
} else {
|
||||
updateBoundElements(latestElement, elementsMap, options);
|
||||
updateBoundElements(latestElement, elementsMap, scene, options);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2095,6 +2095,35 @@ 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,5 +1,5 @@
|
||||
import cssVariables from "./css/variables.module.scss";
|
||||
import type { AppProps } from "./types";
|
||||
import type { AppProps, AppState } 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,3 +421,9 @@ 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,9 +84,11 @@ 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,
|
||||
},
|
||||
@@ -117,6 +119,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"startArrowhead": null,
|
||||
"startBinding": {
|
||||
"elementId": "id47",
|
||||
"fixedPoint": null,
|
||||
"focus": -0.08139534883720931,
|
||||
"gap": 1,
|
||||
},
|
||||
@@ -139,9 +142,11 @@ 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,
|
||||
},
|
||||
@@ -172,6 +177,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"startArrowhead": null,
|
||||
"startBinding": {
|
||||
"elementId": "diamond-1",
|
||||
"fixedPoint": null,
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
},
|
||||
@@ -328,9 +334,11 @@ 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,
|
||||
},
|
||||
@@ -361,6 +369,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"startArrowhead": null,
|
||||
"startBinding": {
|
||||
"elementId": "text-1",
|
||||
"fixedPoint": null,
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
},
|
||||
@@ -429,9 +438,11 @@ 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,
|
||||
},
|
||||
@@ -462,6 +473,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"startArrowhead": null,
|
||||
"startBinding": {
|
||||
"elementId": "id39",
|
||||
"fixedPoint": null,
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
},
|
||||
@@ -604,9 +616,11 @@ 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,
|
||||
},
|
||||
@@ -637,6 +651,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"startArrowhead": null,
|
||||
"startBinding": {
|
||||
"elementId": "id43",
|
||||
"fixedPoint": null,
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
},
|
||||
@@ -824,6 +839,7 @@ exports[`Test Transform > should transform linear elements 1`] = `
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
@@ -871,6 +887,7 @@ exports[`Test Transform > should transform linear elements 2`] = `
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "triangle",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
@@ -1463,9 +1480,11 @@ 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,
|
||||
},
|
||||
@@ -1498,6 +1517,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"startArrowhead": null,
|
||||
"startBinding": {
|
||||
"elementId": "Bob",
|
||||
"fixedPoint": null,
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
},
|
||||
@@ -1525,9 +1545,11 @@ 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,
|
||||
},
|
||||
@@ -1556,6 +1578,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"startArrowhead": null,
|
||||
"startBinding": {
|
||||
"elementId": "Bob",
|
||||
"fixedPoint": null,
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
},
|
||||
@@ -1837,6 +1860,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
},
|
||||
],
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
@@ -1889,6 +1913,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
},
|
||||
],
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
@@ -1941,6 +1966,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
},
|
||||
],
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
@@ -1993,6 +2019,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
},
|
||||
],
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import throttle from "lodash.throttle";
|
||||
import { ENV } from "../constants";
|
||||
import type { OrderedExcalidrawElement } from "../element/types";
|
||||
import { orderByFractionalIndex, syncInvalidIndices } from "../fractionalIndex";
|
||||
import {
|
||||
orderByFractionalIndex,
|
||||
syncInvalidIndices,
|
||||
validateFractionalIndices,
|
||||
} from "../fractionalIndex";
|
||||
import type { AppState } from "../types";
|
||||
import type { MakeBrand } from "../utility-types";
|
||||
import { arrayToMap } from "../utils";
|
||||
@@ -33,6 +39,37 @@ 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[],
|
||||
@@ -72,6 +109,8 @@ export const reconcileElements = (
|
||||
|
||||
const orderedElements = orderByFractionalIndex(reconciledElements);
|
||||
|
||||
validateIndicesThrottled(orderedElements, localElements, remoteElements);
|
||||
|
||||
// de-duplicate indices
|
||||
syncInvalidIndices(orderedElements);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawElementType,
|
||||
ExcalidrawLinearElement,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
} from "../element";
|
||||
import {
|
||||
isArrowElement,
|
||||
isElbowArrow,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
isUsingAdaptiveRadius,
|
||||
@@ -92,11 +94,21 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
|
||||
return DEFAULT_FONT_FAMILY;
|
||||
};
|
||||
|
||||
const repairBinding = (binding: PointBinding | null) => {
|
||||
const repairBinding = (
|
||||
element: ExcalidrawLinearElement,
|
||||
binding: PointBinding | null,
|
||||
): PointBinding | null => {
|
||||
if (!binding) {
|
||||
return null;
|
||||
}
|
||||
return { ...binding, focus: binding.focus || 0 };
|
||||
|
||||
return {
|
||||
...binding,
|
||||
focus: binding.focus || 0,
|
||||
fixedPoint: isElbowArrow(element)
|
||||
? binding.fixedPoint ?? ([0, 0] as [number, number])
|
||||
: null,
|
||||
};
|
||||
};
|
||||
|
||||
const restoreElementWithProperties = <
|
||||
@@ -242,11 +254,7 @@ const restoreElement = (
|
||||
// @ts-ignore LEGACY type
|
||||
// eslint-disable-next-line no-fallthrough
|
||||
case "draw":
|
||||
case "arrow": {
|
||||
const {
|
||||
startArrowhead = null,
|
||||
endArrowhead = element.type === "arrow" ? "arrow" : null,
|
||||
} = element;
|
||||
const { startArrowhead = null, endArrowhead = null } = element;
|
||||
let x = element.x;
|
||||
let y = element.y;
|
||||
let points = // migrate old arrow model to new one
|
||||
@@ -266,8 +274,8 @@ const restoreElement = (
|
||||
(element.type as ExcalidrawElementType | "draw") === "draw"
|
||||
? "line"
|
||||
: element.type,
|
||||
startBinding: repairBinding(element.startBinding),
|
||||
endBinding: repairBinding(element.endBinding),
|
||||
startBinding: repairBinding(element, element.startBinding),
|
||||
endBinding: repairBinding(element, element.endBinding),
|
||||
lastCommittedPoint: null,
|
||||
startArrowhead,
|
||||
endArrowhead,
|
||||
@@ -276,6 +284,36 @@ 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,6 +771,7 @@ describe("Test Transform", () => {
|
||||
const [arrow, rect] = excalidrawElements;
|
||||
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
|
||||
elementId: "rect-1",
|
||||
fixedPoint: null,
|
||||
focus: 0,
|
||||
gap: 205,
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { bindLinearElement } from "../element/binding";
|
||||
import type { ElementConstructorOpts } from "../element/newElement";
|
||||
import {
|
||||
newArrowElement,
|
||||
newFrameElement,
|
||||
newImageElement,
|
||||
newMagicFrameElement,
|
||||
@@ -51,6 +52,7 @@ 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";
|
||||
@@ -545,7 +547,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 = newLinearElement({
|
||||
excalidrawElement = newArrowElement({
|
||||
width,
|
||||
height,
|
||||
endArrowhead: "arrow",
|
||||
@@ -554,6 +556,7 @@ export const convertToExcalidrawElements = (
|
||||
[width, height],
|
||||
],
|
||||
...element,
|
||||
type: "arrow",
|
||||
});
|
||||
|
||||
Object.assign(
|
||||
@@ -655,7 +658,7 @@ export const convertToExcalidrawElements = (
|
||||
elementStore.add(container);
|
||||
elementStore.add(text);
|
||||
|
||||
if (container.type === "arrow") {
|
||||
if (isArrowElement(container)) {
|
||||
const originalStart =
|
||||
element.type === "arrow" ? element?.start : undefined;
|
||||
const originalEnd =
|
||||
@@ -674,7 +677,7 @@ export const convertToExcalidrawElements = (
|
||||
}
|
||||
const { linearElement, startBoundElement, endBoundElement } =
|
||||
bindLinearElementToElement(
|
||||
container as ExcalidrawArrowElement,
|
||||
container,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
elementStore,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import { getGridPoint } from "../math";
|
||||
import type Scene from "../scene/Scene";
|
||||
import {
|
||||
isArrowElement,
|
||||
isElbowArrow,
|
||||
isFrameLikeElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
@@ -18,9 +19,8 @@ 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,6 +28,25 @@ 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
|
||||
@@ -72,9 +91,14 @@ export const dragSelectedElements = (
|
||||
updateElementCoords(pointerDownState, textElement, adjustedOffset);
|
||||
}
|
||||
}
|
||||
updateBoundElements(element, scene.getElementsMapIncludingDeleted(), {
|
||||
simultaneouslyUpdated: Array.from(elementsToUpdate),
|
||||
});
|
||||
updateBoundElements(
|
||||
element,
|
||||
scene.getElementsMapIncludingDeleted(),
|
||||
scene,
|
||||
{
|
||||
simultaneouslyUpdated: Array.from(elementsToUpdate),
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
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;
|
||||
};
|
||||
@@ -11,6 +11,7 @@ export {
|
||||
newTextElement,
|
||||
refreshTextDimensions,
|
||||
newLinearElement,
|
||||
newArrowElement,
|
||||
newImageElement,
|
||||
duplicateElement,
|
||||
} from "./newElement";
|
||||
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
ExcalidrawTextElementWithContainer,
|
||||
ElementsMap,
|
||||
NonDeletedSceneElementsMap,
|
||||
OrderedExcalidrawElement,
|
||||
FixedPointBinding,
|
||||
} from "./types";
|
||||
import {
|
||||
distance2d,
|
||||
@@ -33,7 +35,6 @@ import type {
|
||||
AppState,
|
||||
PointerCoords,
|
||||
InteractiveCanvasAppState,
|
||||
AppClassProperties,
|
||||
} from "../types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
|
||||
@@ -43,13 +44,19 @@ import {
|
||||
isBindingEnabled,
|
||||
} from "./binding";
|
||||
import { tupleToCoors } from "../utils";
|
||||
import { isBindingElement } from "./typeChecks";
|
||||
import {
|
||||
isBindingElement,
|
||||
isElbowArrow,
|
||||
isFixedPointBinding,
|
||||
} 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;
|
||||
@@ -67,6 +74,7 @@ export class LinearElementEditor {
|
||||
prevSelectedPointsIndices: readonly number[] | null;
|
||||
/** index */
|
||||
lastClickedPoint: number;
|
||||
lastClickedIsEndPoint: boolean;
|
||||
origin: Readonly<{ x: number; y: number }> | null;
|
||||
segmentMidpoint: {
|
||||
value: Point | null;
|
||||
@@ -91,7 +99,9 @@ export class LinearElementEditor {
|
||||
this.elementId = element.id as string & {
|
||||
_brand: "excalidrawLinearElementId";
|
||||
};
|
||||
LinearElementEditor.normalizePoints(element);
|
||||
if (!arePointsEqual(element.points[0], [0, 0])) {
|
||||
console.error("Linear element is not normalized", Error().stack);
|
||||
}
|
||||
|
||||
this.selectedPointsIndices = null;
|
||||
this.lastUncommittedPoint = null;
|
||||
@@ -102,6 +112,7 @@ export class LinearElementEditor {
|
||||
this.pointerDownState = {
|
||||
prevSelectedPointsIndices: null,
|
||||
lastClickedPoint: -1,
|
||||
lastClickedIsEndPoint: false,
|
||||
origin: null,
|
||||
|
||||
segmentMidpoint: {
|
||||
@@ -162,8 +173,8 @@ export class LinearElementEditor {
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
const nextSelectedPoints = pointsSceneCoords.reduce(
|
||||
(acc: number[], point, index) => {
|
||||
const nextSelectedPoints = pointsSceneCoords
|
||||
.reduce((acc: number[], point, index) => {
|
||||
if (
|
||||
(point[0] >= selectionX1 &&
|
||||
point[0] <= selectionX2 &&
|
||||
@@ -175,9 +186,17 @@ export class LinearElementEditor {
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
}, [])
|
||||
.filter((index) => {
|
||||
if (
|
||||
isElbowArrow(element) &&
|
||||
index !== 0 &&
|
||||
index !== element.points.length - 1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
setState({
|
||||
editingLinearElement: {
|
||||
@@ -200,21 +219,52 @@ export class LinearElementEditor {
|
||||
pointSceneCoords: { x: number; y: number }[],
|
||||
) => void,
|
||||
linearElementEditor: LinearElementEditor,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
scene: Scene,
|
||||
): boolean {
|
||||
if (!linearElementEditor) {
|
||||
return false;
|
||||
}
|
||||
const { selectedPointsIndices, elementId } = linearElementEditor;
|
||||
const { elementId } = linearElementEditor;
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
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[
|
||||
linearElementEditor.pointerDownState.lastClickedPoint
|
||||
] as [number, number] | undefined;
|
||||
const draggingPoint = element.points[lastClickedPoint] as
|
||||
| [number, number]
|
||||
| undefined;
|
||||
|
||||
if (selectedPointsIndices && draggingPoint) {
|
||||
if (
|
||||
@@ -234,15 +284,17 @@ 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 ===
|
||||
linearElementEditor.pointerDownState.lastClickedPoint,
|
||||
},
|
||||
]);
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
[
|
||||
{
|
||||
index: selectedIndex,
|
||||
point: [width + referencePoint[0], height + referencePoint[1]],
|
||||
isDragging: selectedIndex === lastClickedPoint,
|
||||
},
|
||||
],
|
||||
scene,
|
||||
);
|
||||
} else {
|
||||
const newDraggingPointPosition = LinearElementEditor.createPointAt(
|
||||
element,
|
||||
@@ -259,8 +311,7 @@ export class LinearElementEditor {
|
||||
element,
|
||||
selectedPointsIndices.map((pointIndex) => {
|
||||
const newPointPosition =
|
||||
pointIndex ===
|
||||
linearElementEditor.pointerDownState.lastClickedPoint
|
||||
pointIndex === lastClickedPoint
|
||||
? LinearElementEditor.createPointAt(
|
||||
element,
|
||||
elementsMap,
|
||||
@@ -275,11 +326,10 @@ export class LinearElementEditor {
|
||||
return {
|
||||
index: pointIndex,
|
||||
point: newPointPosition,
|
||||
isDragging:
|
||||
pointIndex ===
|
||||
linearElementEditor.pointerDownState.lastClickedPoint,
|
||||
isDragging: pointIndex === lastClickedPoint,
|
||||
};
|
||||
}),
|
||||
scene,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -334,9 +384,10 @@ export class LinearElementEditor {
|
||||
event: PointerEvent,
|
||||
editingLinearElement: LinearElementEditor,
|
||||
appState: AppState,
|
||||
app: AppClassProperties,
|
||||
scene: Scene,
|
||||
): LinearElementEditor {
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const elements = scene.getNonDeletedElements();
|
||||
|
||||
const { elementId, selectedPointsIndices, isDragging, pointerDownState } =
|
||||
editingLinearElement;
|
||||
@@ -361,15 +412,19 @@ 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],
|
||||
},
|
||||
]);
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
[
|
||||
{
|
||||
index: selectedPoint,
|
||||
point:
|
||||
selectedPoint === 0
|
||||
? element.points[element.points.length - 1]
|
||||
: element.points[0],
|
||||
},
|
||||
],
|
||||
scene,
|
||||
);
|
||||
}
|
||||
|
||||
const bindingElement = isBindingEnabled(appState)
|
||||
@@ -381,6 +436,7 @@ export class LinearElementEditor {
|
||||
elementsMap,
|
||||
),
|
||||
),
|
||||
elements,
|
||||
elementsMap,
|
||||
)
|
||||
: null;
|
||||
@@ -645,13 +701,14 @@ export class LinearElementEditor {
|
||||
store: Store,
|
||||
scenePointer: { x: number; y: number },
|
||||
linearElementEditor: LinearElementEditor,
|
||||
app: AppClassProperties,
|
||||
scene: Scene,
|
||||
): {
|
||||
didAddPoint: boolean;
|
||||
hitElement: NonDeleted<ExcalidrawElement> | null;
|
||||
linearElementEditor: LinearElementEditor | null;
|
||||
} {
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const elements = scene.getNonDeletedElements();
|
||||
|
||||
const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
|
||||
didAddPoint: false,
|
||||
@@ -685,7 +742,10 @@ export class LinearElementEditor {
|
||||
);
|
||||
}
|
||||
if (event.altKey && appState.editingLinearElement) {
|
||||
if (linearElementEditor.lastUncommittedPoint == null) {
|
||||
if (
|
||||
linearElementEditor.lastUncommittedPoint == null ||
|
||||
!isElbowArrow(element)
|
||||
) {
|
||||
mutateElement(element, {
|
||||
points: [
|
||||
...element.points,
|
||||
@@ -706,6 +766,7 @@ export class LinearElementEditor {
|
||||
pointerDownState: {
|
||||
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
|
||||
lastClickedPoint: -1,
|
||||
lastClickedIsEndPoint: false,
|
||||
origin: { x: scenePointer.x, y: scenePointer.y },
|
||||
segmentMidpoint: {
|
||||
value: segmentMidpoint,
|
||||
@@ -717,6 +778,7 @@ export class LinearElementEditor {
|
||||
lastUncommittedPoint: null,
|
||||
endBindingElement: getHoveredElementForBinding(
|
||||
scenePointer,
|
||||
elements,
|
||||
elementsMap,
|
||||
),
|
||||
};
|
||||
@@ -749,6 +811,7 @@ export class LinearElementEditor {
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
elementsMap,
|
||||
scene,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -781,6 +844,7 @@ 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,
|
||||
@@ -815,12 +879,13 @@ export class LinearElementEditor {
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
appState: AppState,
|
||||
elementsMap: ElementsMap,
|
||||
scene: Scene,
|
||||
): LinearElementEditor | null {
|
||||
if (!appState.editingLinearElement) {
|
||||
return null;
|
||||
}
|
||||
const { elementId, lastUncommittedPoint } = appState.editingLinearElement;
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
if (!element) {
|
||||
return appState.editingLinearElement;
|
||||
@@ -831,7 +896,7 @@ export class LinearElementEditor {
|
||||
|
||||
if (!event.altKey) {
|
||||
if (lastPoint === lastUncommittedPoint) {
|
||||
LinearElementEditor.deletePoints(element, [points.length - 1]);
|
||||
LinearElementEditor.deletePoints(element, [points.length - 1], scene);
|
||||
}
|
||||
return {
|
||||
...appState.editingLinearElement,
|
||||
@@ -862,19 +927,30 @@ export class LinearElementEditor {
|
||||
elementsMap,
|
||||
scenePointerX - appState.editingLinearElement.pointerOffset.x,
|
||||
scenePointerY - appState.editingLinearElement.pointerOffset.y,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize,
|
||||
event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
|
||||
? null
|
||||
: appState.gridSize,
|
||||
);
|
||||
}
|
||||
|
||||
if (lastPoint === lastUncommittedPoint) {
|
||||
LinearElementEditor.movePoints(element, [
|
||||
{
|
||||
index: element.points.length - 1,
|
||||
point: newPoint,
|
||||
},
|
||||
]);
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
[
|
||||
{
|
||||
index: element.points.length - 1,
|
||||
point: newPoint,
|
||||
},
|
||||
],
|
||||
scene,
|
||||
);
|
||||
} else {
|
||||
LinearElementEditor.addPoints(element, appState, [{ point: newPoint }]);
|
||||
LinearElementEditor.addPoints(
|
||||
element,
|
||||
appState,
|
||||
[{ point: newPoint }],
|
||||
scene,
|
||||
);
|
||||
}
|
||||
return {
|
||||
...appState.editingLinearElement,
|
||||
@@ -938,6 +1014,11 @@ 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;
|
||||
@@ -1028,13 +1109,13 @@ export class LinearElementEditor {
|
||||
mutateElement(element, LinearElementEditor.getNormalizedPoints(element));
|
||||
}
|
||||
|
||||
static duplicateSelectedPoints(appState: AppState, elementsMap: ElementsMap) {
|
||||
static duplicateSelectedPoints(appState: AppState, scene: Scene) {
|
||||
if (!appState.editingLinearElement) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { selectedPointsIndices, elementId } = appState.editingLinearElement;
|
||||
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
|
||||
if (!element || selectedPointsIndices === null) {
|
||||
@@ -1077,12 +1158,16 @@ 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],
|
||||
},
|
||||
]);
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
[
|
||||
{
|
||||
index: element.points.length - 1,
|
||||
point: [lastPoint[0] + 30, lastPoint[1] + 30],
|
||||
},
|
||||
],
|
||||
scene,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1099,6 +1184,7 @@ export class LinearElementEditor {
|
||||
static deletePoints(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
pointIndices: readonly number[],
|
||||
scene: Scene,
|
||||
) {
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
@@ -1126,25 +1212,46 @@ export class LinearElementEditor {
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
|
||||
LinearElementEditor._updatePoints(
|
||||
element,
|
||||
nextPoints,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scene,
|
||||
);
|
||||
}
|
||||
|
||||
static addPoints(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
appState: AppState,
|
||||
targetPoints: { point: Point }[],
|
||||
scene: Scene,
|
||||
) {
|
||||
const offsetX = 0;
|
||||
const offsetY = 0;
|
||||
|
||||
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
|
||||
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
|
||||
LinearElementEditor._updatePoints(
|
||||
element,
|
||||
nextPoints,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scene,
|
||||
);
|
||||
}
|
||||
|
||||
static movePoints(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
targetPoints: { index: number; point: Point; isDragging?: boolean }[],
|
||||
otherUpdates?: { startBinding?: PointBinding; endBinding?: PointBinding },
|
||||
scene: Scene,
|
||||
otherUpdates?: {
|
||||
startBinding?: PointBinding | null;
|
||||
endBinding?: PointBinding | null;
|
||||
},
|
||||
options?: {
|
||||
changedElements?: Map<string, OrderedExcalidrawElement>;
|
||||
isDragging?: boolean;
|
||||
},
|
||||
) {
|
||||
const { points } = element;
|
||||
|
||||
@@ -1192,7 +1299,16 @@ export class LinearElementEditor {
|
||||
nextPoints,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scene,
|
||||
otherUpdates,
|
||||
{
|
||||
isDragging: targetPoints.reduce(
|
||||
(dragging, targetPoint): boolean =>
|
||||
dragging || targetPoint.isDragging === true,
|
||||
false,
|
||||
),
|
||||
changedElements: options?.changedElements,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1207,6 +1323,11 @@ export class LinearElementEditor {
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
// Elbow arrows don't allow midpoints
|
||||
if (element && isElbowArrow(element)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!element) {
|
||||
return false;
|
||||
}
|
||||
@@ -1266,7 +1387,7 @@ export class LinearElementEditor {
|
||||
elementsMap,
|
||||
pointerCoords.x,
|
||||
pointerCoords.y,
|
||||
snapToGrid ? appState.gridSize : null,
|
||||
snapToGrid && !isElbowArrow(element) ? appState.gridSize : null,
|
||||
);
|
||||
const points = [
|
||||
...element.points.slice(0, segmentMidpoint.index!),
|
||||
@@ -1295,23 +1416,61 @@ export class LinearElementEditor {
|
||||
nextPoints: readonly Point[],
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
otherUpdates?: { startBinding?: PointBinding; endBinding?: PointBinding },
|
||||
scene: Scene,
|
||||
otherUpdates?: {
|
||||
startBinding?: PointBinding | null;
|
||||
endBinding?: PointBinding | null;
|
||||
},
|
||||
options?: {
|
||||
changedElements?: Map<string, OrderedExcalidrawElement>;
|
||||
isDragging?: boolean;
|
||||
},
|
||||
) {
|
||||
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],
|
||||
});
|
||||
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,
|
||||
scene,
|
||||
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],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static _getShiftLockedDelta(
|
||||
@@ -1327,6 +1486,13 @@ 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,6 +121,7 @@ describe("duplicating multiple elements", () => {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -131,6 +132,7 @@ describe("duplicating multiple elements", () => {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
},
|
||||
boundElements: [{ id: "text2", type: "text" }],
|
||||
});
|
||||
@@ -247,6 +249,7 @@ describe("duplicating multiple elements", () => {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -263,11 +266,13 @@ 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],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -278,11 +283,13 @@ 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],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
ExcalidrawMagicFrameElement,
|
||||
ExcalidrawIframeElement,
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
} from "./types";
|
||||
import {
|
||||
arrayToMap,
|
||||
@@ -388,8 +389,6 @@ export const newFreeDrawElement = (
|
||||
export const newLinearElement = (
|
||||
opts: {
|
||||
type: ExcalidrawLinearElement["type"];
|
||||
startArrowhead?: Arrowhead | null;
|
||||
endArrowhead?: Arrowhead | null;
|
||||
points?: ExcalidrawLinearElement["points"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawLinearElement> => {
|
||||
@@ -399,8 +398,29 @@ 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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import {
|
||||
isArrowElement,
|
||||
isBoundToContainer,
|
||||
isElbowArrow,
|
||||
isFrameLikeElement,
|
||||
isFreeDrawElement,
|
||||
isImageElement,
|
||||
@@ -30,7 +31,7 @@ import {
|
||||
} from "./typeChecks";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { getFontString } from "../utils";
|
||||
import { updateBoundElements } from "./binding";
|
||||
import { getArrowLocalFixedPoints, updateBoundElements } from "./binding";
|
||||
import type {
|
||||
MaybeTransformHandleType,
|
||||
TransformHandleDirection,
|
||||
@@ -51,6 +52,7 @@ import {
|
||||
} from "./textElement";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { isInGroup } from "../groups";
|
||||
import { mutateElbowArrow } from "./routing";
|
||||
|
||||
export const normalizeAngle = (angle: number): number => {
|
||||
if (angle < 0) {
|
||||
@@ -75,18 +77,21 @@ export const transformElements = (
|
||||
pointerY: number,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
scene: Scene,
|
||||
) => {
|
||||
if (selectedElements.length === 1) {
|
||||
const [element] = selectedElements;
|
||||
if (transformHandleType === "rotation") {
|
||||
rotateSingleElement(
|
||||
element,
|
||||
elementsMap,
|
||||
pointerX,
|
||||
pointerY,
|
||||
shouldRotateWithDiscreteAngle,
|
||||
);
|
||||
updateBoundElements(element, elementsMap);
|
||||
if (!isElbowArrow(element)) {
|
||||
rotateSingleElement(
|
||||
element,
|
||||
elementsMap,
|
||||
pointerX,
|
||||
pointerY,
|
||||
shouldRotateWithDiscreteAngle,
|
||||
);
|
||||
updateBoundElements(element, elementsMap, scene);
|
||||
}
|
||||
} else if (isTextElement(element) && transformHandleType) {
|
||||
resizeSingleTextElement(
|
||||
originalElements,
|
||||
@@ -97,7 +102,7 @@ export const transformElements = (
|
||||
pointerX,
|
||||
pointerY,
|
||||
);
|
||||
updateBoundElements(element, elementsMap);
|
||||
updateBoundElements(element, elementsMap, scene);
|
||||
} else if (transformHandleType) {
|
||||
resizeSingleElement(
|
||||
originalElements,
|
||||
@@ -108,6 +113,7 @@ export const transformElements = (
|
||||
shouldResizeFromCenter,
|
||||
pointerX,
|
||||
pointerY,
|
||||
scene,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,6 +129,7 @@ export const transformElements = (
|
||||
shouldRotateWithDiscreteAngle,
|
||||
centerX,
|
||||
centerY,
|
||||
scene,
|
||||
);
|
||||
return true;
|
||||
} else if (transformHandleType) {
|
||||
@@ -135,6 +142,7 @@ export const transformElements = (
|
||||
shouldMaintainAspectRatio,
|
||||
pointerX,
|
||||
pointerY,
|
||||
scene,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -431,7 +439,17 @@ export const resizeSingleElement = (
|
||||
shouldResizeFromCenter: boolean,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
scene: Scene,
|
||||
) => {
|
||||
// Elbow arrows cannot be resized when bound on either end
|
||||
if (
|
||||
isArrowElement(element) &&
|
||||
isElbowArrow(element) &&
|
||||
(element.startBinding || element.endBinding)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stateAtResizeStart = originalElements.get(element.id)!;
|
||||
// Gets bounds corners
|
||||
const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
|
||||
@@ -701,8 +719,11 @@ export const resizeSingleElement = (
|
||||
) {
|
||||
mutateElement(element, resizedElement);
|
||||
|
||||
updateBoundElements(element, elementsMap, {
|
||||
newSize: { width: resizedElement.width, height: resizedElement.height },
|
||||
updateBoundElements(element, elementsMap, scene, {
|
||||
oldSize: {
|
||||
width: stateAtResizeStart.width,
|
||||
height: stateAtResizeStart.height,
|
||||
},
|
||||
});
|
||||
|
||||
if (boundTextElement && boundTextFont != null) {
|
||||
@@ -728,6 +749,7 @@ export const resizeMultipleElements = (
|
||||
shouldMaintainAspectRatio: boolean,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
scene: Scene,
|
||||
) => {
|
||||
// map selected elements to the original elements. While it never should
|
||||
// happen that pointerDownState.originalElements won't contain the selected
|
||||
@@ -955,13 +977,20 @@ export const resizeMultipleElements = (
|
||||
element,
|
||||
update: { boundTextFontSize, ...update },
|
||||
} of elementsAndUpdates) {
|
||||
const { width, height, angle } = update;
|
||||
const { angle } = update;
|
||||
const { width: oldWidth, height: oldHeight } = element;
|
||||
|
||||
mutateElement(element, update, false);
|
||||
|
||||
updateBoundElements(element, elementsMap, {
|
||||
if (isArrowElement(element) && isElbowArrow(element)) {
|
||||
mutateElbowArrow(element, scene, element.points, undefined, undefined, {
|
||||
informMutation: false,
|
||||
});
|
||||
}
|
||||
|
||||
updateBoundElements(element, elementsMap, scene, {
|
||||
simultaneouslyUpdated: elementsToUpdate,
|
||||
newSize: { width, height },
|
||||
oldSize: { width: oldWidth, height: oldHeight },
|
||||
});
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
@@ -990,6 +1019,7 @@ const rotateMultipleElements = (
|
||||
shouldRotateWithDiscreteAngle: boolean,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
scene: Scene,
|
||||
) => {
|
||||
let centerAngle =
|
||||
(5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX);
|
||||
@@ -1013,16 +1043,23 @@ const rotateMultipleElements = (
|
||||
centerY,
|
||||
centerAngle + origAngle - element.angle,
|
||||
);
|
||||
mutateElement(
|
||||
element,
|
||||
{
|
||||
x: element.x + (rotatedCX - cx),
|
||||
y: element.y + (rotatedCY - cy),
|
||||
angle: normalizeAngle(centerAngle + origAngle),
|
||||
},
|
||||
false,
|
||||
);
|
||||
updateBoundElements(element, elementsMap, {
|
||||
|
||||
if (isArrowElement(element) && isElbowArrow(element)) {
|
||||
const points = getArrowLocalFixedPoints(element, elementsMap);
|
||||
mutateElbowArrow(element, scene, points);
|
||||
} else {
|
||||
mutateElement(
|
||||
element,
|
||||
{
|
||||
x: element.x + (rotatedCX - cx),
|
||||
y: element.y + (rotatedCY - cy),
|
||||
angle: normalizeAngle(centerAngle + origAngle),
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
updateBoundElements(element, elementsMap, scene, {
|
||||
simultaneouslyUpdated: elements,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
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 editInput = (input: HTMLInputElement, value: string) => {
|
||||
input.focus();
|
||||
fireEvent.change(input, { target: { value } });
|
||||
input.blur();
|
||||
};
|
||||
|
||||
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, [
|
||||
[-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, scene, [
|
||||
[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;
|
||||
editInput(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
@@ -9,7 +9,11 @@ import type { Bounds } from "./bounds";
|
||||
import { getElementAbsoluteCoords } from "./bounds";
|
||||
import { rotate } from "../math";
|
||||
import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
|
||||
import { isFrameLikeElement, isLinearElement } from "./typeChecks";
|
||||
import {
|
||||
isElbowArrow,
|
||||
isFrameLikeElement,
|
||||
isLinearElement,
|
||||
} from "./typeChecks";
|
||||
import {
|
||||
DEFAULT_TRANSFORM_HANDLE_SPACING,
|
||||
isAndroid,
|
||||
@@ -262,7 +266,11 @@ 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) {
|
||||
if (
|
||||
element.locked ||
|
||||
// Elbow arrows cannot be rotated
|
||||
isElbowArrow(element)
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -312,6 +320,9 @@ export const shouldShowBoundingBox = (
|
||||
return true;
|
||||
}
|
||||
const element = elements[0];
|
||||
if (isElbowArrow(element)) {
|
||||
return false;
|
||||
}
|
||||
if (!isLinearElement(element)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ import type {
|
||||
ExcalidrawIframeLikeElement,
|
||||
ExcalidrawMagicFrameElement,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
PointBinding,
|
||||
FixedPointBinding,
|
||||
} from "./types";
|
||||
|
||||
export const isInitializedImageElement = (
|
||||
@@ -106,6 +109,12 @@ 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 => {
|
||||
@@ -150,6 +159,22 @@ 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,
|
||||
@@ -263,3 +288,9 @@ export const getDefaultRoundnessTypeForElement = (
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const isFixedPointBinding = (
|
||||
binding: PointBinding,
|
||||
): binding is FixedPointBinding => {
|
||||
return binding.fixedPoint != null;
|
||||
};
|
||||
|
||||
@@ -6,7 +6,12 @@ import type {
|
||||
THEME,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import type { MakeBrand, MarkNonNullable, ValueOf } from "../utility-types";
|
||||
import type {
|
||||
MakeBrand,
|
||||
MarkNonNullable,
|
||||
Merge,
|
||||
ValueOf,
|
||||
} from "../utility-types";
|
||||
import type { MagicCacheData } from "../data/magic";
|
||||
|
||||
export type ChartType = "bar" | "line";
|
||||
@@ -228,12 +233,22 @@ 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"
|
||||
@@ -259,8 +274,18 @@ 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";
|
||||
|
||||
@@ -1,41 +1,29 @@
|
||||
import { stringToBase64, toByteString } from "../data/encode";
|
||||
import { LOCAL_FONT_PROTOCOL } from "./metadata";
|
||||
|
||||
export interface Font {
|
||||
url: URL;
|
||||
urls: URL[];
|
||||
fontFace: FontFace;
|
||||
getContent(): Promise<string>;
|
||||
}
|
||||
export const UNPKG_PROD_URL = `https://unpkg.com/${
|
||||
import.meta.env.VITE_PKG_NAME
|
||||
}@${import.meta.env.PKG_VERSION}/dist/prod/`;
|
||||
? `${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/`;
|
||||
|
||||
export class ExcalidrawFont implements Font {
|
||||
public readonly url: URL;
|
||||
public readonly urls: URL[];
|
||||
public readonly fontFace: FontFace;
|
||||
|
||||
constructor(family: string, uri: string, descriptors?: FontFaceDescriptors) {
|
||||
// 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;
|
||||
this.urls = ExcalidrawFont.createUrls(uri);
|
||||
|
||||
// fallback to unpkg to form a valid URL in case of a passed relative assetUrl
|
||||
let baseUrlBuilder = window.EXCALIDRAW_ASSET_PATH || UNPKG_PROD_URL;
|
||||
const sources = this.urls
|
||||
.map((url) => `url(${url}) ${ExcalidrawFont.getFormat(url)}`)
|
||||
.join(", ");
|
||||
|
||||
// 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})`, {
|
||||
this.fontFace = new FontFace(family, sources, {
|
||||
display: "swap",
|
||||
style: "normal",
|
||||
weight: "400",
|
||||
@@ -44,35 +32,128 @@ export class ExcalidrawFont implements Font {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches woff2 content based on the registered url (browser).
|
||||
* Tries to fetch woff2 content, based on the registered urls.
|
||||
* Returns last defined url in case of errors.
|
||||
*
|
||||
* Use dataurl outside the browser environment.
|
||||
* Note: uses browser APIs for base64 encoding - use dataurl outside the browser environment.
|
||||
*/
|
||||
public async getContent(): Promise<string> {
|
||||
if (this.url.protocol === "data:") {
|
||||
// it's dataurl, the font is inlined as base64, no need to fetch
|
||||
return this.url.toString();
|
||||
let i = 0;
|
||||
const errorMessages = [];
|
||||
|
||||
while (i < this.urls.length) {
|
||||
const url = this.urls[i];
|
||||
|
||||
if (url.protocol === "data:") {
|
||||
// it's dataurl, the font is inlined as base64, no need to fetch
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: "font/woff2",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const mimeType = await response.headers.get("Content-Type");
|
||||
const buffer = await response.arrayBuffer();
|
||||
|
||||
return `data:${mimeType};base64,${await stringToBase64(
|
||||
await toByteString(buffer),
|
||||
true,
|
||||
)}`;
|
||||
}
|
||||
|
||||
// response not ok, try to continue
|
||||
errorMessages.push(
|
||||
`"${url.toString()}" returned status "${response.status}"`,
|
||||
);
|
||||
} catch (e) {
|
||||
errorMessages.push(`"${url.toString()}" returned error "${e}"`);
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
const response = await fetch(this.url, {
|
||||
headers: {
|
||||
Accept: "font/woff2",
|
||||
},
|
||||
});
|
||||
console.error(
|
||||
`Failed to fetch font "${
|
||||
this.fontFace.family
|
||||
}" from urls "${this.urls.toString()}`,
|
||||
JSON.stringify(errorMessages, undefined, 2),
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
`Couldn't fetch font-family "${this.fontFace.family}" from url "${this.url}"`,
|
||||
response,
|
||||
// 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() : "";
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
const mimeType = await response.headers.get("Content-Type");
|
||||
const buffer = await response.arrayBuffer();
|
||||
// fallback url for bundled fonts
|
||||
urls.push(new URL(assetUrl, UNPKG_PROD_URL));
|
||||
|
||||
return `data:${mimeType};base64,${await stringToBase64(
|
||||
await toByteString(buffer),
|
||||
true,
|
||||
)}`;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,6 @@
|
||||
/* Only UI fonts here, which are needed before the editor initializes. */
|
||||
/* These also cannot be preprended with `EXCALIDRAW_ASSET_PATH`. */
|
||||
/* These cannot be dynamically prepended with `EXCALIDRAW_ASSET_PATH`. */
|
||||
/* WARN: The following content is replaced during excalidraw-app build */
|
||||
|
||||
@font-face {
|
||||
font-family: "Assistant";
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type Scene from "../scene/Scene";
|
||||
import type { ValueOf } from "../utility-types";
|
||||
import type { ExcalidrawTextElement, FontFamilyValues } from "../element/types";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
FontFamilyValues,
|
||||
} from "../element/types";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import { isTextElement } from "../element";
|
||||
import { getFontString } from "../utils";
|
||||
@@ -16,7 +20,7 @@ import { getContainerElement } from "../element/textElement";
|
||||
|
||||
import Virgil from "./assets/Virgil-Regular.woff2";
|
||||
import Excalifont from "./assets/Excalifont-Regular.woff2";
|
||||
import Cascadia from "./assets/CascadiaMono-Regular.woff2";
|
||||
import Cascadia from "./assets/CascadiaCode-Regular.woff2";
|
||||
import ComicShanns from "./assets/ComicShanns-Regular.woff2";
|
||||
import LiberationSans from "./assets/LiberationSans-Regular.woff2";
|
||||
|
||||
@@ -39,15 +43,24 @@ export class Fonts {
|
||||
number,
|
||||
{
|
||||
metadata: FontMetadata;
|
||||
fontFaces: Font[];
|
||||
fonts: 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;
|
||||
@@ -59,17 +72,6 @@ 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;
|
||||
}
|
||||
@@ -119,21 +121,52 @@ export class Fonts {
|
||||
}
|
||||
};
|
||||
|
||||
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)
|
||||
) {
|
||||
/**
|
||||
* 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)) {
|
||||
window.document.fonts.add(fontFace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loaded = await Promise.all(
|
||||
this.sceneFamilies.map(async (fontFamily) => {
|
||||
const loadedFontFaces = await Promise.all(
|
||||
fontFamilies.map(async (fontFamily) => {
|
||||
const fontString = getFontString({
|
||||
fontFamily,
|
||||
fontSize: 16,
|
||||
@@ -148,8 +181,10 @@ 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}" with error "${e}", given the following registered font:`,
|
||||
JSON.stringify(Fonts.registered.get(fontFamily), undefined, 2),
|
||||
`Failed to load font "${fontString}" from urls "${Fonts.registered
|
||||
.get(fontFamily)
|
||||
?.fonts.map((x) => x.urls)}"`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -158,8 +193,8 @@ export class Fonts {
|
||||
}),
|
||||
);
|
||||
|
||||
this.onLoaded(loaded.flat().filter(Boolean) as FontFace[]);
|
||||
};
|
||||
return loadedFontFaces.flat().filter(Boolean) as FontFace[];
|
||||
}
|
||||
|
||||
/**
|
||||
* WARN: should be called just once on init, even across multiple instances.
|
||||
@@ -168,10 +203,11 @@ export class Fonts {
|
||||
const fonts = {
|
||||
registered: new Map<
|
||||
ValueOf<typeof FONT_FAMILY>,
|
||||
{ metadata: FontMetadata; fontFaces: Font[] }
|
||||
{ metadata: FontMetadata; fonts: 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], {
|
||||
@@ -236,8 +272,23 @@ 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>()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -253,7 +304,7 @@ function register(
|
||||
| {
|
||||
registered: Map<
|
||||
ValueOf<typeof FONT_FAMILY>,
|
||||
{ metadata: FontMetadata; fontFaces: Font[] }
|
||||
{ metadata: FontMetadata; fonts: Font[] }
|
||||
>;
|
||||
},
|
||||
family: string,
|
||||
@@ -267,7 +318,7 @@ function register(
|
||||
if (!registeredFamily) {
|
||||
this.registered.set(familyId, {
|
||||
metadata,
|
||||
fontFaces: params.map(
|
||||
fonts: params.map(
|
||||
({ uri, descriptors }) => new ExcalidrawFont(family, uri, descriptors),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -27,6 +27,8 @@ 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> = {
|
||||
@@ -85,6 +87,7 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
|
||||
},
|
||||
icon: FontFamilyNormalIcon,
|
||||
deprecated: true,
|
||||
local: true,
|
||||
},
|
||||
[FONT_FAMILY.Cascadia]: {
|
||||
metrics: {
|
||||
|
||||
@@ -6,6 +6,9 @@ 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:
|
||||
@@ -30,17 +33,84 @@ import { InvalidFractionalIndexError } from "./errors";
|
||||
* @throws `InvalidFractionalIndexError` if invalid index is detected.
|
||||
*/
|
||||
export const validateFractionalIndices = (
|
||||
indices: (ExcalidrawElement["index"] | undefined)[],
|
||||
elements: readonly ExcalidrawElement[],
|
||||
{
|
||||
shouldThrow = false,
|
||||
includeBoundTextValidation = false,
|
||||
ignoreLogs,
|
||||
reconciliationContext,
|
||||
}: {
|
||||
shouldThrow: boolean;
|
||||
includeBoundTextValidation: boolean;
|
||||
ignoreLogs?: true;
|
||||
reconciliationContext?: {
|
||||
localElements: ReadonlyArray<ExcalidrawElement>;
|
||||
remoteElements: ReadonlyArray<ExcalidrawElement>;
|
||||
};
|
||||
},
|
||||
) => {
|
||||
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)) {
|
||||
throw new InvalidFractionalIndexError(
|
||||
`Fractional indices invariant for element has been compromised - ["${predecessorIndex}", "${index}", "${successorIndex}"] [predecessor, current, successor]`,
|
||||
errorMessages.push(
|
||||
`Fractional indices invariant has been compromised: "${stringifyElement(
|
||||
elements[i - 1],
|
||||
)}", "${stringifyElement(elements[i])}", "${stringifyElement(
|
||||
elements[i + 1],
|
||||
)}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -83,10 +153,19 @@ 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(
|
||||
elements.map((x) => elementsUpdates.get(x)?.index || x.index),
|
||||
elementsCandidates,
|
||||
// we don't autofix invalid bound text indices, hence don't include it in the validation
|
||||
{
|
||||
includeBoundTextValidation: false,
|
||||
shouldThrow: true,
|
||||
ignoreLogs: true,
|
||||
},
|
||||
);
|
||||
|
||||
// split mutation so we don't end up in an incosistent state
|
||||
|
||||
@@ -755,15 +755,12 @@ export const isElementInFrame = (
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getFrameLikeTitle = (
|
||||
element: ExcalidrawFrameLikeElement,
|
||||
frameIdx: number,
|
||||
) => {
|
||||
export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
|
||||
// TODO name frames "AI" only if specific to AI frames
|
||||
return element.name === null
|
||||
? isFrameElement(element)
|
||||
? `Frame ${frameIdx}`
|
||||
: `AI Frame $${frameIdx}`
|
||||
? "Frame"
|
||||
: "AI Frame"
|
||||
: element.name;
|
||||
};
|
||||
|
||||
|
||||
Vendored
+2
-1
@@ -1,9 +1,10 @@
|
||||
interface Window {
|
||||
ClipboardItem: any;
|
||||
__EXCALIDRAW_SHA__: string | undefined;
|
||||
EXCALIDRAW_ASSET_PATH: string | undefined;
|
||||
EXCALIDRAW_ASSET_PATH: string | string[] | undefined;
|
||||
EXCALIDRAW_EXPORT_SOURCE: string;
|
||||
EXCALIDRAW_THROTTLE_RENDER: boolean | undefined;
|
||||
DEBUG_FRACTIONAL_INDICES: boolean | undefined;
|
||||
gtag: Function;
|
||||
sa_event: Function;
|
||||
fathom: { trackEvent: Function };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AppStateChange, ElementsChange } from "./change";
|
||||
import type { SceneElementsMap } from "./element/types";
|
||||
import { Emitter } from "./emitter";
|
||||
import type Scene from "./scene/Scene";
|
||||
import type { Snapshot } from "./store";
|
||||
import type { AppState } from "./types";
|
||||
|
||||
@@ -64,6 +65,7 @@ export class History {
|
||||
elements: SceneElementsMap,
|
||||
appState: AppState,
|
||||
snapshot: Readonly<Snapshot>,
|
||||
scene: Scene,
|
||||
) {
|
||||
return this.perform(
|
||||
elements,
|
||||
@@ -71,6 +73,7 @@ export class History {
|
||||
snapshot,
|
||||
() => History.pop(this.undoStack),
|
||||
(entry: HistoryEntry) => History.push(this.redoStack, entry, elements),
|
||||
scene,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,6 +81,7 @@ export class History {
|
||||
elements: SceneElementsMap,
|
||||
appState: AppState,
|
||||
snapshot: Readonly<Snapshot>,
|
||||
scene: Scene,
|
||||
) {
|
||||
return this.perform(
|
||||
elements,
|
||||
@@ -85,6 +89,7 @@ export class History {
|
||||
snapshot,
|
||||
() => History.pop(this.redoStack),
|
||||
(entry: HistoryEntry) => History.push(this.undoStack, entry, elements),
|
||||
scene,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,6 +99,7 @@ export class History {
|
||||
snapshot: Readonly<Snapshot>,
|
||||
pop: () => HistoryEntry | null,
|
||||
push: (entry: HistoryEntry) => void,
|
||||
scene: Scene,
|
||||
): [SceneElementsMap, AppState] | void {
|
||||
try {
|
||||
let historyEntry = pop();
|
||||
@@ -110,7 +116,7 @@ export class History {
|
||||
while (historyEntry) {
|
||||
try {
|
||||
[nextElements, nextAppState, containsVisibleChange] =
|
||||
historyEntry.applyTo(nextElements, nextAppState, snapshot);
|
||||
historyEntry.applyTo(nextElements, nextAppState, snapshot, scene);
|
||||
} finally {
|
||||
// make sure to always push / pop, even if the increment is corrupted
|
||||
push(historyEntry);
|
||||
@@ -181,9 +187,10 @@ export class HistoryEntry {
|
||||
elements: SceneElementsMap,
|
||||
appState: AppState,
|
||||
snapshot: Readonly<Snapshot>,
|
||||
scene: Scene,
|
||||
): [SceneElementsMap, AppState, boolean] {
|
||||
const [nextElements, elementsContainVisibleChange] =
|
||||
this.elementsChange.applyTo(elements, snapshot.elements);
|
||||
this.elementsChange.applyTo(elements, snapshot.elements, scene);
|
||||
|
||||
const [nextAppState, appStateContainsVisibleChange] =
|
||||
this.appStateChange.applyTo(appState, nextElements);
|
||||
|
||||
@@ -46,6 +46,10 @@
|
||||
"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\"",
|
||||
@@ -295,6 +299,7 @@
|
||||
"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,4 +1,9 @@
|
||||
import { rangeIntersection, rangesOverlap, rotate } from "./math";
|
||||
import {
|
||||
isPointOnSymmetricArc,
|
||||
rangeIntersection,
|
||||
rangesOverlap,
|
||||
rotate,
|
||||
} from "./math";
|
||||
|
||||
describe("rotate", () => {
|
||||
it("should rotate over (x2, y2) and return the rotated coordinates for (x1, y1)", () => {
|
||||
@@ -53,3 +58,42 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,9 +10,11 @@ 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
|
||||
@@ -153,6 +155,12 @@ 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];
|
||||
};
|
||||
@@ -519,3 +527,179 @@ 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);
|
||||
};
|
||||
|
||||
@@ -87,14 +87,14 @@
|
||||
"tunnel-rat": "0.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
"@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",
|
||||
"@size-limit/preset-big-lib": "9.0.0",
|
||||
"@testing-library/jest-dom": "5.16.2",
|
||||
"@testing-library/react": "12.1.5",
|
||||
@@ -115,8 +115,6 @@
|
||||
"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,6 +48,8 @@ import {
|
||||
} from "./helpers";
|
||||
import oc from "open-color";
|
||||
import {
|
||||
isArrowElement,
|
||||
isElbowArrow,
|
||||
isFrameLikeElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
@@ -67,6 +69,7 @@ import type {
|
||||
InteractiveSceneRenderConfig,
|
||||
RenderableElementsMap,
|
||||
} from "../scene/types";
|
||||
import { getCornerRadius } from "../math";
|
||||
|
||||
const renderLinearElementPointHighlight = (
|
||||
context: CanvasRenderingContext2D,
|
||||
@@ -212,13 +215,18 @@ const renderBindingHighlightForBindableElement = (
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
const width = x2 - x1;
|
||||
const height = y2 - y1;
|
||||
const threshold = maxBindingGap(element, width, height);
|
||||
const thickness = 10;
|
||||
|
||||
// So that we don't overlap the element itself
|
||||
const strokeOffset = 4;
|
||||
context.strokeStyle = "rgba(0,0,0,.05)";
|
||||
context.lineWidth = threshold - strokeOffset;
|
||||
const padding = strokeOffset / 2 + threshold / 2;
|
||||
context.lineWidth = thickness - strokeOffset;
|
||||
const padding = strokeOffset / 2 + thickness / 2;
|
||||
|
||||
const radius = getCornerRadius(
|
||||
Math.min(element.width, element.height),
|
||||
element,
|
||||
);
|
||||
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
@@ -237,6 +245,8 @@ const renderBindingHighlightForBindableElement = (
|
||||
x1 + width / 2,
|
||||
y1 + height / 2,
|
||||
element.angle,
|
||||
undefined,
|
||||
radius,
|
||||
);
|
||||
break;
|
||||
case "diamond":
|
||||
@@ -474,6 +484,10 @@ 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);
|
||||
|
||||
@@ -727,7 +741,13 @@ const _renderInteractiveScene = ({
|
||||
|
||||
if (
|
||||
appState.selectedLinearElement &&
|
||||
appState.selectedLinearElement.hoverPointIndex >= 0
|
||||
appState.selectedLinearElement.hoverPointIndex >= 0 &&
|
||||
!(
|
||||
isElbowArrow(selectedElements[0]) &&
|
||||
appState.selectedLinearElement.hoverPointIndex > 0 &&
|
||||
appState.selectedLinearElement.hoverPointIndex <
|
||||
selectedElements[0].points.length - 1
|
||||
)
|
||||
) {
|
||||
renderLinearElementPointHighlight(context, appState, elementsMap);
|
||||
}
|
||||
@@ -771,27 +791,39 @@ 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 (remoteClients) {
|
||||
selectionColors.push(
|
||||
...remoteClients.map((socketId) => {
|
||||
const background = getClientColor(
|
||||
socketId,
|
||||
appState.collaborators.get(socketId),
|
||||
);
|
||||
return background;
|
||||
}),
|
||||
);
|
||||
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 (selectionColors.length) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import throttle from "lodash.throttle";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
@@ -50,6 +51,24 @@ 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],
|
||||
) => {
|
||||
@@ -274,10 +293,7 @@ class Scene {
|
||||
: Array.from(nextElements.values());
|
||||
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
||||
|
||||
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));
|
||||
}
|
||||
validateIndicesThrottled(_nextElements);
|
||||
|
||||
this.elements = syncInvalidIndices(_nextElements);
|
||||
this.elementsMap.clear();
|
||||
|
||||
@@ -9,12 +9,13 @@ import type {
|
||||
ExcalidrawLinearElement,
|
||||
Arrowhead,
|
||||
} from "../element/types";
|
||||
import { isPathALoop, getCornerRadius } from "../math";
|
||||
import { isPathALoop, getCornerRadius, distanceSq2d } 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,
|
||||
@@ -400,9 +401,16 @@ export const _generateElementShape = (
|
||||
// initial position to it
|
||||
const points = element.points.length ? element.points : [[0, 0]];
|
||||
|
||||
// curve is always the first element
|
||||
// this simplifies finding the curve for an element
|
||||
if (!element.roundness) {
|
||||
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
|
||||
if (options.fill) {
|
||||
shape = [generator.polygon(points as [number, number][], options)];
|
||||
} else {
|
||||
@@ -482,3 +490,60 @@ 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(" ");
|
||||
};
|
||||
|
||||
@@ -40,11 +40,12 @@ 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 = (
|
||||
|
||||
@@ -34,16 +34,11 @@ import {
|
||||
import { newTextElement } from "../element";
|
||||
import { type Mutable } from "../utility-types";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import {
|
||||
isFrameElement,
|
||||
isFrameLikeElement,
|
||||
isTextElement,
|
||||
} from "../element/typeChecks";
|
||||
import { 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 -->`;
|
||||
|
||||
@@ -89,15 +84,8 @@ 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,
|
||||
@@ -108,10 +96,7 @@ const addFrameLabelsAsTextElements = (
|
||||
strokeColor: opts.exportWithDarkMode
|
||||
? FRAME_STYLE.nameColorDarkTheme
|
||||
: FRAME_STYLE.nameColorLightTheme,
|
||||
text: getFrameLikeTitle(
|
||||
element,
|
||||
isFrameElement(element) ? frameIndex : magicFrameIndex,
|
||||
),
|
||||
text: getFrameLikeTitle(element),
|
||||
});
|
||||
textElement.y -= textElement.height;
|
||||
|
||||
@@ -188,7 +173,13 @@ 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,
|
||||
@@ -375,35 +366,28 @@ export const exportToSvg = async (
|
||||
? []
|
||||
: await Promise.all(
|
||||
Array.from(fontFamilies).map(async (x) => {
|
||||
const { fontFaces } = Fonts.registered.get(x) ?? {};
|
||||
const { fonts, metadata } = Fonts.registered.get(x) ?? {};
|
||||
|
||||
if (!Array.isArray(fontFaces)) {
|
||||
if (!Array.isArray(fonts)) {
|
||||
console.error(
|
||||
`Couldn't find registered font-faces for font-family "${x}"`,
|
||||
`Couldn't find registered fonts 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();
|
||||
if (metadata?.local) {
|
||||
// don't inline local fonts
|
||||
return;
|
||||
}
|
||||
|
||||
return `@font-face {
|
||||
return Promise.all(
|
||||
fonts.map(
|
||||
async (font) => `@font-face {
|
||||
font-family: ${font.fontFace.family};
|
||||
src: url(${content});
|
||||
}`;
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Skipped inlining font with URL "${font.url.toString()}"`,
|
||||
e,
|
||||
);
|
||||
return "";
|
||||
}
|
||||
}),
|
||||
src: url(${await font.getContent()});
|
||||
}`,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -796,6 +796,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
},
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -998,6 +999,7 @@ 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",
|
||||
@@ -1210,6 +1212,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -1537,6 +1540,7 @@ 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",
|
||||
@@ -1864,6 +1868,7 @@ 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",
|
||||
@@ -2076,6 +2081,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -2312,6 +2318,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -2609,6 +2616,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -2974,6 +2982,7 @@ 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",
|
||||
@@ -3445,6 +3454,7 @@ 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",
|
||||
@@ -3764,6 +3774,7 @@ 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",
|
||||
@@ -4083,6 +4094,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -5265,6 +5277,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
},
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -6388,6 +6401,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
},
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -7319,6 +7333,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
||||
},
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -8227,6 +8242,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
},
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -9117,6 +9133,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
},
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
|
||||
@@ -8,6 +8,7 @@ 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
@@ -173,7 +173,7 @@ exports[`move element > rectangles with binding arrow 6`] = `
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 7,
|
||||
"versionNonce": 1984422985,
|
||||
"versionNonce": 745419401,
|
||||
"width": 300,
|
||||
"x": 201,
|
||||
"y": 2,
|
||||
@@ -186,16 +186,18 @@ exports[`move element > rectangles with binding arrow 7`] = `
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": {
|
||||
"elementId": "id1",
|
||||
"fixedPoint": null,
|
||||
"focus": "-0.46667",
|
||||
"gap": 10,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": "81.48231",
|
||||
"height": "81.47368",
|
||||
"id": "id2",
|
||||
"index": "a2",
|
||||
"isDeleted": false,
|
||||
@@ -210,7 +212,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
|
||||
],
|
||||
[
|
||||
81,
|
||||
"81.48231",
|
||||
"81.47368",
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
@@ -221,6 +223,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
|
||||
"startArrowhead": null,
|
||||
"startBinding": {
|
||||
"elementId": "id0",
|
||||
"fixedPoint": null,
|
||||
"focus": "-0.60000",
|
||||
"gap": 10,
|
||||
},
|
||||
@@ -229,10 +232,10 @@ exports[`move element > rectangles with binding arrow 7`] = `
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 14,
|
||||
"versionNonce": 2066753033,
|
||||
"version": 11,
|
||||
"versionNonce": 1996028265,
|
||||
"width": 81,
|
||||
"x": 110,
|
||||
"y": "49.98179",
|
||||
"y": 50,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -6,6 +6,7 @@ exports[`multi point mode in linear elements > arrow 3`] = `
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
|
||||
@@ -13,6 +13,7 @@ exports[`given element A and group of elements B and given both are selected whe
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -421,6 +422,7 @@ exports[`given element A and group of elements B and given both are selected whe
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -820,6 +822,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -1358,6 +1361,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -1555,6 +1559,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -1923,6 +1928,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -2156,6 +2162,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -2329,6 +2336,7 @@ exports[`regression tests > can drag element that covers another element, while
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -2642,6 +2650,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "#ffc9c9",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -2881,6 +2890,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -3117,6 +3127,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -3340,6 +3351,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -3589,6 +3601,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -3893,6 +3906,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -4300,6 +4314,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -4606,6 +4621,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -4882,6 +4898,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -5115,6 +5132,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -5307,6 +5325,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -5682,6 +5701,7 @@ exports[`regression tests > drags selected elements from point inside common bou
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -5965,6 +5985,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -6247,6 +6268,7 @@ History {
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
@@ -6387,6 +6409,7 @@ History {
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
@@ -6764,6 +6787,7 @@ exports[`regression tests > given a group of selected elements with an element t
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -7087,6 +7111,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "#ffc9c9",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -7356,6 +7381,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -7583,6 +7609,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -7813,6 +7840,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -7986,6 +8014,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -8159,6 +8188,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -8332,6 +8362,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -8408,6 +8439,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
|
||||
"isDragging": false,
|
||||
"lastUncommittedPoint": null,
|
||||
"pointerDownState": {
|
||||
"lastClickedIsEndPoint": false,
|
||||
"lastClickedPoint": -1,
|
||||
"origin": null,
|
||||
"prevSelectedPointsIndices": null,
|
||||
@@ -8480,6 +8512,7 @@ History {
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
@@ -8545,6 +8578,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -8621,6 +8655,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
|
||||
"isDragging": false,
|
||||
"lastUncommittedPoint": null,
|
||||
"pointerDownState": {
|
||||
"lastClickedIsEndPoint": false,
|
||||
"lastClickedPoint": -1,
|
||||
"origin": null,
|
||||
"prevSelectedPointsIndices": null,
|
||||
@@ -8758,6 +8793,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -8945,6 +8981,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -9021,6 +9058,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
|
||||
"isDragging": false,
|
||||
"lastUncommittedPoint": null,
|
||||
"pointerDownState": {
|
||||
"lastClickedIsEndPoint": false,
|
||||
"lastClickedPoint": -1,
|
||||
"origin": null,
|
||||
"prevSelectedPointsIndices": null,
|
||||
@@ -9093,6 +9131,7 @@ History {
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
@@ -9158,6 +9197,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -9331,6 +9371,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -9407,6 +9448,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
|
||||
"isDragging": false,
|
||||
"lastUncommittedPoint": null,
|
||||
"pointerDownState": {
|
||||
"lastClickedIsEndPoint": false,
|
||||
"lastClickedPoint": -1,
|
||||
"origin": null,
|
||||
"prevSelectedPointsIndices": null,
|
||||
@@ -9544,6 +9586,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -9717,6 +9760,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -9904,6 +9948,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -10077,6 +10122,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -10584,6 +10630,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -10854,6 +10901,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -10973,6 +11021,7 @@ exports[`regression tests > shift click on selected element should deselect it o
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -11165,6 +11214,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -11469,6 +11519,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -11874,6 +11925,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -12480,6 +12532,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -12602,6 +12655,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -13179,6 +13233,7 @@ exports[`regression tests > switches from group of selected elements to another
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -13540,6 +13595,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -13828,6 +13884,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -13947,6 +14004,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -14146,6 +14204,7 @@ History {
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
@@ -14318,6 +14377,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
@@ -14437,6 +14497,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
|
||||
@@ -6,6 +6,7 @@ exports[`select single element on the scene > arrow 1`] = `
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
|
||||
@@ -62,6 +62,7 @@ describe("element binding", () => {
|
||||
|
||||
expect(arrow.startBinding).toEqual({
|
||||
elementId: rect.id,
|
||||
fixedPoint: null,
|
||||
focus: expect.toBeNonNaNNumber(),
|
||||
gap: expect.toBeNonNaNNumber(),
|
||||
});
|
||||
@@ -74,11 +75,13 @@ describe("element binding", () => {
|
||||
// Both the start and the end points should be bound
|
||||
expect(arrow.startBinding).toEqual({
|
||||
elementId: rect.id,
|
||||
fixedPoint: null,
|
||||
focus: expect.toBeNonNaNNumber(),
|
||||
gap: expect.toBeNonNaNNumber(),
|
||||
});
|
||||
expect(arrow.endBinding).toEqual({
|
||||
elementId: rect.id,
|
||||
fixedPoint: null,
|
||||
focus: expect.toBeNonNaNNumber(),
|
||||
gap: expect.toBeNonNaNNumber(),
|
||||
});
|
||||
@@ -318,11 +321,13 @@ describe("element binding", () => {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
},
|
||||
endBinding: {
|
||||
elementId: "text1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [1, 0.5],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -337,11 +342,13 @@ describe("element binding", () => {
|
||||
elementId: "text1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
},
|
||||
endBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [1, 0.5],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ exports[`restoreElements > should restore arrow element correctly 1`] = `
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [],
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": null,
|
||||
"endBinding": null,
|
||||
"fillStyle": "solid",
|
||||
|
||||
@@ -149,8 +149,6 @@ const createLinearElementWithCurveInsideMinMaxPoints = (
|
||||
[-922.4761962890625, 300.3277587890625],
|
||||
[828.0126953125, 410.51605224609375],
|
||||
],
|
||||
startArrowhead: null,
|
||||
endArrowhead: null,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -183,8 +181,6 @@ const createLinearElementsWithCurveOutsideMinMaxPoints = (
|
||||
[-591.2804897585779, 36.09360810181511],
|
||||
[-148.56510566829502, 53.96308359105342],
|
||||
],
|
||||
startArrowhead: null,
|
||||
endArrowhead: null,
|
||||
...extraProps,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -763,7 +763,11 @@ function test(
|
||||
// ensure the input is invalid (unless the flag is on)
|
||||
if (!expectValidInput) {
|
||||
expect(() =>
|
||||
validateFractionalIndices(elements.map((x) => x.index)),
|
||||
validateFractionalIndices(elements, {
|
||||
shouldThrow: true,
|
||||
includeBoundTextValidation: true,
|
||||
ignoreLogs: true,
|
||||
}),
|
||||
).toThrowError(InvalidFractionalIndexError);
|
||||
}
|
||||
|
||||
@@ -777,7 +781,11 @@ function test(
|
||||
|
||||
expect(syncedElements.length).toBe(elements.length);
|
||||
expect(() =>
|
||||
validateFractionalIndices(syncedElements.map((x) => x.index)),
|
||||
validateFractionalIndices(syncedElements, {
|
||||
shouldThrow: true,
|
||||
includeBoundTextValidation: true,
|
||||
ignoreLogs: true,
|
||||
}),
|
||||
).not.toThrowError(InvalidFractionalIndexError);
|
||||
|
||||
syncedElements.forEach((synced, index) => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import util from "util";
|
||||
import path from "path";
|
||||
import { getMimeType } from "../../data/blob";
|
||||
import {
|
||||
newArrowElement,
|
||||
newEmbeddableElement,
|
||||
newFrameElement,
|
||||
newFreeDrawElement,
|
||||
@@ -146,6 +147,7 @@ export class API {
|
||||
endBinding?: T extends "arrow"
|
||||
? ExcalidrawLinearElement["endBinding"]
|
||||
: never;
|
||||
elbowed?: boolean;
|
||||
}): T extends "arrow" | "line"
|
||||
? ExcalidrawLinearElement
|
||||
: T extends "freedraw"
|
||||
@@ -250,14 +252,24 @@ export class API {
|
||||
});
|
||||
break;
|
||||
case "arrow":
|
||||
element = newArrowElement({
|
||||
...base,
|
||||
width,
|
||||
height,
|
||||
type,
|
||||
points: rest.points ?? [
|
||||
[0, 0],
|
||||
[100, 100],
|
||||
],
|
||||
elbowed: rest.elbowed ?? false,
|
||||
});
|
||||
break;
|
||||
case "line":
|
||||
element = newLinearElement({
|
||||
...base,
|
||||
width,
|
||||
height,
|
||||
type,
|
||||
startArrowhead: null,
|
||||
endArrowhead: null,
|
||||
points: rest.points ?? [
|
||||
[0, 0],
|
||||
[100, 100],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -95,7 +95,12 @@ describe("library", () => {
|
||||
const arrow = API.createElement({
|
||||
id: "arrow1",
|
||||
type: "arrow",
|
||||
endBinding: { elementId: "rectangle1", focus: -1, gap: 0 },
|
||||
endBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: -1,
|
||||
gap: 0,
|
||||
fixedPoint: [0.5, 1],
|
||||
},
|
||||
});
|
||||
|
||||
await API.drop(
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
ExcalidrawTextElementWithContainer,
|
||||
FontString,
|
||||
} from "../element/types";
|
||||
import { Excalidraw } from "../index";
|
||||
import { Excalidraw, mutateElement } from "../index";
|
||||
import { centerPoint } from "../math";
|
||||
import { reseed } from "../random";
|
||||
import * as StaticScene from "../renderer/staticScene";
|
||||
@@ -107,6 +107,7 @@ describe("Test Linear Elements", () => {
|
||||
],
|
||||
roundness,
|
||||
});
|
||||
mutateElement(line, { points: line.points });
|
||||
h.elements = [line];
|
||||
mouse.clickAt(p1[0], p1[1]);
|
||||
return line;
|
||||
@@ -307,7 +308,7 @@ describe("Test Linear Elements", () => {
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`9`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
|
||||
const midPointsWithRoundEdge = LinearElementEditor.getEditorMidPoints(
|
||||
h.elements[0] as ExcalidrawLinearElement,
|
||||
@@ -365,7 +366,7 @@ describe("Test Linear Elements", () => {
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
expect([line.x, line.y]).toEqual([
|
||||
points[0][0] + deltaX,
|
||||
@@ -427,7 +428,7 @@ describe("Test Linear Elements", () => {
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`16`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
expect(line.points.length).toEqual(5);
|
||||
|
||||
@@ -478,7 +479,7 @@ describe("Test Linear Elements", () => {
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
|
||||
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
line,
|
||||
@@ -519,7 +520,7 @@ describe("Test Linear Elements", () => {
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
|
||||
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
line,
|
||||
@@ -567,7 +568,7 @@ describe("Test Linear Elements", () => {
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`18`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
const newMidPoints = LinearElementEditor.getEditorMidPoints(
|
||||
line,
|
||||
@@ -617,7 +618,7 @@ describe("Test Linear Elements", () => {
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`16`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
expect(line.points.length).toEqual(5);
|
||||
|
||||
expect((h.elements[0] as ExcalidrawLinearElement).points)
|
||||
@@ -715,7 +716,7 @@ describe("Test Linear Elements", () => {
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
|
||||
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
line,
|
||||
@@ -843,6 +844,7 @@ describe("Test Linear Elements", () => {
|
||||
id: textElement.id,
|
||||
}),
|
||||
};
|
||||
|
||||
const elements: ExcalidrawElement[] = [];
|
||||
h.elements.forEach((element) => {
|
||||
if (element.id === container.id) {
|
||||
@@ -1235,7 +1237,7 @@ describe("Test Linear Elements", () => {
|
||||
mouse.moveTo(200, 0);
|
||||
mouse.upAt(200, 0);
|
||||
|
||||
expect(arrow.width).toBe(200);
|
||||
expect(arrow.width).toBe(205);
|
||||
expect(rect.x).toBe(200);
|
||||
expect(rect.y).toBe(0);
|
||||
expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
|
||||
@@ -1356,16 +1358,20 @@ describe("Test Linear Elements", () => {
|
||||
const line = createThreePointerLinearElement("arrow");
|
||||
const [origStartX, origStartY] = [line.x, line.y];
|
||||
|
||||
LinearElementEditor.movePoints(line, [
|
||||
{ index: 0, point: [line.points[0][0] + 10, line.points[0][1] + 10] },
|
||||
{
|
||||
index: line.points.length - 1,
|
||||
point: [
|
||||
line.points[line.points.length - 1][0] - 10,
|
||||
line.points[line.points.length - 1][1] - 10,
|
||||
],
|
||||
},
|
||||
]);
|
||||
LinearElementEditor.movePoints(
|
||||
line,
|
||||
[
|
||||
{ index: 0, point: [line.points[0][0] + 10, line.points[0][1] + 10] },
|
||||
{
|
||||
index: line.points.length - 1,
|
||||
point: [
|
||||
line.points[line.points.length - 1][0] - 10,
|
||||
line.points[line.points.length - 1][1] - 10,
|
||||
],
|
||||
},
|
||||
],
|
||||
h.scene,
|
||||
);
|
||||
expect(line.x).toBe(origStartX + 10);
|
||||
expect(line.y).toBe(origStartY + 10);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
import { UI, Pointer, Keyboard } from "./helpers/ui";
|
||||
import { KEYS } from "../keys";
|
||||
import { vi } from "vitest";
|
||||
import type Scene from "../scene/Scene";
|
||||
|
||||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
@@ -85,6 +86,7 @@ describe("move element", () => {
|
||||
rectA.get() as ExcalidrawRectangleElement,
|
||||
rectB.get() as ExcalidrawRectangleElement,
|
||||
elementsMap,
|
||||
{} as Scene,
|
||||
);
|
||||
|
||||
// select the second rectangle
|
||||
|
||||
@@ -798,6 +798,7 @@ describe("multiple selection", () => {
|
||||
width: 100,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
const rightBoundArrow = UI.createElement("arrow", {
|
||||
x: 210,
|
||||
y: 50,
|
||||
@@ -822,11 +823,16 @@ describe("multiple selection", () => {
|
||||
|
||||
expect(leftBoundArrow.x).toBeCloseTo(-110);
|
||||
expect(leftBoundArrow.y).toBeCloseTo(50);
|
||||
expect(leftBoundArrow.width).toBeCloseTo(140, 0);
|
||||
expect(leftBoundArrow.width).toBeCloseTo(137.5, 0);
|
||||
expect(leftBoundArrow.height).toBeCloseTo(7, 0);
|
||||
expect(leftBoundArrow.angle).toEqual(0);
|
||||
expect(leftBoundArrow.startBinding).toBeNull();
|
||||
expect(leftBoundArrow.endBinding).toMatchObject(leftArrowBinding);
|
||||
expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(12.352);
|
||||
expect(leftBoundArrow.endBinding?.elementId).toBe(
|
||||
leftArrowBinding.elementId,
|
||||
);
|
||||
expect(leftBoundArrow.endBinding?.fixedPoint).toBeNull();
|
||||
expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus);
|
||||
|
||||
expect(rightBoundArrow.x).toBeCloseTo(210);
|
||||
expect(rightBoundArrow.y).toBeCloseTo(
|
||||
@@ -836,7 +842,12 @@ describe("multiple selection", () => {
|
||||
expect(rightBoundArrow.height).toBeCloseTo(0);
|
||||
expect(rightBoundArrow.angle).toEqual(0);
|
||||
expect(rightBoundArrow.startBinding).toBeNull();
|
||||
expect(rightBoundArrow.endBinding).toMatchObject(rightArrowBinding);
|
||||
expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(8.0952);
|
||||
expect(rightBoundArrow.endBinding?.elementId).toBe(
|
||||
rightArrowBinding.elementId,
|
||||
);
|
||||
expect(rightBoundArrow.endBinding?.fixedPoint).toBeNull();
|
||||
expect(rightBoundArrow.endBinding?.focus).toBe(rightArrowBinding.focus);
|
||||
});
|
||||
|
||||
it("resizes with labeled arrows", async () => {
|
||||
|
||||
@@ -281,6 +281,7 @@ export interface AppState {
|
||||
currentItemEndArrowhead: Arrowhead | null;
|
||||
currentHoveredFontFamily: FontFamilyValues | null;
|
||||
currentItemRoundness: StrokeRoundness;
|
||||
currentItemArrowType: "sharp" | "round" | "elbow";
|
||||
viewBackgroundColor: string;
|
||||
scrollX: number;
|
||||
scrollY: number;
|
||||
@@ -624,6 +625,7 @@ export type AppClassProperties = {
|
||||
insertEmbeddableElement: App["insertEmbeddableElement"];
|
||||
onMagicframeToolSelect: App["onMagicframeToolSelect"];
|
||||
getName: App["getName"];
|
||||
dismissLinearEditor: App["dismissLinearEditor"];
|
||||
};
|
||||
|
||||
export type PointerDownState = Readonly<{
|
||||
|
||||
@@ -1157,3 +1157,6 @@ export const promiseTry = async <TValue, TArgs extends unknown[]>(
|
||||
resolve(fn(...args));
|
||||
});
|
||||
};
|
||||
|
||||
export const isAnyTrue = (...args: boolean[]): boolean =>
|
||||
Math.max(...args.map((arg) => (arg ? 1 : 0))) > 0;
|
||||
|
||||
@@ -13,6 +13,7 @@ exports[`exportToSvg > with default arguments 1`] = `
|
||||
"contextMenu": null,
|
||||
"currentChartType": "bar",
|
||||
"currentHoveredFontFamily": null,
|
||||
"currentItemArrowType": "round",
|
||||
"currentItemBackgroundColor": "transparent",
|
||||
"currentItemEndArrowhead": "arrow",
|
||||
"currentItemFillStyle": "solid",
|
||||
|
||||
@@ -16,10 +16,22 @@ const DEFAULT_THRESHOLD = 10e-5;
|
||||
*/
|
||||
|
||||
// the two vectors are ao and bo
|
||||
export const cross = (a: Point, b: Point, o: Point) => {
|
||||
export const cross = (
|
||||
a: Readonly<Point>,
|
||||
b: Readonly<Point>,
|
||||
o: Readonly<Point>,
|
||||
) => {
|
||||
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
||||
};
|
||||
|
||||
export const dot = (
|
||||
a: Readonly<Point>,
|
||||
b: Readonly<Point>,
|
||||
o: Readonly<Point>,
|
||||
) => {
|
||||
return (a[0] - o[0]) * (b[0] - o[0]) + (a[1] - o[1]) * (b[1] - o[1]);
|
||||
};
|
||||
|
||||
export const isClosed = (polygon: Polygon) => {
|
||||
const first = polygon[0];
|
||||
const last = polygon[polygon.length - 1];
|
||||
@@ -36,7 +48,9 @@ export const close = (polygon: Polygon) => {
|
||||
|
||||
// convert radians to degress
|
||||
export const angleToDegrees = (angle: number) => {
|
||||
return (angle * 180) / Math.PI;
|
||||
const theta = (angle * 180) / Math.PI;
|
||||
|
||||
return theta < 0 ? 360 + theta : theta;
|
||||
};
|
||||
|
||||
// convert degrees to radians
|
||||
|
||||
@@ -55,13 +55,13 @@
|
||||
"roughjs": "4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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.6",
|
||||
"@babel/plugin-transform-typescript": "7.18.8",
|
||||
"@babel/preset-env": "7.18.9",
|
||||
"@babel/preset-typescript": "7.18.6",
|
||||
"@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-typescript": "7.24.1",
|
||||
"babel-loader": "8.2.5",
|
||||
"babel-plugin-transform-class-properties": "6.24.1",
|
||||
"cross-env": "7.0.3",
|
||||
|
||||
+5
-1
@@ -6,9 +6,13 @@ const pkg = require(excalidrawPackage);
|
||||
|
||||
const publish = () => {
|
||||
try {
|
||||
console.info("Installing the dependencies in root folder...");
|
||||
execSync(`yarn --frozen-lockfile`);
|
||||
console.info("Installing the dependencies in excalidraw directory...");
|
||||
execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir });
|
||||
execSync(`yarn run build:umd`, { cwd: excalidrawDir });
|
||||
console.info("Building ESM Package...");
|
||||
execSync(`yarn run build:esm`, { cwd: excalidrawDir });
|
||||
console.info("Publishing the package...");
|
||||
execSync(`yarn --cwd ${excalidrawDir} publish`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
const OSS_FONTS_CDN =
|
||||
"https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/oss/";
|
||||
|
||||
/**
|
||||
* Custom vite plugin to convert url woff2 imports into a text.
|
||||
* Other woff2 imports are automatically served and resolved as a file uri.
|
||||
@@ -41,6 +44,89 @@ module.exports.woff2BrowserPlugin = () => {
|
||||
`const $1 = $2`,
|
||||
);
|
||||
}
|
||||
|
||||
// use CDN for Assistant
|
||||
if (!isDev && id.endsWith("/excalidraw/fonts/assets/fonts.css")) {
|
||||
return `/* WARN: The following content is generated during excalidraw-app build */
|
||||
|
||||
@font-face {
|
||||
font-family: "Assistant";
|
||||
src: url(${OSS_FONTS_CDN}Assistant-Regular-DVxZuzxb.woff2)
|
||||
format("woff2"),
|
||||
url(./Assistant-Regular.woff2) format("woff2");
|
||||
font-weight: 400;
|
||||
style: normal;
|
||||
display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Assistant";
|
||||
src: url(${OSS_FONTS_CDN}Assistant-Medium-DrcxCXg3.woff2)
|
||||
format("woff2"),
|
||||
url(./Assistant-Medium.woff2) format("woff2");
|
||||
font-weight: 500;
|
||||
style: normal;
|
||||
display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Assistant";
|
||||
src: url(${OSS_FONTS_CDN}Assistant-SemiBold-SCI4bEL9.woff2)
|
||||
format("woff2"),
|
||||
url(./Assistant-SemiBold.woff2) format("woff2");
|
||||
font-weight: 600;
|
||||
style: normal;
|
||||
display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Assistant";
|
||||
src: url(${OSS_FONTS_CDN}Assistant-Bold-gm-uSS1B.woff2)
|
||||
format("woff2"),
|
||||
url(./Assistant-Bold.woff2) format("woff2");
|
||||
font-weight: 700;
|
||||
style: normal;
|
||||
display: swap;
|
||||
}`;
|
||||
}
|
||||
|
||||
// using EXCALIDRAW_ASSET_PATH as a SSOT
|
||||
if (!isDev && id.endsWith("excalidraw-app/index.html")) {
|
||||
return code.replace(
|
||||
"<!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS -->",
|
||||
`<script>
|
||||
// point into our CDN in prod, fallback to root (excalidraw.com) domain in case of issues
|
||||
window.EXCALIDRAW_ASSET_PATH = [
|
||||
"${OSS_FONTS_CDN}",
|
||||
"/",
|
||||
];
|
||||
</script>
|
||||
|
||||
<!-- Preload all default fonts and Virgil for backwards compatibility to avoid swap on init -->
|
||||
<link
|
||||
rel="preload"
|
||||
href="${OSS_FONTS_CDN}Excalifont-Regular-C9eKQy_N.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="${OSS_FONTS_CDN}Virgil-Regular-hO16qHwV.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="${OSS_FONTS_CDN}ComicShanns-Regular-D0c8wzsC.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
`,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
+4
-2
@@ -72,12 +72,14 @@ vi.mock(
|
||||
...mod,
|
||||
ExcalidrawFont: class extends ExcalidrawFontImpl {
|
||||
public async getContent(): Promise<string> {
|
||||
if (this.url.protocol !== "file:") {
|
||||
const url = this.urls[0];
|
||||
|
||||
if (url.protocol !== "file:") {
|
||||
return super.getContent();
|
||||
}
|
||||
|
||||
// read local assets directly, without running a server
|
||||
const content = await fs.promises.readFile(this.url);
|
||||
const content = await fs.promises.readFile(url);
|
||||
return `data:font/woff2;base64,${content.toString("base64")}`;
|
||||
}
|
||||
},
|
||||
|
||||
+27
-1
@@ -6,7 +6,7 @@
|
||||
"headers": [
|
||||
{
|
||||
"key": "Access-Control-Allow-Origin",
|
||||
"value": "*"
|
||||
"value": "https://excalidraw.com"
|
||||
},
|
||||
{
|
||||
"key": "X-Content-Type-Options",
|
||||
@@ -21,6 +21,32 @@
|
||||
"value": "origin"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "/:file*.woff2",
|
||||
"headers": [
|
||||
{
|
||||
"key": "Cache-Control",
|
||||
"value": "public, max-age=31536000"
|
||||
},
|
||||
{
|
||||
"key": "Access-Control-Allow-Origin",
|
||||
"value": "https://excalidraw.com"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "/(Virgil|Cascadia|Assistant-Regular).woff2",
|
||||
"headers": [
|
||||
{
|
||||
"key": "Cache-Control",
|
||||
"value": "public, max-age=31536000"
|
||||
},
|
||||
{
|
||||
"key": "Access-Control-Allow-Origin",
|
||||
"value": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"redirects": [
|
||||
|
||||
Reference in New Issue
Block a user