Compare commits

...

20 Commits

Author SHA1 Message Date
Ryan Di 3b5d62c8d6 fix uppercase typo 2025-02-05 21:05:27 +11:00
Ryan Di 4f74274d04 animated trail for lasso selection 2025-02-05 20:59:51 +11:00
Ryan Di 52eaf64591 feat: box select frame & children to allow resizing at the same time (#9031)
* box select frame & children

* avoid selecting children twice to avoid double their moving

* do not show ele stats if frame and children selected together

* do not update frame membership if selected together

* do not group frame and its children

* comment and refactor code

* hide align altogether

* include frame children when selecting all

* simplify

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-01-28 22:10:16 +01:00
David Luzar 7028daa44a fix: remove flushSync to fix flickering (#9057) 2025-01-28 19:23:35 +01:00
Ashwin Temkar 65f218b100 fix: excalidraw issue #9045 flowcharts: align attributes of new node (#9047)
* fix: excalidraw#9045 by modifying the stroke style, opacity, and fill style for the new node and next nodes.

* fix: added roughness and opacity to the arrowbindings
2025-01-25 17:05:50 +01:00
Alplune 807b3c59f2 fix: align arrows bound to elements excalidraw#8833 (#8998) 2025-01-25 17:00:39 +01:00
Alplune b8da5065fd fix: update elbow arrow on font size change #8798 (#9002) 2025-01-25 17:00:26 +01:00
Márk Tolmács 49f1276ef2 fix: Undo for elbow arrows create incorrect routing (#9046) 2025-01-24 20:18:08 +01:00
Ashwin Temkar 8f20b29b73 fix: #8575 , Flowchart clones the current arrowhead (#8581)
* fix: #8575, Flowchart clones the current arrowhead

* fix: #8575, changed stroke color, style and width to startBindingElement
2025-01-24 16:50:07 +01:00
David Luzar f87c2cde09 feat: allow installing libs from excal github (#9041) 2025-01-23 16:50:47 +01:00
Ryan Di 0bf234fcc9 fix: adding partial group to frame (#9014)
* prevent new frame from including partial groups

* separate wrapped partial group
2025-01-23 07:26:12 +08:00
Ryan Di dd1b45a25a perf: reduce unnecessary frame clippings (#8980)
* reduce unnecessary frame clippings

* further optim
2025-01-23 07:25:46 +08:00
David Luzar ec06fbc1fc fix: do not refocus element link input on unrelated updates (#9037) 2025-01-22 21:30:15 +01:00
David Luzar fa05ae1230 refactor: remove defaultProps (#9035) 2025-01-22 12:43:02 +01:00
Márk Tolmács 91ebf8b0ea feat: Elbow arrow segment fixing & positioning (#8952)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2025-01-17 18:07:03 +01:00
Arnost Pleskot 8551823da9 feat: update jotai (#9015)
* feat: update jotai in excalidraw package

* feat: update jotai in excalidraw-app

* fix: exports from excalidraw/jotai

* fix: use isolated react hooks

* test: use jotai provider in <Trans /> test

* remove unused package

* refactor & make safer

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-01-16 16:59:11 +01:00
David Luzar ae6bee3403 feat: do not delete frame children on frame delete (#9011) 2025-01-14 21:08:25 +01:00
David Luzar 46f42ef8d7 fix: arrow binding behaving unexpectedly on pointerup (#9010)
* fix: arrow binding behaving unexpectedly on pointerup

* update snaps
2025-01-14 19:36:47 +01:00
Ryan Di 00b5b0a0ca feat: add action to wrap selected items in a frame (#9005)
* feat: add action to wrap selected items in a frame

* fix type

* select frame on wrap & refactor

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-01-13 15:03:56 +00:00
YongJoon Kim c92f3bebf5 fix: change cursor by tool change immediately (#8212) 2025-01-09 14:26:12 +01:00
106 changed files with 4994 additions and 2434 deletions
+15 -1
View File
@@ -3,6 +3,20 @@
"rules": { "rules": {
"import/no-anonymous-default-export": "off", "import/no-anonymous-default-export": "off",
"no-restricted-globals": "off", "no-restricted-globals": "off",
"@typescript-eslint/consistent-type-imports": ["error", { "prefer": "type-imports", "disallowTypeAnnotations": false, "fixStyle": "separate-type-imports" }] "@typescript-eslint/consistent-type-imports": [
"error",
{
"prefer": "type-imports",
"disallowTypeAnnotations": false,
"fixStyle": "separate-type-imports"
}
],
"no-restricted-imports": [
"error",
{
"name": "jotai",
"message": "Do not import from \"jotai\" directly. Use our app-specific modules (\"editor-jotai\" or \"app-jotai\")."
}
]
} }
} }
+10 -7
View File
@@ -90,9 +90,13 @@ import {
import { AppMainMenu } from "./components/AppMainMenu"; import { AppMainMenu } from "./components/AppMainMenu";
import { AppWelcomeScreen } from "./components/AppWelcomeScreen"; import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
import { AppFooter } from "./components/AppFooter"; import { AppFooter } from "./components/AppFooter";
import { Provider, useAtom, useAtomValue } from "jotai"; import {
import { useAtomWithInitialValue } from "../packages/excalidraw/jotai"; Provider,
import { appJotaiStore } from "./app-jotai"; useAtom,
useAtomValue,
useAtomWithInitialValue,
appJotaiStore,
} from "./app-jotai";
import "./index.scss"; import "./index.scss";
import type { ResolutionType } from "../packages/excalidraw/utility-types"; import type { ResolutionType } from "../packages/excalidraw/utility-types";
@@ -117,7 +121,7 @@ import {
share, share,
youtubeIcon, youtubeIcon,
} from "../packages/excalidraw/components/icons"; } from "../packages/excalidraw/components/icons";
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme"; import { useHandleAppTheme } from "./useHandleAppTheme";
import { getPreferredLanguage } from "./app-language/language-detector"; import { getPreferredLanguage } from "./app-language/language-detector";
import { useAppLangCode } from "./app-language/language-state"; import { useAppLangCode } from "./app-language/language-state";
import DebugCanvas, { import DebugCanvas, {
@@ -328,8 +332,7 @@ const ExcalidrawWrapper = () => {
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const isCollabDisabled = isRunningInIframe(); const isCollabDisabled = isRunningInIframe();
const [appTheme, setAppTheme] = useAtom(appThemeAtom); const { editorTheme, appTheme, setAppTheme } = useHandleAppTheme();
const { editorTheme } = useHandleAppTheme();
const [langCode, setLangCode] = useAppLangCode(); const [langCode, setLangCode] = useAppLangCode();
@@ -1141,7 +1144,7 @@ const ExcalidrawApp = () => {
return ( return (
<TopErrorBoundary> <TopErrorBoundary>
<Provider unstable_createStore={() => appJotaiStore}> <Provider store={appJotaiStore}>
<ExcalidrawWrapper /> <ExcalidrawWrapper />
</Provider> </Provider>
</TopErrorBoundary> </TopErrorBoundary>
+36 -2
View File
@@ -1,3 +1,37 @@
import { unstable_createStore } from "jotai"; // eslint-disable-next-line no-restricted-imports
import {
atom,
Provider,
useAtom,
useAtomValue,
useSetAtom,
createStore,
type PrimitiveAtom,
} from "jotai";
import { useLayoutEffect } from "react";
export const appJotaiStore = unstable_createStore(); export const appJotaiStore = createStore();
export { atom, Provider, useAtom, useAtomValue, useSetAtom };
export const useAtomWithInitialValue = <
T extends unknown,
A extends PrimitiveAtom<T>,
>(
atom: A,
initialValue: T | (() => T),
) => {
const [value, setValue] = useAtom(atom);
useLayoutEffect(() => {
if (typeof initialValue === "function") {
// @ts-ignore
setValue(initialValue());
} else {
setValue(initialValue);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return [value, setValue] as const;
};
+1 -1
View File
@@ -1,6 +1,6 @@
import { useSetAtom } from "jotai";
import React from "react"; import React from "react";
import { useI18n, languages } from "../../packages/excalidraw/i18n"; import { useI18n, languages } from "../../packages/excalidraw/i18n";
import { useSetAtom } from "../app-jotai";
import { appLangCodeAtom } from "./language-state"; import { appLangCodeAtom } from "./language-state";
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => { export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
@@ -1,5 +1,5 @@
import { atom, useAtom } from "jotai";
import { useEffect } from "react"; import { useEffect } from "react";
import { atom, useAtom } from "../app-jotai";
import { getPreferredLanguage, languageDetector } from "./language-detector"; import { getPreferredLanguage, languageDetector } from "./language-detector";
export const appLangCodeAtom = atom(getPreferredLanguage()); export const appLangCodeAtom = atom(getPreferredLanguage());
+1 -2
View File
@@ -79,8 +79,7 @@ import { newElementWith } from "../../packages/excalidraw/element/mutateElement"
import { decryptData } from "../../packages/excalidraw/data/encryption"; import { decryptData } from "../../packages/excalidraw/data/encryption";
import { resetBrowserStateVersions } from "../data/tabSync"; import { resetBrowserStateVersions } from "../data/tabSync";
import { LocalData } from "../data/LocalData"; import { LocalData } from "../data/LocalData";
import { atom } from "jotai"; import { appJotaiStore, atom } from "../app-jotai";
import { appJotaiStore } from "../app-jotai";
import type { Mutable, ValueOf } from "../../packages/excalidraw/utility-types"; import type { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds"; import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils"; import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
+1 -1
View File
@@ -2,9 +2,9 @@ import { Tooltip } from "../../packages/excalidraw/components/Tooltip";
import { warning } from "../../packages/excalidraw/components/icons"; import { warning } from "../../packages/excalidraw/components/icons";
import clsx from "clsx"; import clsx from "clsx";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { atom } from "../app-jotai";
import "./CollabError.scss"; import "./CollabError.scss";
import { atom } from "jotai";
type ErrorIndicator = { type ErrorIndicator = {
message: string | null; message: string | null;
+1 -1
View File
@@ -32,7 +32,7 @@
"firebase": "8.3.3", "firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.4", "i18next-browser-languagedetector": "6.1.4",
"idb-keyval": "6.0.3", "idb-keyval": "6.0.3",
"jotai": "1.13.1", "jotai": "2.11.0",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"socket.io-client": "4.7.2", "socket.io-client": "4.7.2",
+3 -3
View File
@@ -18,11 +18,11 @@ import { TextField } from "../../packages/excalidraw/components/TextField";
import { FilledButton } from "../../packages/excalidraw/components/FilledButton"; import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
import type { CollabAPI } from "../collab/Collab"; import type { CollabAPI } from "../collab/Collab";
import { activeRoomLinkAtom } from "../collab/Collab"; import { activeRoomLinkAtom } from "../collab/Collab";
import { atom, useAtom, useAtomValue } from "jotai";
import "./ShareDialog.scss";
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState"; import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
import { useCopyStatus } from "../../packages/excalidraw/hooks/useCopiedIndicator"; import { useCopyStatus } from "../../packages/excalidraw/hooks/useCopiedIndicator";
import { atom, useAtom, useAtomValue } from "../app-jotai";
import "./ShareDialog.scss";
type OnExportToBackend = () => void; type OnExportToBackend = () => void;
type ShareDialogType = "share" | "collaborationOnly"; type ShareDialogType = "share" | "collaborationOnly";
+9 -10
View File
@@ -1,4 +1,3 @@
import { atom, useAtom } from "jotai";
import { useEffect, useLayoutEffect, useState } from "react"; import { useEffect, useLayoutEffect, useState } from "react";
import { THEME } from "../packages/excalidraw"; import { THEME } from "../packages/excalidraw";
import { EVENT } from "../packages/excalidraw/constants"; import { EVENT } from "../packages/excalidraw/constants";
@@ -6,18 +5,18 @@ import type { Theme } from "../packages/excalidraw/element/types";
import { CODES, KEYS } from "../packages/excalidraw/keys"; import { CODES, KEYS } from "../packages/excalidraw/keys";
import { STORAGE_KEYS } from "./app_constants"; import { STORAGE_KEYS } from "./app_constants";
export const appThemeAtom = atom<Theme | "system">(
(localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_THEME) as
| Theme
| "system"
| null) || THEME.LIGHT,
);
const getDarkThemeMediaQuery = (): MediaQueryList | undefined => const getDarkThemeMediaQuery = (): MediaQueryList | undefined =>
window.matchMedia?.("(prefers-color-scheme: dark)"); window.matchMedia?.("(prefers-color-scheme: dark)");
export const useHandleAppTheme = () => { export const useHandleAppTheme = () => {
const [appTheme, setAppTheme] = useAtom(appThemeAtom); const [appTheme, setAppTheme] = useState<Theme | "system">(() => {
return (
(localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_THEME) as
| Theme
| "system"
| null) || THEME.LIGHT
);
});
const [editorTheme, setEditorTheme] = useState<Theme>(THEME.LIGHT); const [editorTheme, setEditorTheme] = useState<Theme>(THEME.LIGHT);
useEffect(() => { useEffect(() => {
@@ -66,5 +65,5 @@ export const useHandleAppTheme = () => {
} }
}, [appTheme]); }, [appTheme]);
return { editorTheme }; return { editorTheme, appTheme, setAppTheme };
}; };
+20 -15
View File
@@ -21,10 +21,8 @@ import type { AppClassProperties, AppState, UIAppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils"; import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
const alignActionsPredicate = ( export const alignActionsPredicate = (
elements: readonly ExcalidrawElement[],
appState: UIAppState, appState: UIAppState,
_: unknown,
app: AppClassProperties, app: AppClassProperties,
) => { ) => {
const selectedElements = app.scene.getSelectedElements(appState); const selectedElements = app.scene.getSelectedElements(appState);
@@ -48,6 +46,7 @@ const alignSelectedElements = (
selectedElements, selectedElements,
elementsMap, elementsMap,
alignment, alignment,
app.scene,
); );
const updatedElementsMap = arrayToMap(updatedElements); const updatedElementsMap = arrayToMap(updatedElements);
@@ -64,7 +63,8 @@ export const actionAlignTop = register({
label: "labels.alignTop", label: "labels.alignTop",
icon: AlignTopIcon, icon: AlignTopIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate, predicate: (elements, appState, appProps, app) =>
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
@@ -79,7 +79,7 @@ export const actionAlignTop = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)} hidden={!alignActionsPredicate(appState, app)}
type="button" type="button"
icon={AlignTopIcon} icon={AlignTopIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -97,7 +97,8 @@ export const actionAlignBottom = register({
label: "labels.alignBottom", label: "labels.alignBottom",
icon: AlignBottomIcon, icon: AlignBottomIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate, predicate: (elements, appState, appProps, app) =>
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
@@ -112,7 +113,7 @@ export const actionAlignBottom = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)} hidden={!alignActionsPredicate(appState, app)}
type="button" type="button"
icon={AlignBottomIcon} icon={AlignBottomIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -130,7 +131,8 @@ export const actionAlignLeft = register({
label: "labels.alignLeft", label: "labels.alignLeft",
icon: AlignLeftIcon, icon: AlignLeftIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate, predicate: (elements, appState, appProps, app) =>
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
@@ -145,7 +147,7 @@ export const actionAlignLeft = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)} hidden={!alignActionsPredicate(appState, app)}
type="button" type="button"
icon={AlignLeftIcon} icon={AlignLeftIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -163,7 +165,8 @@ export const actionAlignRight = register({
label: "labels.alignRight", label: "labels.alignRight",
icon: AlignRightIcon, icon: AlignRightIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate, predicate: (elements, appState, appProps, app) =>
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
@@ -178,7 +181,7 @@ export const actionAlignRight = register({
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT, event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)} hidden={!alignActionsPredicate(appState, app)}
type="button" type="button"
icon={AlignRightIcon} icon={AlignRightIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -196,7 +199,8 @@ export const actionAlignVerticallyCentered = register({
label: "labels.centerVertically", label: "labels.centerVertically",
icon: CenterVerticallyIcon, icon: CenterVerticallyIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate, predicate: (elements, appState, appProps, app) =>
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
@@ -209,7 +213,7 @@ export const actionAlignVerticallyCentered = register({
}, },
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)} hidden={!alignActionsPredicate(appState, app)}
type="button" type="button"
icon={CenterVerticallyIcon} icon={CenterVerticallyIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -225,7 +229,8 @@ export const actionAlignHorizontallyCentered = register({
label: "labels.centerHorizontally", label: "labels.centerHorizontally",
icon: CenterHorizontallyIcon, icon: CenterHorizontallyIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: alignActionsPredicate, predicate: (elements, appState, appProps, app) =>
alignActionsPredicate(appState, app),
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
return { return {
appState, appState,
@@ -238,7 +243,7 @@ export const actionAlignHorizontallyCentered = register({
}, },
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
<ToolButton <ToolButton
hidden={!alignActionsPredicate(elements, appState, null, app)} hidden={!alignActionsPredicate(appState, app)}
type="button" type="button"
icon={CenterHorizontallyIcon} icon={CenterHorizontallyIcon}
onClick={() => updateData(null)} onClick={() => updateData(null)}
@@ -7,7 +7,7 @@ import { getNonDeletedElements } from "../element";
import type { ExcalidrawElement } from "../element/types"; import type { ExcalidrawElement } from "../element/types";
import type { AppClassProperties, AppState } from "../types"; import type { AppClassProperties, AppState } from "../types";
import { mutateElement, newElementWith } from "../element/mutateElement"; import { mutateElement, newElementWith } from "../element/mutateElement";
import { getElementsInGroup } from "../groups"; import { getElementsInGroup, selectGroupsForSelectedElements } from "../groups";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { fixBindingsAfterDeletion } from "../element/binding"; import { fixBindingsAfterDeletion } from "../element/binding";
import { import {
@@ -18,14 +18,12 @@ import {
import { updateActiveTool } from "../utils"; import { updateActiveTool } from "../utils";
import { TrashIcon } from "../components/icons"; import { TrashIcon } from "../components/icons";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { mutateElbowArrow } from "../element/routing";
const deleteSelectedElements = ( const deleteSelectedElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
app: AppClassProperties, app: AppClassProperties,
) => { ) => {
const elementsMap = app.scene.getNonDeletedElementsMap();
const framesToBeDeleted = new Set( const framesToBeDeleted = new Set(
getSelectedElements( getSelectedElements(
elements.filter((el) => isFrameLikeElement(el)), elements.filter((el) => isFrameLikeElement(el)),
@@ -33,48 +31,99 @@ const deleteSelectedElements = (
).map((el) => el.id), ).map((el) => el.id),
); );
return { const selectedElementIds: Record<ExcalidrawElement["id"], true> = {};
elements: elements.map((el) => {
if (appState.selectedElementIds[el.id]) { let shouldSelectEditingGroup = true;
if (el.boundElements) {
el.boundElements.forEach((candidate) => { const nextElements = elements.map((el) => {
const bound = app.scene if (appState.selectedElementIds[el.id]) {
.getNonDeletedElementsMap() if (el.boundElements) {
.get(candidate.id); el.boundElements.forEach((candidate) => {
if (bound && isElbowArrow(bound)) { const bound = app.scene.getNonDeletedElementsMap().get(candidate.id);
mutateElement(bound, { if (bound && isElbowArrow(bound)) {
startBinding: mutateElement(bound, {
el.id === bound.startBinding?.elementId startBinding:
? null el.id === bound.startBinding?.elementId
: bound.startBinding, ? null
endBinding: : bound.startBinding,
el.id === bound.endBinding?.elementId endBinding:
? null el.id === bound.endBinding?.elementId ? null : bound.endBinding,
: bound.endBinding, });
}); mutateElement(bound, { points: bound.points });
mutateElbowArrow(bound, elementsMap, bound.points); }
} });
}); }
return newElementWith(el, { isDeleted: true });
}
// if deleting a frame, remove the children from it and select them
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
shouldSelectEditingGroup = false;
selectedElementIds[el.id] = true;
return newElementWith(el, { frameId: null });
}
if (isBoundToContainer(el) && appState.selectedElementIds[el.containerId]) {
return newElementWith(el, { isDeleted: true });
}
return el;
});
let nextEditingGroupId = appState.editingGroupId;
// select next eligible element in currently editing group or supergroup
if (shouldSelectEditingGroup && appState.editingGroupId) {
const elems = getElementsInGroup(
nextElements,
appState.editingGroupId,
).filter((el) => !el.isDeleted);
if (elems.length > 1) {
if (elems[0]) {
selectedElementIds[elems[0].id] = true;
}
} else {
nextEditingGroupId = null;
if (elems[0]) {
selectedElementIds[elems[0].id] = true;
}
const lastElementInGroup = elems[0];
if (lastElementInGroup) {
const editingGroupIdx = lastElementInGroup.groupIds.findIndex(
(groupId) => {
return groupId === appState.editingGroupId;
},
);
const superGroupId = lastElementInGroup.groupIds[editingGroupIdx + 1];
if (superGroupId) {
const elems = getElementsInGroup(nextElements, superGroupId).filter(
(el) => !el.isDeleted,
);
if (elems.length > 1) {
nextEditingGroupId = superGroupId;
elems.forEach((el) => {
selectedElementIds[el.id] = true;
});
}
} }
return newElementWith(el, { isDeleted: true });
} }
}
}
if (el.frameId && framesToBeDeleted.has(el.frameId)) { return {
return newElementWith(el, { isDeleted: true }); elements: nextElements,
}
if (
isBoundToContainer(el) &&
appState.selectedElementIds[el.containerId]
) {
return newElementWith(el, { isDeleted: true });
}
return el;
}),
appState: { appState: {
...appState, ...appState,
selectedElementIds: {}, ...selectGroupsForSelectedElements(
selectedGroupIds: {}, {
selectedElementIds,
editingGroupId: nextEditingGroupId,
},
nextElements,
appState,
null,
),
}, },
}; };
}; };
@@ -157,12 +206,7 @@ export const actionDeleteSelected = register({
: endBindingElement, : endBindingElement,
}; };
LinearElementEditor.deletePoints( LinearElementEditor.deletePoints(element, selectedPointsIndices);
element,
selectedPointsIndices,
elementsMap,
appState.zoom,
);
return { return {
elements, elements,
@@ -49,12 +49,13 @@ describe("flipping re-centers selection", () => {
}, },
startArrowhead: null, startArrowhead: null,
endArrowhead: "arrow", endArrowhead: "arrow",
fixedSegments: null,
points: [ points: [
pointFrom(0, 0), pointFrom(0, 0),
pointFrom(0, -35), pointFrom(0, -35),
pointFrom(-90.9, -35), pointFrom(-90, -35),
pointFrom(-90.9, 204.9), pointFrom(-90, 204),
pointFrom(65.1, 204.9), pointFrom(66, 204),
], ],
elbowed: true, elbowed: true,
}), }),
@@ -70,13 +71,13 @@ describe("flipping re-centers selection", () => {
API.executeAction(actionFlipHorizontal); API.executeAction(actionFlipHorizontal);
API.executeAction(actionFlipHorizontal); API.executeAction(actionFlipHorizontal);
const rec1 = h.elements.find((el) => el.id === "rec1"); const rec1 = h.elements.find((el) => el.id === "rec1")!;
expect(rec1?.x).toBeCloseTo(100); expect(rec1.x).toBeCloseTo(100, 0);
expect(rec1?.y).toBeCloseTo(100); expect(rec1.y).toBeCloseTo(100, 0);
const rec2 = h.elements.find((el) => el.id === "rec2"); const rec2 = h.elements.find((el) => el.id === "rec2")!;
expect(rec2?.x).toBeCloseTo(220); expect(rec2.x).toBeCloseTo(220, 0);
expect(rec2?.y).toBeCloseTo(250); expect(rec2.y).toBeCloseTo(250, 0);
}); });
}); });
+23 -17
View File
@@ -24,8 +24,8 @@ import {
isElbowArrow, isElbowArrow,
isLinearElement, isLinearElement,
} from "../element/typeChecks"; } from "../element/typeChecks";
import { mutateElbowArrow } from "../element/routing";
import { mutateElement, newElementWith } from "../element/mutateElement"; import { mutateElement, newElementWith } from "../element/mutateElement";
import { deepCopyElement } from "../element/newElement";
import { getCommonBoundingBox } from "../element/bounds"; import { getCommonBoundingBox } from "../element/bounds";
export const actionFlipHorizontal = register({ export const actionFlipHorizontal = register({
@@ -134,12 +134,24 @@ const flipElements = (
const { midX, midY } = getCommonBoundingBox(selectedElements); const { midX, midY } = getCommonBoundingBox(selectedElements);
resizeMultipleElements(selectedElements, elementsMap, "nw", app.scene, { resizeMultipleElements(
flipByX: flipDirection === "horizontal", selectedElements,
flipByY: flipDirection === "vertical", elementsMap,
shouldResizeFromCenter: true, "nw",
shouldMaintainAspectRatio: true, app.scene,
}); new Map(
Array.from(elementsMap.values()).map((element) => [
element.id,
deepCopyElement(element),
]),
),
{
flipByX: flipDirection === "horizontal",
flipByY: flipDirection === "vertical",
shouldResizeFromCenter: true,
shouldMaintainAspectRatio: true,
},
);
bindOrUnbindLinearElements( bindOrUnbindLinearElements(
selectedElements.filter(isLinearElement), selectedElements.filter(isLinearElement),
@@ -181,16 +193,10 @@ const flipElements = (
}), }),
); );
elbowArrows.forEach((element) => elbowArrows.forEach((element) =>
mutateElbowArrow( mutateElement(element, {
element, x: element.x + diffX,
elementsMap, y: element.y + diffY,
element.points, }),
undefined,
undefined,
{
informMutation: false,
},
),
); );
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+70 -2
View File
@@ -1,6 +1,6 @@
import { getNonDeletedElements } from "../element"; import { getCommonBounds, getNonDeletedElements } from "../element";
import type { ExcalidrawElement } from "../element/types"; import type { ExcalidrawElement } from "../element/types";
import { removeAllElementsFromFrame } from "../frame"; import { addElementsToFrame, removeAllElementsFromFrame } from "../frame";
import { getFrameChildren } from "../frame"; import { getFrameChildren } from "../frame";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import type { AppClassProperties, AppState, UIAppState } from "../types"; import type { AppClassProperties, AppState, UIAppState } from "../types";
@@ -10,6 +10,10 @@ import { register } from "./register";
import { isFrameLikeElement } from "../element/typeChecks"; import { isFrameLikeElement } from "../element/typeChecks";
import { frameToolIcon } from "../components/icons"; import { frameToolIcon } from "../components/icons";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { getSelectedElements } from "../scene";
import { newFrameElement } from "../element/newElement";
import { getElementsInGroup } from "../groups";
import { mutateElement } from "../element/mutateElement";
const isSingleFrameSelected = ( const isSingleFrameSelected = (
appState: UIAppState, appState: UIAppState,
@@ -144,3 +148,67 @@ export const actionSetFrameAsActiveTool = register({
!event.altKey && !event.altKey &&
event.key.toLocaleLowerCase() === KEYS.F, event.key.toLocaleLowerCase() === KEYS.F,
}); });
export const actionWrapSelectionInFrame = register({
name: "wrapSelectionInFrame",
label: "labels.wrapSelectionInFrame",
trackEvent: { category: "element" },
predicate: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState);
return (
selectedElements.length > 0 &&
!selectedElements.some((element) => isFrameLikeElement(element))
);
},
perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState);
const [x1, y1, x2, y2] = getCommonBounds(
selectedElements,
app.scene.getNonDeletedElementsMap(),
);
const PADDING = 16;
const frame = newFrameElement({
x: x1 - PADDING,
y: y1 - PADDING,
width: x2 - x1 + PADDING * 2,
height: y2 - y1 + PADDING * 2,
});
// for a selected partial group, we want to remove it from the remainder of the group
if (appState.editingGroupId) {
const elementsInGroup = getElementsInGroup(
selectedElements,
appState.editingGroupId,
);
for (const elementInGroup of elementsInGroup) {
const index = elementInGroup.groupIds.indexOf(appState.editingGroupId);
mutateElement(
elementInGroup,
{
groupIds: elementInGroup.groupIds.slice(0, index),
},
false,
);
}
}
const nextElements = addElementsToFrame(
[...app.scene.getElementsIncludingDeleted(), frame],
selectedElements,
frame,
appState,
);
return {
elements: nextElements,
appState: {
selectedElementIds: { [frame.id]: true },
},
storeAction: StoreAction.CAPTURE,
};
},
});
+12 -5
View File
@@ -25,8 +25,10 @@ import type {
import type { AppClassProperties, AppState } from "../types"; import type { AppClassProperties, AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks"; import { isBoundToContainer } from "../element/typeChecks";
import { import {
frameAndChildrenSelectedTogether,
getElementsInResizingFrame, getElementsInResizingFrame,
getFrameLikeElements, getFrameLikeElements,
getRootElements,
groupByFrameLikes, groupByFrameLikes,
removeElementsFromFrame, removeElementsFromFrame,
replaceAllElementsInFrame, replaceAllElementsInFrame,
@@ -60,8 +62,11 @@ const enableActionGroup = (
selectedElementIds: appState.selectedElementIds, selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true, includeBoundTextElement: true,
}); });
return ( return (
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements) selectedElements.length >= 2 &&
!allElementsInSameGroup(selectedElements) &&
!frameAndChildrenSelectedTogether(selectedElements)
); );
}; };
@@ -71,10 +76,12 @@ export const actionGroup = register({
icon: (appState) => <GroupIcon theme={appState.theme} />, icon: (appState) => <GroupIcon theme={appState.theme} />,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements({ const selectedElements = getRootElements(
selectedElementIds: appState.selectedElementIds, app.scene.getSelectedElements({
includeBoundTextElement: true, selectedElementIds: appState.selectedElementIds,
}); includeBoundTextElement: true,
}),
);
if (selectedElements.length < 2) { if (selectedElements.length < 2) {
// nothing to group // nothing to group
return { appState, elements, storeAction: StoreAction.NONE }; return { appState, elements, storeAction: StoreAction.NONE };
+193 -168
View File
@@ -89,6 +89,7 @@ import type {
FontFamilyValues, FontFamilyValues,
TextAlign, TextAlign,
VerticalAlign, VerticalAlign,
NonDeletedSceneElementsMap,
} from "../element/types"; } from "../element/types";
import { getLanguage, t } from "../i18n"; import { getLanguage, t } from "../i18n";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
@@ -115,11 +116,11 @@ import {
bindPointToSnapToElementOutline, bindPointToSnapToElementOutline,
calculateFixedPointForElbowArrowBinding, calculateFixedPointForElbowArrowBinding,
getHoveredElementForBinding, getHoveredElementForBinding,
updateBoundElements,
} from "../element/binding"; } from "../element/binding";
import { mutateElbowArrow } from "../element/routing";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import type { LocalPoint } from "../../math"; import type { LocalPoint } from "../../math";
import { pointFrom, vector } from "../../math"; import { pointFrom } from "../../math";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@@ -219,33 +220,47 @@ const changeFontSize = (
) => { ) => {
const newFontSizes = new Set<number>(); const newFontSizes = new Set<number>();
const updatedElements = changeProperty(
elements,
appState,
(oldElement) => {
if (isTextElement(oldElement)) {
const newFontSize = getNewFontSize(oldElement);
newFontSizes.add(newFontSize);
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
fontSize: newFontSize,
});
redrawTextBoundingBox(
newElement,
app.scene.getContainerElement(oldElement),
app.scene.getNonDeletedElementsMap(),
);
newElement = offsetElementAfterFontResize(oldElement, newElement);
return newElement;
}
return oldElement;
},
true,
);
// Update arrow elements after text elements have been updated
const updatedElementsMap = arrayToMap(updatedElements);
getSelectedElements(elements, appState, {
includeBoundTextElement: true,
}).forEach((element) => {
if (isTextElement(element)) {
updateBoundElements(
element,
updatedElementsMap as NonDeletedSceneElementsMap,
);
}
});
return { return {
elements: changeProperty( elements: updatedElements,
elements,
appState,
(oldElement) => {
if (isTextElement(oldElement)) {
const newFontSize = getNewFontSize(oldElement);
newFontSizes.add(newFontSize);
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
fontSize: newFontSize,
});
redrawTextBoundingBox(
newElement,
app.scene.getContainerElement(oldElement),
app.scene.getNonDeletedElementsMap(),
);
newElement = offsetElementAfterFontResize(oldElement, newElement);
return newElement;
}
return oldElement;
},
true,
),
appState: { appState: {
...appState, ...appState,
// update state only if we've set all select text elements to // update state only if we've set all select text elements to
@@ -1560,152 +1575,162 @@ export const actionChangeArrowType = register({
label: "Change arrow types", label: "Change arrow types",
trackEvent: false, trackEvent: false,
perform: (elements, appState, value, app) => { perform: (elements, appState, value, app) => {
return { const newElements = changeProperty(elements, appState, (el) => {
elements: changeProperty(elements, appState, (el) => { if (!isArrowElement(el)) {
if (!isArrowElement(el)) { return el;
return el; }
} const newElement = newElementWith(el, {
const newElement = newElementWith(el, { roundness:
roundness: value === ARROW_TYPE.round
value === ARROW_TYPE.round ? {
? { type: ROUNDNESS.PROPORTIONAL_RADIUS,
type: ROUNDNESS.PROPORTIONAL_RADIUS, }
} : null,
: null, elbowed: value === ARROW_TYPE.elbow,
elbowed: value === ARROW_TYPE.elbow, points:
points: value === ARROW_TYPE.elbow || el.elbowed
value === ARROW_TYPE.elbow || el.elbowed ? [el.points[0], el.points[el.points.length - 1]]
? [el.points[0], el.points[el.points.length - 1]] : el.points,
: 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,
appState.zoom,
);
const endHoveredElement =
!newElement.endBinding &&
getHoveredElementForBinding(
tupleToCoors(endGlobalPoint),
elements,
elementsMap,
appState.zoom,
);
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);
mutateElement(newElement, {
points: [finalStartPoint, finalEndPoint].map(
(p): LocalPoint =>
pointFrom(p[0] - newElement.x, p[1] - newElement.y),
),
...(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,
),
},
}
: {}),
}); });
if (isElbowArrow(newElement)) { LinearElementEditor.updateEditorMidPointsCache(
const elementsMap = app.scene.getNonDeletedElementsMap(); newElement,
elementsMap,
app.state,
);
}
app.dismissLinearEditor(); return newElement;
});
const startGlobalPoint = const newState = {
LinearElementEditor.getPointAtIndexGlobalCoordinates( ...appState,
newElement, currentItemArrowType: value,
0, };
elementsMap,
);
const endGlobalPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
newElement,
-1,
elementsMap,
);
const startHoveredElement =
!newElement.startBinding &&
getHoveredElementForBinding(
tupleToCoors(startGlobalPoint),
elements,
elementsMap,
appState.zoom,
true,
);
const endHoveredElement =
!newElement.endBinding &&
getHoveredElementForBinding(
tupleToCoors(endGlobalPoint),
elements,
elementsMap,
appState.zoom,
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 // Change the arrow type and update any other state settings for
? bindPointToSnapToElementOutline( // the arrow.
startGlobalPoint, const selectedId = appState.selectedLinearElement?.elementId;
endGlobalPoint, if (selectedId) {
startHoveredElement, const selected = newElements.find((el) => el.id === selectedId);
elementsMap, if (selected) {
) newState.selectedLinearElement = new LinearElementEditor(
: startGlobalPoint; selected as ExcalidrawLinearElement,
const finalEndPoint = endHoveredElement );
? bindPointToSnapToElementOutline( }
endGlobalPoint, }
startGlobalPoint,
endHoveredElement,
elementsMap,
)
: endGlobalPoint;
startHoveredElement && return {
bindLinearElement( elements: newElements,
newElement, appState: newState,
startHoveredElement,
"start",
elementsMap,
);
endHoveredElement &&
bindLinearElement(
newElement,
endHoveredElement,
"end",
elementsMap,
);
mutateElbowArrow(
newElement,
elementsMap,
[finalStartPoint, finalEndPoint].map(
(p): LocalPoint =>
pointFrom(p[0] - newElement.x, p[1] - newElement.y),
),
vector(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,
),
},
}
: {}),
},
);
}
return newElement;
}),
appState: {
...appState,
currentItemArrowType: value,
},
storeAction: StoreAction.CAPTURE, storeAction: StoreAction.CAPTURE,
}; };
}, },
@@ -5,7 +5,6 @@ import { getNonDeletedElements, isTextElement } from "../element";
import type { ExcalidrawElement } from "../element/types"; import type { ExcalidrawElement } from "../element/types";
import { isLinearElement } from "../element/typeChecks"; import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { excludeElementsInFramesFromSelection } from "../scene/selection";
import { selectAllIcon } from "../components/icons"; import { selectAllIcon } from "../components/icons";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
@@ -20,17 +19,17 @@ export const actionSelectAll = register({
return false; return false;
} }
const selectedElementIds = excludeElementsInFramesFromSelection( const selectedElementIds = elements
elements.filter( .filter(
(element) => (element) =>
!element.isDeleted && !element.isDeleted &&
!(isTextElement(element) && element.containerId) && !(isTextElement(element) && element.containerId) &&
!element.locked, !element.locked,
), )
).reduce((map: Record<ExcalidrawElement["id"], true>, element) => { .reduce((map: Record<ExcalidrawElement["id"], true>, element) => {
map[element.id] = true; map[element.id] = true;
return map; return map;
}, {}); }, {});
return { return {
appState: { appState: {
+2
View File
@@ -47,6 +47,7 @@ export type ShortcutName =
| "saveFileToDisk" | "saveFileToDisk"
| "saveToActiveFile" | "saveToActiveFile"
| "toggleShortcuts" | "toggleShortcuts"
| "wrapSelectionInFrame"
> >
| "saveScene" | "saveScene"
| "imageExport" | "imageExport"
@@ -112,6 +113,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")], saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
toggleShortcuts: [getShortcutKey("?")], toggleShortcuts: [getShortcutKey("?")],
searchMenu: [getShortcutKey("CtrlOrCmd+F")], searchMenu: [getShortcutKey("CtrlOrCmd+F")],
wrapSelectionInFrame: [],
}; };
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => { export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
+2 -1
View File
@@ -137,7 +137,8 @@ export type ActionName =
| "searchMenu" | "searchMenu"
| "copyElementLink" | "copyElementLink"
| "linkToElement" | "linkToElement"
| "cropEditor"; | "cropEditor"
| "wrapSelectionInFrame";
export type PanelComponentProps = { export type PanelComponentProps = {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
+14 -5
View File
@@ -1,8 +1,10 @@
import type { ElementsMap, ExcalidrawElement } from "./element/types"; import type { ElementsMap, ExcalidrawElement } from "./element/types";
import { newElementWith } from "./element/mutateElement"; import { mutateElement } from "./element/mutateElement";
import type { BoundingBox } from "./element/bounds"; import type { BoundingBox } from "./element/bounds";
import { getCommonBoundingBox } from "./element/bounds"; import { getCommonBoundingBox } from "./element/bounds";
import { getMaximumGroups } from "./groups"; import { getMaximumGroups } from "./groups";
import { updateBoundElements } from "./element/binding";
import type Scene from "./scene/Scene";
export interface Alignment { export interface Alignment {
position: "start" | "center" | "end"; position: "start" | "center" | "end";
@@ -13,6 +15,7 @@ export const alignElements = (
selectedElements: ExcalidrawElement[], selectedElements: ExcalidrawElement[],
elementsMap: ElementsMap, elementsMap: ElementsMap,
alignment: Alignment, alignment: Alignment,
scene: Scene,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
const groups: ExcalidrawElement[][] = getMaximumGroups( const groups: ExcalidrawElement[][] = getMaximumGroups(
selectedElements, selectedElements,
@@ -26,12 +29,18 @@ export const alignElements = (
selectionBoundingBox, selectionBoundingBox,
alignment, alignment,
); );
return group.map((element) => return group.map((element) => {
newElementWith(element, { // update element
const updatedEle = mutateElement(element, {
x: element.x + translation.x, x: element.x + translation.x,
y: element.y + translation.y, y: element.y + translation.y,
}), });
); // update bound elements
updateBoundElements(element, scene.getNonDeletedElementsMap(), {
simultaneouslyUpdated: group,
});
return updatedEle;
});
}); });
}; };
+12 -2
View File
@@ -20,7 +20,7 @@ export interface AnimatedTrailOptions {
} }
export class AnimatedTrail implements Trail { export class AnimatedTrail implements Trail {
private currentTrail?: LaserPointer; currentTrail?: LaserPointer;
private pastTrails: LaserPointer[] = []; private pastTrails: LaserPointer[] = [];
private container?: SVGSVGElement; private container?: SVGSVGElement;
@@ -28,7 +28,7 @@ export class AnimatedTrail implements Trail {
constructor( constructor(
private animationFrameHandler: AnimationFrameHandler, private animationFrameHandler: AnimationFrameHandler,
private app: App, protected app: App,
private options: Partial<LaserPointerOptions> & private options: Partial<LaserPointerOptions> &
Partial<AnimatedTrailOptions>, Partial<AnimatedTrailOptions>,
) { ) {
@@ -98,6 +98,16 @@ export class AnimatedTrail implements Trail {
} }
} }
getCurrentTrail() {
return this.currentTrail;
}
clearTrails() {
this.pastTrails = [];
this.currentTrail = undefined;
this.update();
}
private update() { private update() {
this.start(); this.start();
} }
+2
View File
@@ -120,6 +120,7 @@ export const getDefaultAppState = (): Omit<
isCropping: false, isCropping: false,
croppingElementId: null, croppingElementId: null,
searchMatches: [], searchMatches: [],
lassoSelectionEnabled: false,
}; };
}; };
@@ -244,6 +245,7 @@ const APP_STATE_STORAGE_CONF = (<
isCropping: { browser: false, export: false, server: false }, isCropping: { browser: false, export: false, server: false },
croppingElementId: { browser: false, export: false, server: false }, croppingElementId: { browser: false, export: false, server: false },
searchMatches: { browser: false, export: false, server: false }, searchMatches: { browser: false, export: false, server: false },
lassoSelectionEnabled: { browser: true, export: false, server: false },
}); });
const _clearAppStateForStorage = < const _clearAppStateForStorage = <
+7 -1
View File
@@ -51,6 +51,7 @@ import {
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { useTunnels } from "../context/tunnels"; import { useTunnels } from "../context/tunnels";
import { CLASSES } from "../constants"; import { CLASSES } from "../constants";
import { alignActionsPredicate } from "../actions/actionAlign";
export const canChangeStrokeColor = ( export const canChangeStrokeColor = (
appState: UIAppState, appState: UIAppState,
@@ -90,10 +91,12 @@ export const SelectedShapeActions = ({
appState, appState,
elementsMap, elementsMap,
renderAction, renderAction,
app,
}: { }: {
appState: UIAppState; appState: UIAppState;
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap; elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
renderAction: ActionManager["renderAction"]; renderAction: ActionManager["renderAction"];
app: AppClassProperties;
}) => { }) => {
const targetElements = getTargetElements(elementsMap, appState); const targetElements = getTargetElements(elementsMap, appState);
@@ -133,6 +136,9 @@ export const SelectedShapeActions = ({
targetElements.length === 1 && targetElements.length === 1 &&
isImageElement(targetElements[0]); isImageElement(targetElements[0]);
const showAlignActions =
!isSingleElementBoundContainer && alignActionsPredicate(appState, app);
return ( return (
<div className="panelColumn"> <div className="panelColumn">
<div> <div>
@@ -200,7 +206,7 @@ export const SelectedShapeActions = ({
</div> </div>
</fieldset> </fieldset>
{targetElements.length > 1 && !isSingleElementBoundContainer && ( {showAlignActions && !isSingleElementBoundContainer && (
<fieldset> <fieldset>
<legend>{t("labels.align")}</legend> <legend>{t("labels.align")}</legend>
<div className="buttonList"> <div className="buttonList">
@@ -1,7 +1,6 @@
import { atom, useAtom } from "jotai";
import { actionClearCanvas } from "../actions"; import { actionClearCanvas } from "../actions";
import { t } from "../i18n"; import { t } from "../i18n";
import { jotaiScope } from "../jotai"; import { atom, useAtom } from "../editor-jotai";
import { useExcalidrawActionManager } from "./App"; import { useExcalidrawActionManager } from "./App";
import ConfirmDialog from "./ConfirmDialog"; import ConfirmDialog from "./ConfirmDialog";
@@ -10,7 +9,6 @@ export const activeConfirmDialogAtom = atom<"clearCanvas" | null>(null);
export const ActiveConfirmDialog = () => { export const ActiveConfirmDialog = () => {
const [activeConfirmDialog, setActiveConfirmDialog] = useAtom( const [activeConfirmDialog, setActiveConfirmDialog] = useAtom(
activeConfirmDialogAtom, activeConfirmDialogAtom,
jotaiScope,
); );
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
+302 -116
View File
@@ -165,6 +165,7 @@ import {
isTextBindableContainer, isTextBindableContainer,
isElbowArrow, isElbowArrow,
isFlowchartNodeElement, isFlowchartNodeElement,
isBindableElement,
} from "../element/typeChecks"; } from "../element/typeChecks";
import type { import type {
ExcalidrawBindableElement, ExcalidrawBindableElement,
@@ -189,7 +190,6 @@ import type {
MagicGenerationData, MagicGenerationData,
ExcalidrawNonSelectionElement, ExcalidrawNonSelectionElement,
ExcalidrawArrowElement, ExcalidrawArrowElement,
NonDeletedSceneElementsMap,
} from "../element/types"; } from "../element/types";
import { getCenter, getDistance } from "../gesture"; import { getCenter, getDistance } from "../gesture";
import { import {
@@ -292,7 +292,6 @@ import {
getDateTime, getDateTime,
isShallowEqual, isShallowEqual,
arrayToMap, arrayToMap,
toBrandedType,
} from "../utils"; } from "../utils";
import { import {
createSrcDoc, createSrcDoc,
@@ -378,9 +377,10 @@ import { actionPaste } from "../actions/actionClipboard";
import { import {
actionRemoveAllElementsFromFrame, actionRemoveAllElementsFromFrame,
actionSelectAllElementsInFrame, actionSelectAllElementsInFrame,
actionWrapSelectionInFrame,
} from "../actions/actionFrame"; } from "../actions/actionFrame";
import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas"; import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas";
import { jotaiStore } from "../jotai"; import { editorJotaiStore } from "../editor-jotai";
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog"; import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import { ImageSceneDataError } from "../errors"; import { ImageSceneDataError } from "../errors";
import { import {
@@ -442,7 +442,6 @@ import { actionTextAutoResize } from "../actions/actionTextAutoResize";
import { getVisibleSceneBounds } from "../element/bounds"; import { getVisibleSceneBounds } from "../element/bounds";
import { isMaybeMermaidDefinition } from "../mermaid"; import { isMaybeMermaidDefinition } from "../mermaid";
import NewElementCanvas from "./canvases/NewElementCanvas"; import NewElementCanvas from "./canvases/NewElementCanvas";
import { mutateElbowArrow, updateElbowArrow } from "../element/routing";
import { import {
FlowChartCreator, FlowChartCreator,
FlowChartNavigator, FlowChartNavigator,
@@ -466,6 +465,7 @@ import { cropElement } from "../element/cropElement";
import { wrapText } from "../element/textWrapping"; import { wrapText } from "../element/textWrapping";
import { actionCopyElementLink } from "../actions/actionElementLink"; import { actionCopyElementLink } from "../actions/actionElementLink";
import { isElementLink, parseElementLinkFromURL } from "../element/elementLink"; import { isElementLink, parseElementLinkFromURL } from "../element/elementLink";
import { LassoTrail } from "../lasso";
const AppContext = React.createContext<AppClassProperties>(null!); const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!); const AppPropsContext = React.createContext<AppProps>(null!);
@@ -636,6 +636,8 @@ class App extends React.Component<AppProps, AppState> {
: "rgba(255, 255, 255, 0.2)", : "rgba(255, 255, 255, 0.2)",
}); });
lassoTrail = new LassoTrail(this.animationFrameHandler, this);
onChangeEmitter = new Emitter< onChangeEmitter = new Emitter<
[ [
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@@ -1608,7 +1610,11 @@ class App extends React.Component<AppProps, AppState> {
<div className="excalidraw-contextMenuContainer" /> <div className="excalidraw-contextMenuContainer" />
<div className="excalidraw-eye-dropper-container" /> <div className="excalidraw-eye-dropper-container" />
<SVGLayer <SVGLayer
trails={[this.laserTrails, this.eraserTrail]} trails={[
this.laserTrails,
this.eraserTrail,
this.lassoTrail,
]}
/> />
{selectedElements.length === 1 && {selectedElements.length === 1 &&
this.state.openDialog?.name !== this.state.openDialog?.name !==
@@ -2076,7 +2082,7 @@ class App extends React.Component<AppProps, AppState> {
}; };
private openEyeDropper = ({ type }: { type: "stroke" | "background" }) => { private openEyeDropper = ({ type }: { type: "stroke" | "background" }) => {
jotaiStore.set(activeEyeDropperAtom, { editorJotaiStore.set(activeEyeDropperAtom, {
swapPreviewOnAlt: true, swapPreviewOnAlt: true,
colorPickerType: colorPickerType:
type === "stroke" ? "elementStroke" : "elementBackground", type === "stroke" ? "elementStroke" : "elementBackground",
@@ -3183,49 +3189,7 @@ class App extends React.Component<AppProps, AppState> {
retainSeed?: boolean; retainSeed?: boolean;
fitToContent?: boolean; fitToContent?: boolean;
}) => { }) => {
let elements = opts.elements.map((el, _, elements) => { const elements = restoreElements(opts.elements, null, undefined);
if (isElbowArrow(el)) {
const startEndElements = [
el.startBinding &&
elements.find((l) => l.id === el.startBinding?.elementId),
el.endBinding &&
elements.find((l) => l.id === el.endBinding?.elementId),
];
const startBinding = startEndElements[0] ? el.startBinding : null;
const endBinding = startEndElements[1] ? el.endBinding : null;
return {
...el,
...updateElbowArrow(
{
...el,
startBinding,
endBinding,
},
toBrandedType<NonDeletedSceneElementsMap>(
new Map(
startEndElements
.filter((x) => x != null)
.map(
(el) =>
[el!.id, el] as [
string,
Ordered<NonDeletedExcalidrawElement>,
],
),
),
),
[el.points[0], el.points[el.points.length - 1]],
undefined,
{
zoom: this.state.zoom,
},
),
};
}
return el;
});
elements = restoreElements(elements, null, undefined);
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
const elementsCenterX = distance(minX, maxX) / 2; const elementsCenterX = distance(minX, maxX) / 2;
@@ -3278,7 +3242,12 @@ class App extends React.Component<AppProps, AppState> {
newElements, newElements,
topLayerFrame, topLayerFrame,
); );
addElementsToFrame(nextElements, eligibleElements, topLayerFrame); addElementsToFrame(
nextElements,
eligibleElements,
topLayerFrame,
this.state,
);
} }
this.scene.replaceAllElements(nextElements); this.scene.replaceAllElements(nextElements);
@@ -3324,7 +3293,7 @@ class App extends React.Component<AppProps, AppState> {
openSidebar: openSidebar:
this.state.openSidebar && this.state.openSidebar &&
this.device.editor.canFitSidebar && this.device.editor.canFitSidebar &&
jotaiStore.get(isSidebarDockedAtom) editorJotaiStore.get(isSidebarDockedAtom)
? this.state.openSidebar ? this.state.openSidebar
: null, : null,
...selectGroupsForSelectedElements( ...selectGroupsForSelectedElements(
@@ -4369,14 +4338,17 @@ class App extends React.Component<AppProps, AppState> {
} }
selectedElements.forEach((element) => { selectedElements.forEach((element) => {
mutateElement(element, { mutateElement(
x: element.x + offsetX, element,
y: element.y + offsetY, {
}); x: element.x + offsetX,
y: element.y + offsetY,
},
false,
);
updateBoundElements(element, this.scene.getNonDeletedElementsMap(), { updateBoundElements(element, this.scene.getNonDeletedElementsMap(), {
simultaneouslyUpdated: selectedElements, simultaneouslyUpdated: selectedElements,
zoom: this.state.zoom,
}); });
}); });
@@ -4390,6 +4362,8 @@ class App extends React.Component<AppProps, AppState> {
), ),
}); });
this.scene.triggerUpdate();
event.preventDefault(); event.preventDefault();
} else if (event.key === KEYS.ENTER) { } else if (event.key === KEYS.ENTER) {
const selectedElements = this.scene.getSelectedElements(this.state); const selectedElements = this.scene.getSelectedElements(this.state);
@@ -4548,11 +4522,19 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
if (event.key === KEYS[1] && !event.altKey && !event[KEYS.CTRL_OR_CMD]) {
if (this.state.activeTool.type === "selection") {
this.setActiveTool({ type: "lassoSelection" });
} else {
this.setActiveTool({ type: "selection" });
}
}
if ( if (
event[KEYS.CTRL_OR_CMD] && event[KEYS.CTRL_OR_CMD] &&
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
) { ) {
jotaiStore.set(activeConfirmDialogAtom, "clearCanvas"); editorJotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
} }
// eye dropper // eye dropper
@@ -4695,7 +4677,10 @@ class App extends React.Component<AppProps, AppState> {
if (nextActiveTool.type === "hand") { if (nextActiveTool.type === "hand") {
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB); setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
} else if (!isHoldingSpace) { } else if (!isHoldingSpace) {
setCursorForShape(this.interactiveCanvas, this.state); setCursorForShape(this.interactiveCanvas, {
...this.state,
activeTool: nextActiveTool,
});
} }
if (isToolIcon(document.activeElement)) { if (isToolIcon(document.activeElement)) {
this.focusContainer(); this.focusContainer();
@@ -5361,6 +5346,11 @@ class App extends React.Component<AppProps, AppState> {
const selectedElements = this.scene.getSelectedElements(this.state); const selectedElements = this.scene.getSelectedElements(this.state);
let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
event,
this.state,
);
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) { if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
if ( if (
event[KEYS.CTRL_OR_CMD] && event[KEYS.CTRL_OR_CMD] &&
@@ -5374,6 +5364,64 @@ class App extends React.Component<AppProps, AppState> {
editingLinearElement: new LinearElementEditor(selectedElements[0]), editingLinearElement: new LinearElementEditor(selectedElements[0]),
}); });
return; return;
} else if (
this.state.selectedLinearElement &&
isElbowArrow(selectedElements[0])
) {
const hitCoords = LinearElementEditor.getSegmentMidpointHitCoords(
this.state.selectedLinearElement,
{ x: sceneX, y: sceneY },
this.state,
this.scene.getNonDeletedElementsMap(),
);
const midPoint = hitCoords
? LinearElementEditor.getSegmentMidPointIndex(
this.state.selectedLinearElement,
this.state,
hitCoords,
this.scene.getNonDeletedElementsMap(),
)
: -1;
if (midPoint && midPoint > -1) {
this.store.shouldCaptureIncrement();
LinearElementEditor.deleteFixedSegment(selectedElements[0], midPoint);
const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords(
{
...this.state.selectedLinearElement,
segmentMidPointHoveredCoords: null,
},
{ x: sceneX, y: sceneY },
this.state,
this.scene.getNonDeletedElementsMap(),
);
const nextIndex = nextCoords
? LinearElementEditor.getSegmentMidPointIndex(
this.state.selectedLinearElement,
this.state,
nextCoords,
this.scene.getNonDeletedElementsMap(),
)
: null;
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
pointerDownState: {
...this.state.selectedLinearElement.pointerDownState,
segmentMidpoint: {
index: nextIndex,
value: hitCoords,
added: false,
},
},
segmentMidPointHoveredCoords: nextCoords,
},
});
return;
}
} }
} }
@@ -5384,11 +5432,6 @@ class App extends React.Component<AppProps, AppState> {
resetCursor(this.interactiveCanvas); resetCursor(this.interactiveCanvas);
let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
event,
this.state,
);
const selectedGroupIds = getSelectedGroupIds(this.state); const selectedGroupIds = getSelectedGroupIds(this.state);
if (selectedGroupIds.length > 0) { if (selectedGroupIds.length > 0) {
@@ -5845,41 +5888,23 @@ class App extends React.Component<AppProps, AppState> {
if (isPathALoop(points, this.state.zoom.value)) { if (isPathALoop(points, this.state.zoom.value)) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
} }
if (isElbowArrow(multiElement)) { // update last uncommitted point
mutateElbowArrow( mutateElement(
multiElement, multiElement,
this.scene.getNonDeletedElementsMap(), {
[ points: [
...points.slice(0, -1), ...points.slice(0, -1),
pointFrom<LocalPoint>( pointFrom<LocalPoint>(
lastCommittedX + dxFromLastCommitted, lastCommittedX + dxFromLastCommitted,
lastCommittedY + dyFromLastCommitted, lastCommittedY + dyFromLastCommitted,
), ),
], ],
undefined, },
undefined, false,
{ {
isDragging: true, isDragging: true,
informMutation: false, },
zoom: this.state.zoom, );
},
);
} else {
// update last uncommitted point
mutateElement(
multiElement,
{
points: [
...points.slice(0, -1),
pointFrom<LocalPoint>(
lastCommittedX + dxFromLastCommitted,
lastCommittedY + dyFromLastCommitted,
),
],
},
false,
);
}
// in this path, we're mutating multiElement to reflect // in this path, we're mutating multiElement to reflect
// how it will be after adding pointer position as the next point // how it will be after adding pointer position as the next point
@@ -6045,7 +6070,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ this.setState({
activeEmbeddable: { element: hitElement, state: "hover" }, activeEmbeddable: { element: hitElement, state: "hover" },
}); });
} else { } else if (!hitElement || !isElbowArrow(hitElement)) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
if (this.state.activeEmbeddable?.state === "hover") { if (this.state.activeEmbeddable?.state === "hover") {
this.setState({ activeEmbeddable: null }); this.setState({ activeEmbeddable: null });
@@ -6231,14 +6256,18 @@ class App extends React.Component<AppProps, AppState> {
this.state, this.state,
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
); );
const isHoveringAPointHandle = isElbowArrow(element)
if (hoverPointIndex >= 0 || segmentMidPointHoveredCoords) { ? hoverPointIndex === 0 ||
hoverPointIndex === element.points.length - 1
: hoverPointIndex >= 0;
if (isHoveringAPointHandle || segmentMidPointHoveredCoords) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
} else if (this.hitElement(scenePointerX, scenePointerY, element)) { } else if (this.hitElement(scenePointerX, scenePointerY, element)) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
} }
} else if (this.hitElement(scenePointerX, scenePointerY, element)) { } else if (this.hitElement(scenePointerX, scenePointerY, element)) {
if ( if (
// Ebow arrows can only be moved when unconnected
!isElbowArrow(element) || !isElbowArrow(element) ||
!(element.startBinding || element.endBinding) !(element.startBinding || element.endBinding)
) { ) {
@@ -6288,7 +6317,7 @@ class App extends React.Component<AppProps, AppState> {
focus: false, focus: false,
})), })),
})); }));
jotaiStore.set(searchItemInFocusAtom, null); editorJotaiStore.set(searchItemInFocusAtom, null);
} }
// since contextMenu options are potentially evaluated on each render, // since contextMenu options are potentially evaluated on each render,
@@ -6531,6 +6560,15 @@ class App extends React.Component<AppProps, AppState> {
this.state.activeTool.type, this.state.activeTool.type,
pointerDownState, pointerDownState,
); );
} else if (this.state.activeTool.type === "lassoSelection") {
// Begin a mark capture. This does not have to update state yet.
const [gridX, gridY] = getGridPoint(
pointerDownState.origin.x,
pointerDownState.origin.y,
null,
);
this.lassoTrail.startPath(gridX, gridY);
} else if (this.state.activeTool.type === "custom") { } else if (this.state.activeTool.type === "custom") {
setCursorForShape(this.interactiveCanvas, this.state); setCursorForShape(this.interactiveCanvas, this.state);
} else if ( } else if (
@@ -6968,6 +7006,7 @@ class App extends React.Component<AppProps, AppState> {
if ( if (
selectedElements.length === 1 && selectedElements.length === 1 &&
!this.state.editingLinearElement && !this.state.editingLinearElement &&
!isElbowArrow(selectedElements[0]) &&
!( !(
this.state.selectedLinearElement && this.state.selectedLinearElement &&
this.state.selectedLinearElement.hoverPointIndex !== -1 this.state.selectedLinearElement.hoverPointIndex !== -1
@@ -7669,6 +7708,10 @@ class App extends React.Component<AppProps, AppState> {
locked: false, locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null, frameId: topLayerFrame ? topLayerFrame.id : null,
elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow, elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow,
fixedSegments:
this.state.currentItemArrowType === ARROW_TYPE.elbow
? []
: null,
}) })
: newLinearElement({ : newLinearElement({
type: elementType, type: elementType,
@@ -7909,6 +7952,63 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
const pointerCoords = viewportCoordsToSceneCoords(event, this.state); const pointerCoords = viewportCoordsToSceneCoords(event, this.state);
if (
this.state.selectedLinearElement &&
this.state.selectedLinearElement.elbowed &&
this.state.selectedLinearElement.pointerDownState.segmentMidpoint.index
) {
const [gridX, gridY] = getGridPoint(
pointerCoords.x,
pointerCoords.y,
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
);
let index =
this.state.selectedLinearElement.pointerDownState.segmentMidpoint
.index;
if (index < 0) {
const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords(
{
...this.state.selectedLinearElement,
segmentMidPointHoveredCoords: null,
},
{ x: gridX, y: gridY },
this.state,
this.scene.getNonDeletedElementsMap(),
);
index = nextCoords
? LinearElementEditor.getSegmentMidPointIndex(
this.state.selectedLinearElement,
this.state,
nextCoords,
this.scene.getNonDeletedElementsMap(),
)
: -1;
}
const ret = LinearElementEditor.moveFixedSegment(
this.state.selectedLinearElement,
index,
gridX,
gridY,
this.scene.getNonDeletedElementsMap(),
);
flushSync(() => {
if (this.state.selectedLinearElement) {
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
segmentMidPointHoveredCoords: ret.segmentMidPointHoveredCoords,
pointerDownState: ret.pointerDownState,
},
});
}
});
return;
}
const lastPointerCoords = const lastPointerCoords =
this.lastPointerMoveCoords ?? pointerDownState.origin; this.lastPointerMoveCoords ?? pointerDownState.origin;
this.lastPointerMoveCoords = pointerCoords; this.lastPointerMoveCoords = pointerCoords;
@@ -8255,13 +8355,11 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
); );
flushSync(() => { this.setState({ snapLines });
this.setState({ snapLines });
});
// when we're editing the name of a frame, we want the user to be // when we're editing the name of a frame, we want the user to be
// able to select and interact with the text input // able to select and interact with the text input
!this.state.editingFrame && if (!this.state.editingFrame) {
dragSelectedElements( dragSelectedElements(
pointerDownState, pointerDownState,
selectedElements, selectedElements,
@@ -8270,6 +8368,7 @@ class App extends React.Component<AppProps, AppState> {
snapOffset, snapOffset,
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(), event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
); );
}
this.setState({ this.setState({
selectedElementsAreBeingDragged: true, selectedElementsAreBeingDragged: true,
@@ -8386,6 +8485,63 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.lastCoords.x = pointerCoords.x; pointerDownState.lastCoords.x = pointerCoords.x;
pointerDownState.lastCoords.y = pointerCoords.y; pointerDownState.lastCoords.y = pointerCoords.y;
this.maybeDragNewGenericElement(pointerDownState, event); this.maybeDragNewGenericElement(pointerDownState, event);
} else if (this.state.activeTool.type === "lassoSelection") {
const { intersectedElementIds, enclosedElementIds } =
this.lassoTrail.addPointToPath(pointerCoords.x, pointerCoords.y);
this.setState((prevState) => {
const elements = [...intersectedElementIds, ...enclosedElementIds];
const nextSelectedElementIds = elements.reduce((acc, id) => {
acc[id] = true;
return acc;
}, {} as Record<ExcalidrawElement["id"], true>);
const nextSelectedGroupIds = selectGroupsForSelectedElements(
{
selectedElementIds: nextSelectedElementIds,
editingGroupId: prevState.editingGroupId,
},
this.scene.getNonDeletedElements(),
prevState,
this,
);
// TODO: not entirely correct (need to select all elements in group instead)
for (const [id, selected] of Object.entries(nextSelectedElementIds)) {
if (selected) {
const element = this.scene.getNonDeletedElement(id);
if (element && element.groupIds.length > 0) {
delete nextSelectedElementIds[id];
}
}
}
// TODO: make elegant and decide if all children are selected, do we keep?
for (const [id, selected] of Object.entries(nextSelectedElementIds)) {
if (selected) {
const element = this.scene.getNonDeletedElement(id);
if (element && isFrameLikeElement(element)) {
const elementsInFrame = getFrameChildren(
elementsMap,
element.id,
);
for (const child of elementsInFrame) {
delete nextSelectedElementIds[child.id];
}
}
}
}
return {
selectedElementIds: makeNextSelectedElementIds(
nextSelectedElementIds,
prevState,
),
selectedGroupIds: nextSelectedGroupIds.selectedGroupIds,
};
});
} else { } else {
// It is very important to read this.state within each move event, // It is very important to read this.state within each move event,
// otherwise we would read a stale one! // otherwise we would read a stale one!
@@ -8445,26 +8601,17 @@ class App extends React.Component<AppProps, AppState> {
}, },
false, false,
); );
} else if (points.length > 1 && isElbowArrow(newElement)) { } else if (
mutateElbowArrow( points.length === 2 ||
newElement, (points.length > 1 && isElbowArrow(newElement))
elementsMap, ) {
[...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
vector(0, 0),
undefined,
{
isDragging: true,
informMutation: false,
zoom: this.state.zoom,
},
);
} else if (points.length === 2) {
mutateElement( mutateElement(
newElement, newElement,
{ {
points: [...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)], points: [...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
}, },
false, false,
{ isDragging: true },
); );
} }
@@ -8531,6 +8678,7 @@ class App extends React.Component<AppProps, AppState> {
elements, elements,
this.state.selectionElement, this.state.selectionElement,
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
false,
) )
: []; : [];
@@ -8648,6 +8796,8 @@ class App extends React.Component<AppProps, AppState> {
originSnapOffset: null, originSnapOffset: null,
})); }));
this.lassoTrail.endPath();
this.lastPointerMoveCoords = null; this.lastPointerMoveCoords = null;
SnapCache.setReferenceSnapPoints(null); SnapCache.setReferenceSnapPoints(null);
@@ -8659,6 +8809,24 @@ class App extends React.Component<AppProps, AppState> {
selectedElementsAreBeingDragged: false, selectedElementsAreBeingDragged: false,
}); });
const elementsMap = this.scene.getNonDeletedElementsMap(); const elementsMap = this.scene.getNonDeletedElementsMap();
if (
pointerDownState.drag.hasOccurred &&
pointerDownState.hit?.element?.id
) {
const element = elementsMap.get(pointerDownState.hit.element.id);
if (isBindableElement(element)) {
// Renormalize elbow arrows when they are changed via indirect move
element.boundElements
?.filter((e) => e.type === "arrow")
.map((e) => elementsMap.get(e.id))
.filter((e) => isElbowArrow(e))
.forEach((e) => {
!!e && mutateElement(e, {}, true);
});
}
}
// Handle end of dragging a point of a linear element, might close a loop // Handle end of dragging a point of a linear element, might close a loop
// and sets binding element // and sets binding element
if (this.state.editingLinearElement) { if (this.state.editingLinearElement) {
@@ -8683,6 +8851,17 @@ class App extends React.Component<AppProps, AppState> {
} }
} }
} else if (this.state.selectedLinearElement) { } else if (this.state.selectedLinearElement) {
// Normalize elbow arrow points, remove close parallel segments
if (this.state.selectedLinearElement.elbowed) {
const element = LinearElementEditor.getElement(
this.state.selectedLinearElement.elementId,
this.scene.getNonDeletedElementsMap(),
);
if (element) {
mutateElement(element, {}, true);
}
}
if ( if (
pointerDownState.hit?.element?.id !== pointerDownState.hit?.element?.id !==
this.state.selectedLinearElement.elementId this.state.selectedLinearElement.elementId
@@ -8785,6 +8964,7 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
if (isImageElement(newElement)) { if (isImageElement(newElement)) {
const imageElement = newElement; const imageElement = newElement;
try { try {
@@ -8930,6 +9110,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getElementsMapIncludingDeleted(), this.scene.getElementsMapIncludingDeleted(),
elementsInsideFrame, elementsInsideFrame,
newElement, newElement,
this.state,
), ),
); );
} }
@@ -9047,6 +9228,7 @@ class App extends React.Component<AppProps, AppState> {
nextElements, nextElements,
elementsToAdd, elementsToAdd,
topLayerFrame, topLayerFrame,
this.state,
); );
} else if (!topLayerFrame) { } else if (!topLayerFrame) {
if (this.state.editingGroupId) { if (this.state.editingGroupId) {
@@ -9122,10 +9304,10 @@ class App extends React.Component<AppProps, AppState> {
this.state.selectedLinearElement?.elementId !== hitElement?.id && this.state.selectedLinearElement?.elementId !== hitElement?.id &&
isLinearElement(hitElement) isLinearElement(hitElement)
) { ) {
const selectedELements = this.scene.getSelectedElements(this.state); const selectedElements = this.scene.getSelectedElements(this.state);
// set selectedLinearElement when no other element selected except // set selectedLinearElement when no other element selected except
// the one we've hit // the one we've hit
if (selectedELements.length === 1) { if (selectedElements.length === 1) {
this.setState({ this.setState({
selectedLinearElement: new LinearElementEditor(hitElement), selectedLinearElement: new LinearElementEditor(hitElement),
}); });
@@ -9333,6 +9515,8 @@ class App extends React.Component<AppProps, AppState> {
} }
if ( if (
// not elbow midpoint dragged
!(hitElement && isElbowArrow(hitElement)) &&
// not dragged // not dragged
!pointerDownState.drag.hasOccurred && !pointerDownState.drag.hasOccurred &&
// not resized // not resized
@@ -10661,8 +10845,10 @@ class App extends React.Component<AppProps, AppState> {
actionCut, actionCut,
actionCopy, actionCopy,
actionPaste, actionPaste,
CONTEXT_MENU_SEPARATOR,
actionSelectAllElementsInFrame, actionSelectAllElementsInFrame,
actionRemoveAllElementsFromFrame, actionRemoveAllElementsFromFrame,
actionWrapSelectionInFrame,
CONTEXT_MENU_SEPARATOR, CONTEXT_MENU_SEPARATOR,
actionToggleCropEditor, actionToggleCropEditor,
CONTEXT_MENU_SEPARATOR, CONTEXT_MENU_SEPARATOR,
@@ -1,10 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { getColor } from "./ColorPicker"; import { getColor } from "./ColorPicker";
import { useAtom } from "jotai";
import type { ColorPickerType } from "./colorPickerUtils"; import type { ColorPickerType } from "./colorPickerUtils";
import { activeColorPickerSectionAtom } from "./colorPickerUtils"; import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import { eyeDropperIcon } from "../icons"; import { eyeDropperIcon } from "../icons";
import { jotaiScope } from "../../jotai"; import { useAtom } from "../../editor-jotai";
import { KEYS } from "../../keys"; import { KEYS } from "../../keys";
import { activeEyeDropperAtom } from "../EyeDropper"; import { activeEyeDropperAtom } from "../EyeDropper";
import clsx from "clsx"; import clsx from "clsx";
@@ -57,10 +56,7 @@ export const ColorInput = ({
} }
}, [activeSection]); }, [activeSection]);
const [eyeDropperState, setEyeDropperState] = useAtom( const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
activeEyeDropperAtom,
jotaiScope,
);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -5,7 +5,6 @@ import { TopPicks } from "./TopPicks";
import { ButtonSeparator } from "../ButtonSeparator"; import { ButtonSeparator } from "../ButtonSeparator";
import { Picker } from "./Picker"; import { Picker } from "./Picker";
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
import { useAtom } from "jotai";
import type { ColorPickerType } from "./colorPickerUtils"; import type { ColorPickerType } from "./colorPickerUtils";
import { activeColorPickerSectionAtom } from "./colorPickerUtils"; import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import { useExcalidrawContainer } from "../App"; import { useExcalidrawContainer } from "../App";
@@ -15,7 +14,7 @@ import PickerHeading from "./PickerHeading";
import { t } from "../../i18n"; import { t } from "../../i18n";
import clsx from "clsx"; import clsx from "clsx";
import { useRef } from "react"; import { useRef } from "react";
import { jotaiScope } from "../../jotai"; import { useAtom } from "../../editor-jotai";
import { ColorInput } from "./ColorInput"; import { ColorInput } from "./ColorInput";
import { activeEyeDropperAtom } from "../EyeDropper"; import { activeEyeDropperAtom } from "../EyeDropper";
import { PropertiesPopover } from "../PropertiesPopover"; import { PropertiesPopover } from "../PropertiesPopover";
@@ -76,10 +75,7 @@ const ColorPickerPopupContent = ({
const { container } = useExcalidrawContainer(); const { container } = useExcalidrawContainer();
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom); const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
const [eyeDropperState, setEyeDropperState] = useAtom( const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
activeEyeDropperAtom,
jotaiScope,
);
const colorInputJSX = ( const colorInputJSX = (
<div> <div>
@@ -1,5 +1,5 @@
import clsx from "clsx"; import clsx from "clsx";
import { useAtom } from "jotai"; import { useAtom } from "../../editor-jotai";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { activeColorPickerSectionAtom } from "./colorPickerUtils"; import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import HotkeyLabel from "./HotkeyLabel"; import HotkeyLabel from "./HotkeyLabel";
@@ -5,7 +5,7 @@ import type { ExcalidrawElement } from "../../element/types";
import { ShadeList } from "./ShadeList"; import { ShadeList } from "./ShadeList";
import PickerColorList from "./PickerColorList"; import PickerColorList from "./PickerColorList";
import { useAtom } from "jotai"; import { useAtom } from "../../editor-jotai";
import { CustomColorList } from "./CustomColorList"; import { CustomColorList } from "./CustomColorList";
import { colorPickerKeyNavHandler } from "./keyboardNavHandlers"; import { colorPickerKeyNavHandler } from "./keyboardNavHandlers";
import PickerHeading from "./PickerHeading"; import PickerHeading from "./PickerHeading";
@@ -1,5 +1,5 @@
import clsx from "clsx"; import clsx from "clsx";
import { useAtom } from "jotai"; import { useAtom } from "../../editor-jotai";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { import {
activeColorPickerSectionAtom, activeColorPickerSectionAtom,
@@ -1,5 +1,5 @@
import clsx from "clsx"; import clsx from "clsx";
import { useAtom } from "jotai"; import { useAtom } from "../../editor-jotai";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { import {
activeColorPickerSectionAtom, activeColorPickerSectionAtom,
@@ -1,7 +1,7 @@
import type { ExcalidrawElement } from "../../element/types"; import type { ExcalidrawElement } from "../../element/types";
import { atom } from "jotai";
import type { ColorPickerColor, ColorPaletteCustom } from "../../colors"; import type { ColorPickerColor, ColorPaletteCustom } from "../../colors";
import { MAX_CUSTOM_COLORS_USED_IN_CANVAS } from "../../colors"; import { MAX_CUSTOM_COLORS_USED_IN_CANVAS } from "../../colors";
import { atom } from "../../editor-jotai";
export const getColorNameAndShadeFromColor = ({ export const getColorNameAndShadeFromColor = ({
palette, palette,
@@ -36,7 +36,7 @@ import {
getShortcutKey, getShortcutKey,
isWritableElement, isWritableElement,
} from "../../utils"; } from "../../utils";
import { atom, useAtom } from "jotai"; import { atom, useAtom, editorJotaiStore } from "../../editor-jotai";
import { deburr } from "../../deburr"; import { deburr } from "../../deburr";
import type { MarkRequired } from "../../utility-types"; import type { MarkRequired } from "../../utility-types";
import { InlineIcon } from "../InlineIcon"; import { InlineIcon } from "../InlineIcon";
@@ -48,7 +48,6 @@ import {
actionLink, actionLink,
actionToggleSearchMenu, actionToggleSearchMenu,
} from "../../actions"; } from "../../actions";
import { jotaiStore } from "../../jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog"; import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import type { CommandPaletteItem } from "./types"; import type { CommandPaletteItem } from "./types";
import * as defaultItems from "./defaultCommandPaletteItems"; import * as defaultItems from "./defaultCommandPaletteItems";
@@ -263,6 +262,7 @@ function CommandPaletteInner({
actionManager.actions.cut, actionManager.actions.cut,
actionManager.actions.copy, actionManager.actions.copy,
actionManager.actions.deleteSelectedElements, actionManager.actions.deleteSelectedElements,
actionManager.actions.wrapSelectionInFrame,
actionManager.actions.copyStyles, actionManager.actions.copyStyles,
actionManager.actions.pasteStyles, actionManager.actions.pasteStyles,
actionManager.actions.bringToFront, actionManager.actions.bringToFront,
@@ -348,7 +348,7 @@ function CommandPaletteInner({
keywords: ["delete", "destroy"], keywords: ["delete", "destroy"],
viewMode: false, viewMode: false,
perform: () => { perform: () => {
jotaiStore.set(activeConfirmDialogAtom, "clearCanvas"); editorJotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
}, },
}, },
{ {
@@ -5,10 +5,9 @@ import { Dialog } from "./Dialog";
import "./ConfirmDialog.scss"; import "./ConfirmDialog.scss";
import DialogActionButton from "./DialogActionButton"; import DialogActionButton from "./DialogActionButton";
import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenu"; import { isLibraryMenuOpenAtom } from "./LibraryMenu";
import { useExcalidrawContainer, useExcalidrawSetAppState } from "./App"; import { useExcalidrawContainer, useExcalidrawSetAppState } from "./App";
import { jotaiScope } from "../jotai"; import { useSetAtom } from "../editor-jotai";
interface Props extends Omit<DialogProps, "onCloseRequest"> { interface Props extends Omit<DialogProps, "onCloseRequest"> {
onConfirm: () => void; onConfirm: () => void;
@@ -27,7 +26,7 @@ const ConfirmDialog = (props: Props) => {
...rest ...rest
} = props; } = props;
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope); const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
const { container } = useExcalidrawContainer(); const { container } = useExcalidrawContainer();
return ( return (
+2 -3
View File
@@ -11,9 +11,8 @@ import "./Dialog.scss";
import { Island } from "./Island"; import { Island } from "./Island";
import { Modal } from "./Modal"; import { Modal } from "./Modal";
import { queryFocusableElements } from "../utils"; import { queryFocusableElements } from "../utils";
import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenu"; import { isLibraryMenuOpenAtom } from "./LibraryMenu";
import { jotaiScope } from "../jotai"; import { useSetAtom } from "../editor-jotai";
import { t } from "../i18n"; import { t } from "../i18n";
import { CloseIcon } from "./icons"; import { CloseIcon } from "./icons";
@@ -92,7 +91,7 @@ export const Dialog = (props: DialogProps) => {
}, [islandNode, props.autofocus]); }, [islandNode, props.autofocus]);
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope); const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
const onClose = () => { const onClose = () => {
setAppState({ openMenu: null }); setAppState({ openMenu: null });
@@ -1,4 +1,3 @@
import { atom } from "jotai";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { rgbToHex } from "../colors"; import { rgbToHex } from "../colors";
@@ -14,6 +13,7 @@ import { useStable } from "../hooks/useStable";
import "./EyeDropper.scss"; import "./EyeDropper.scss";
import type { ColorPickerType } from "./ColorPicker/colorPickerUtils"; import type { ColorPickerType } from "./ColorPicker/colorPickerUtils";
import type { ExcalidrawElement } from "../element/types"; import type { ExcalidrawElement } from "../element/types";
import { atom } from "../editor-jotai";
export type EyeDropperProperties = { export type EyeDropperProperties = {
keepOpenOnAlt: boolean; keepOpenOnAlt: boolean;
@@ -1,15 +1,14 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
import "./IconPicker.scss";
import { isArrowKey, KEYS } from "../keys"; import { isArrowKey, KEYS } from "../keys";
import { getLanguage, t } from "../i18n"; import { getLanguage, t } from "../i18n";
import clsx from "clsx"; import clsx from "clsx";
import Collapsible from "./Stats/Collapsible"; import Collapsible from "./Stats/Collapsible";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "../editor-jotai";
import { jotaiScope } from "../jotai";
import { useDevice } from ".."; import { useDevice } from "..";
import "./IconPicker.scss";
const moreOptionsAtom = atom(false); const moreOptionsAtom = atom(false);
type Option<T> = { type Option<T> = {
@@ -94,10 +93,7 @@ function Picker<T>({
event.stopPropagation(); event.stopPropagation();
}; };
const [showMoreOptions, setShowMoreOptions] = useAtom( const [showMoreOptions, setShowMoreOptions] = useAtom(moreOptionsAtom);
moreOptionsAtom,
jotaiScope,
);
const alwaysVisibleOptions = React.useMemo( const alwaysVisibleOptions = React.useMemo(
() => options.slice(0, numberOfOptionsToAlwaysShow), () => options.slice(0, numberOfOptionsToAlwaysShow),
+8 -9
View File
@@ -41,8 +41,7 @@ import { trackEvent } from "../analytics";
import { useDevice } from "./App"; import { useDevice } from "./App";
import Footer from "./footer/Footer"; import Footer from "./footer/Footer";
import { isSidebarDockedAtom } from "./Sidebar/Sidebar"; import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import { jotaiScope } from "../jotai"; import { useAtom, useAtomValue } from "../editor-jotai";
import { Provider, useAtom, useAtomValue } from "jotai";
import MainMenu from "./main-menu/MainMenu"; import MainMenu from "./main-menu/MainMenu";
import { ActiveConfirmDialog } from "./ActiveConfirmDialog"; import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
import { OverwriteConfirmDialog } from "./OverwriteConfirm/OverwriteConfirm"; import { OverwriteConfirmDialog } from "./OverwriteConfirm/OverwriteConfirm";
@@ -148,10 +147,9 @@ const LayerUI = ({
const device = useDevice(); const device = useDevice();
const tunnels = useInitializeTunnels(); const tunnels = useInitializeTunnels();
const [eyeDropperState, setEyeDropperState] = useAtom( const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider;
activeEyeDropperAtom,
jotaiScope, const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
);
const renderJSONExportDialog = () => { const renderJSONExportDialog = () => {
if (!UIOptions.canvasActions.export) { if (!UIOptions.canvasActions.export) {
@@ -221,6 +219,7 @@ const LayerUI = ({
appState={appState} appState={appState}
elementsMap={app.scene.getNonDeletedElementsMap()} elementsMap={app.scene.getNonDeletedElementsMap()}
renderAction={actionManager.renderAction} renderAction={actionManager.renderAction}
app={app}
/> />
</Island> </Island>
</Section> </Section>
@@ -382,7 +381,7 @@ const LayerUI = ({
); );
}; };
const isSidebarDocked = useAtomValue(isSidebarDockedAtom, jotaiScope); const isSidebarDocked = useAtomValue(isSidebarDockedAtom);
const layerUIJSX = ( const layerUIJSX = (
<> <>
@@ -566,11 +565,11 @@ const LayerUI = ({
return ( return (
<UIAppStateContext.Provider value={appState}> <UIAppStateContext.Provider value={appState}>
<Provider scope={tunnels.jotaiScope}> <TunnelsJotaiProvider>
<TunnelsContext.Provider value={tunnels}> <TunnelsContext.Provider value={tunnels}>
{layerUIJSX} {layerUIJSX}
</TunnelsContext.Provider> </TunnelsContext.Provider>
</Provider> </TunnelsJotaiProvider>
</UIAppStateContext.Provider> </UIAppStateContext.Provider>
); );
}; };
@@ -14,8 +14,7 @@ import type {
} from "../types"; } from "../types";
import LibraryMenuItems from "./LibraryMenuItems"; import LibraryMenuItems from "./LibraryMenuItems";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "../editor-jotai";
import { jotaiScope } from "../jotai";
import Spinner from "./Spinner"; import Spinner from "./Spinner";
import { import {
useApp, useApp,
@@ -61,7 +60,7 @@ export const LibraryMenuContent = ({
selectedItems: LibraryItem["id"][]; selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void; onSelectItems: (id: LibraryItem["id"][]) => void;
}) => { }) => {
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope); const [libraryItemsData] = useAtom(libraryItemsAtom);
const _onAddToLibrary = useCallback( const _onAddToLibrary = useCallback(
(elements: LibraryItem["elements"]) => { (elements: LibraryItem["elements"]) => {
@@ -1,7 +1,7 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { t } from "../i18n"; import { t } from "../i18n";
import Trans from "./Trans"; import Trans from "./Trans";
import { jotaiScope } from "../jotai"; import { useAtom } from "../editor-jotai";
import type { LibraryItem, LibraryItems, UIAppState } from "../types"; import type { LibraryItem, LibraryItems, UIAppState } from "../types";
import { useApp, useExcalidrawSetAppState } from "./App"; import { useApp, useExcalidrawSetAppState } from "./App";
import { saveLibraryAsJSON } from "../data/json"; import { saveLibraryAsJSON } from "../data/json";
@@ -17,7 +17,6 @@ import {
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { fileOpen } from "../data/filesystem"; import { fileOpen } from "../data/filesystem";
import { muteFSAbortError } from "../utils"; import { muteFSAbortError } from "../utils";
import { useAtom } from "jotai";
import ConfirmDialog from "./ConfirmDialog"; import ConfirmDialog from "./ConfirmDialog";
import PublishLibrary from "./PublishLibrary"; import PublishLibrary from "./PublishLibrary";
import { Dialog } from "./Dialog"; import { Dialog } from "./Dialog";
@@ -51,10 +50,9 @@ export const LibraryDropdownMenuButton: React.FC<{
appState, appState,
className, className,
}) => { }) => {
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope); const [libraryItemsData] = useAtom(libraryItemsAtom);
const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom( const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom(
isLibraryMenuOpenAtom, isLibraryMenuOpenAtom,
jotaiScope,
); );
const renderRemoveLibAlert = () => { const renderRemoveLibAlert = () => {
@@ -286,7 +284,7 @@ export const LibraryDropdownMenu = ({
const appState = useUIAppState(); const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope); const [libraryItemsData] = useAtom(libraryItemsAtom);
const removeFromLibrary = async (libraryItems: LibraryItems) => { const removeFromLibrary = async (libraryItems: LibraryItems) => {
const nextItems = libraryItems.filter( const nextItems = libraryItems.filter(
@@ -179,6 +179,7 @@ export const MobileMenu = ({
appState={appState} appState={appState}
elementsMap={app.scene.getNonDeletedElementsMap()} elementsMap={app.scene.getNonDeletedElementsMap()}
renderAction={actionManager.renderAction} renderAction={actionManager.renderAction}
app={app}
/> />
</Section> </Section>
) : null} ) : null}
@@ -1,8 +1,7 @@
import React from "react"; import React from "react";
import { useAtom } from "jotai";
import { useTunnels } from "../../context/tunnels"; import { useTunnels } from "../../context/tunnels";
import { jotaiScope } from "../../jotai"; import { useAtom } from "../../editor-jotai";
import { Dialog } from "../Dialog"; import { Dialog } from "../Dialog";
import { withInternalFallback } from "../hoc/withInternalFallback"; import { withInternalFallback } from "../hoc/withInternalFallback";
import { overwriteConfirmStateAtom } from "./OverwriteConfirmState"; import { overwriteConfirmStateAtom } from "./OverwriteConfirmState";
@@ -23,7 +22,6 @@ const OverwriteConfirmDialog = Object.assign(
const { OverwriteConfirmDialogTunnel } = useTunnels(); const { OverwriteConfirmDialogTunnel } = useTunnels();
const [overwriteConfirmState, setState] = useAtom( const [overwriteConfirmState, setState] = useAtom(
overwriteConfirmStateAtom, overwriteConfirmStateAtom,
jotaiScope,
); );
if (!overwriteConfirmState.active) { if (!overwriteConfirmState.active) {
@@ -1,5 +1,4 @@
import { atom } from "jotai"; import { atom, editorJotaiStore } from "../../editor-jotai";
import { jotaiStore } from "../../jotai";
import type React from "react"; import type React from "react";
export type OverwriteConfirmState = export type OverwriteConfirmState =
@@ -32,7 +31,7 @@ export async function openConfirmModal({
color: "danger" | "warning"; color: "danger" | "warning";
}) { }) {
return new Promise<boolean>((resolve) => { return new Promise<boolean>((resolve) => {
jotaiStore.set(overwriteConfirmStateAtom, { editorJotaiStore.set(overwriteConfirmStateAtom, {
active: true, active: true,
onConfirm: () => resolve(true), onConfirm: () => resolve(true),
onClose: () => resolve(false), onClose: () => resolve(false),
@@ -11,8 +11,7 @@ import { measureText } from "../element/textElement";
import { addEventListener, getFontString } from "../utils"; import { addEventListener, getFontString } from "../utils";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import clsx from "clsx"; import clsx from "clsx";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "../editor-jotai";
import { jotaiScope } from "../jotai";
import { t } from "../i18n"; import { t } from "../i18n";
import { isElementCompletelyInViewport } from "../element/sizeHelpers"; import { isElementCompletelyInViewport } from "../element/sizeHelpers";
import { randomInteger } from "../random"; import { randomInteger } from "../random";
@@ -58,7 +57,7 @@ export const SearchMenu = () => {
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useAtom(searchQueryAtom, jotaiScope); const [inputValue, setInputValue] = useAtom(searchQueryAtom);
const searchQuery = inputValue.trim() as SearchQuery; const searchQuery = inputValue.trim() as SearchQuery;
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
@@ -70,10 +69,7 @@ export const SearchMenu = () => {
const searchedQueryRef = useRef<SearchQuery | null>(null); const searchedQueryRef = useRef<SearchQuery | null>(null);
const lastSceneNonceRef = useRef<number | undefined>(undefined); const lastSceneNonceRef = useRef<number | undefined>(undefined);
const [focusIndex, setFocusIndex] = useAtom( const [focusIndex, setFocusIndex] = useAtom(searchItemInFocusAtom);
searchItemInFocusAtom,
jotaiScope,
);
const elementsMap = app.scene.getNonDeletedElementsMap(); const elementsMap = app.scene.getNonDeletedElementsMap();
useEffect(() => { useEffect(() => {
@@ -8,8 +8,7 @@ import React, {
useCallback, useCallback,
} from "react"; } from "react";
import { Island } from "../Island"; import { Island } from "../Island";
import { atom, useSetAtom } from "jotai"; import { atom, useSetAtom } from "../../editor-jotai";
import { jotaiScope } from "../../jotai";
import type { SidebarProps, SidebarPropsContextValue } from "./common"; import type { SidebarProps, SidebarPropsContextValue } from "./common";
import { SidebarPropsContext } from "./common"; import { SidebarPropsContext } from "./common";
import { SidebarHeader } from "./SidebarHeader"; import { SidebarHeader } from "./SidebarHeader";
@@ -58,7 +57,7 @@ export const SidebarInner = forwardRef(
const setAppState = useExcalidrawSetAppState(); const setAppState = useExcalidrawSetAppState();
const setIsSidebarDockedAtom = useSetAtom(isSidebarDockedAtom, jotaiScope); const setIsSidebarDockedAtom = useSetAtom(isSidebarDockedAtom);
useLayoutEffect(() => { useLayoutEffect(() => {
setIsSidebarDockedAtom(!!docked); setIsSidebarDockedAtom(!!docked);
@@ -237,6 +237,7 @@ const MultiPosition = ({
const [x1, y1] = getCommonBounds(elementsInUnit); const [x1, y1] = getCommonBounds(elementsInUnit);
return Math.round((property === "x" ? x1 : y1) * 100) / 100; return Math.round((property === "x" ? x1 : y1) * 100) / 100;
} }
const [el] = elementsInUnit; const [el] = elementsInUnit;
const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2]; const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
@@ -31,6 +31,7 @@ import "./Stats.scss";
import { isGridModeEnabled } from "../../snapping"; import { isGridModeEnabled } from "../../snapping";
import { getUncroppedWidthAndHeight } from "../../element/cropElement"; import { getUncroppedWidthAndHeight } from "../../element/cropElement";
import { round } from "../../../math"; import { round } from "../../../math";
import { frameAndChildrenSelectedTogether } from "../../frame";
interface StatsProps { interface StatsProps {
app: AppClassProperties; app: AppClassProperties;
@@ -170,6 +171,10 @@ export const StatsInner = memo(
return getAtomicUnits(selectedElements, appState); return getAtomicUnits(selectedElements, appState);
}, [selectedElements, appState]); }, [selectedElements, appState]);
const _frameAndChildrenSelectedTogether = useMemo(() => {
return frameAndChildrenSelectedTogether(selectedElements);
}, [selectedElements]);
return ( return (
<div className="exc-stats"> <div className="exc-stats">
<Island padding={3}> <Island padding={3}>
@@ -226,7 +231,7 @@ export const StatsInner = memo(
{renderCustomStats?.(elements, appState)} {renderCustomStats?.(elements, appState)}
</Collapsible> </Collapsible>
{selectedElements.length > 0 && ( {!_frameAndChildrenSelectedTogether && selectedElements.length > 0 && (
<div <div
id="elementStats" id="elementStats"
style={{ style={{
@@ -25,7 +25,7 @@ import type { BinaryFiles } from "../../types";
import { ArrowRightIcon } from "../icons"; import { ArrowRightIcon } from "../icons";
import "./TTDDialog.scss"; import "./TTDDialog.scss";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "../../editor-jotai";
import { trackEvent } from "../../analytics"; import { trackEvent } from "../../analytics";
import { InlineIcon } from "../InlineIcon"; import { InlineIcon } from "../InlineIcon";
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut"; import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
+135 -129
View File
@@ -55,146 +55,152 @@ type ToolButtonProps =
onPointerDown?(data: { pointerType: PointerType }): void; onPointerDown?(data: { pointerType: PointerType }): void;
}); });
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => { export const ToolButton = React.forwardRef(
const { id: excalId } = useExcalidrawContainer(); (
const innerRef = React.useRef(null); {
React.useImperativeHandle(ref, () => innerRef.current); size = "medium",
const sizeCn = `ToolIcon_size_${props.size}`; visible = true,
className = "",
...props
}: ToolButtonProps,
ref,
) => {
const { id: excalId } = useExcalidrawContainer();
const innerRef = React.useRef(null);
React.useImperativeHandle(ref, () => innerRef.current);
const sizeCn = `ToolIcon_size_${size}`;
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const isMountedRef = useRef(true); const isMountedRef = useRef(true);
const onClick = async (event: React.MouseEvent) => { const onClick = async (event: React.MouseEvent) => {
const ret = "onClick" in props && props.onClick?.(event); const ret = "onClick" in props && props.onClick?.(event);
if (isPromiseLike(ret)) { if (isPromiseLike(ret)) {
try { try {
setIsLoading(true); setIsLoading(true);
await ret; await ret;
} catch (error: any) { } catch (error: any) {
if (!(error instanceof AbortError)) { if (!(error instanceof AbortError)) {
throw error; throw error;
} else { } else {
console.warn(error); console.warn(error);
} }
} finally { } finally {
if (isMountedRef.current) { if (isMountedRef.current) {
setIsLoading(false); setIsLoading(false);
}
} }
} }
}
};
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
}; };
}, []);
const lastPointerTypeRef = useRef<PointerType | null>(null); useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const lastPointerTypeRef = useRef<PointerType | null>(null);
if (
props.type === "button" ||
props.type === "icon" ||
props.type === "submit"
) {
const type = (props.type === "icon" ? "button" : props.type) as
| "button"
| "submit";
return (
<button
className={clsx(
"ToolIcon_type_button",
sizeCn,
className,
visible && !props.hidden
? "ToolIcon_type_button--show"
: "ToolIcon_type_button--hide",
{
ToolIcon: !props.hidden,
"ToolIcon--selected": props.selected,
"ToolIcon--plain": props.type === "icon",
},
)}
style={props.style}
data-testid={props["data-testid"]}
hidden={props.hidden}
title={props.title}
aria-label={props["aria-label"]}
type={type}
onClick={onClick}
ref={innerRef}
disabled={isLoading || props.isLoading || !!props.disabled}
>
{(props.icon || props.label) && (
<div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled={!!props.disabled}
>
{props.icon || props.label}
{props.keyBindingLabel && (
<span className="ToolIcon__keybinding">
{props.keyBindingLabel}
</span>
)}
{props.isLoading && <Spinner />}
</div>
)}
{props.showAriaLabel && (
<div className="ToolIcon__label">
{props["aria-label"]} {isLoading && <Spinner />}
</div>
)}
{props.children}
</button>
);
}
if (
props.type === "button" ||
props.type === "icon" ||
props.type === "submit"
) {
const type = (props.type === "icon" ? "button" : props.type) as
| "button"
| "submit";
return ( return (
<button <label
className={clsx( className={clsx("ToolIcon", className)}
"ToolIcon_type_button",
sizeCn,
props.className,
props.visible && !props.hidden
? "ToolIcon_type_button--show"
: "ToolIcon_type_button--hide",
{
ToolIcon: !props.hidden,
"ToolIcon--selected": props.selected,
"ToolIcon--plain": props.type === "icon",
},
)}
style={props.style}
data-testid={props["data-testid"]}
hidden={props.hidden}
title={props.title} title={props.title}
aria-label={props["aria-label"]} onPointerDown={(event) => {
type={type} lastPointerTypeRef.current = event.pointerType || null;
onClick={onClick} props.onPointerDown?.({ pointerType: event.pointerType || null });
ref={innerRef}
disabled={isLoading || props.isLoading || !!props.disabled}
>
{(props.icon || props.label) && (
<div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled={!!props.disabled}
>
{props.icon || props.label}
{props.keyBindingLabel && (
<span className="ToolIcon__keybinding">
{props.keyBindingLabel}
</span>
)}
{props.isLoading && <Spinner />}
</div>
)}
{props.showAriaLabel && (
<div className="ToolIcon__label">
{props["aria-label"]} {isLoading && <Spinner />}
</div>
)}
{props.children}
</button>
);
}
return (
<label
className={clsx("ToolIcon", props.className)}
title={props.title}
onPointerDown={(event) => {
lastPointerTypeRef.current = event.pointerType || null;
props.onPointerDown?.({ pointerType: event.pointerType || null });
}}
onPointerUp={() => {
requestAnimationFrame(() => {
lastPointerTypeRef.current = null;
});
}}
>
<input
className={`ToolIcon_type_radio ${sizeCn}`}
type="radio"
name={props.name}
aria-label={props["aria-label"]}
aria-keyshortcuts={props["aria-keyshortcuts"]}
data-testid={props["data-testid"]}
id={`${excalId}-${props.id}`}
onChange={() => {
props.onChange?.({ pointerType: lastPointerTypeRef.current });
}} }}
checked={props.checked} onPointerUp={() => {
ref={innerRef} requestAnimationFrame(() => {
/> lastPointerTypeRef.current = null;
<div className="ToolIcon__icon"> });
{props.icon} }}
{props.keyBindingLabel && ( >
<span className="ToolIcon__keybinding">{props.keyBindingLabel}</span> <input
)} className={`ToolIcon_type_radio ${sizeCn}`}
</div> type="radio"
</label> name={props.name}
); aria-label={props["aria-label"]}
}); aria-keyshortcuts={props["aria-keyshortcuts"]}
data-testid={props["data-testid"]}
ToolButton.defaultProps = { id={`${excalId}-${props.id}`}
visible: true, onChange={() => {
className: "", props.onChange?.({ pointerType: lastPointerTypeRef.current });
size: "medium", }}
}; checked={props.checked}
ref={innerRef}
/>
<div className="ToolIcon__icon">
{props.icon}
{props.keyBindingLabel && (
<span className="ToolIcon__keybinding">
{props.keyBindingLabel}
</span>
)}
</div>
</label>
);
},
);
ToolButton.displayName = "ToolButton"; ToolButton.displayName = "ToolButton";
@@ -4,6 +4,7 @@ import fallbackLangData from "../locales/en.json";
import Trans from "./Trans"; import Trans from "./Trans";
import type { TranslationKeys } from "../i18n"; import type { TranslationKeys } from "../i18n";
import { EditorJotaiProvider } from "../editor-jotai";
describe("Test <Trans/>", () => { describe("Test <Trans/>", () => {
it("should translate the the strings correctly", () => { it("should translate the the strings correctly", () => {
@@ -17,7 +18,7 @@ describe("Test <Trans/>", () => {
}; };
const { getByTestId } = render( const { getByTestId } = render(
<> <EditorJotaiProvider>
<div data-testid="test1"> <div data-testid="test1">
<Trans <Trans
i18nKey={"transTest.key1" as unknown as TranslationKeys} i18nKey={"transTest.key1" as unknown as TranslationKeys}
@@ -51,7 +52,7 @@ describe("Test <Trans/>", () => {
connect-link={(el) => <a href="https://example.com">{el}</a>} connect-link={(el) => <a href="https://example.com">{el}</a>}
/> />
</div> </div>
</>, </EditorJotaiProvider>,
); );
expect(getByTestId("test1").innerHTML).toEqual("Hello world"); expect(getByTestId("test1").innerHTML).toEqual("Hello world");
@@ -1,6 +1,6 @@
import { atom, useAtom } from "jotai";
import React, { useLayoutEffect, useRef } from "react"; import React, { useLayoutEffect, useRef } from "react";
import { useTunnels } from "../../context/tunnels"; import { useTunnels } from "../../context/tunnels";
import { atom } from "../../editor-jotai";
export const withInternalFallback = <P,>( export const withInternalFallback = <P,>(
componentName: string, componentName: string,
@@ -13,9 +13,11 @@ export const withInternalFallback = <P,>(
__fallback?: boolean; __fallback?: boolean;
} }
> = (props) => { > = (props) => {
const { jotaiScope } = useTunnels(); const {
tunnelsJotai: { useAtom },
} = useTunnels();
// for rerenders // for rerenders
const [, setCounter] = useAtom(renderAtom, jotaiScope); const [, setCounter] = useAtom(renderAtom);
// for initial & subsequent renders. Tracked as component state // for initial & subsequent renders. Tracked as component state
// due to excalidraw multi-instance scanerios. // due to excalidraw multi-instance scanerios.
const metaRef = useRef({ const metaRef = useRef({
@@ -171,15 +171,17 @@ export const Hyperlink = ({
}, [handleSubmit]); }, [handleSubmit]);
useEffect(() => { useEffect(() => {
let timeoutId: number | null = null;
if ( if (
inputRef && isEditing &&
inputRef.current && inputRef?.current &&
!(device.viewport.isMobile || device.isTouchScreen) !(device.viewport.isMobile || device.isTouchScreen)
) { ) {
inputRef.current.select(); inputRef.current.select();
} }
}, [isEditing, device.viewport.isMobile, device.isTouchScreen]);
useEffect(() => {
let timeoutId: number | null = null;
const handlePointerMove = (event: PointerEvent) => { const handlePointerMove = (event: PointerEvent) => {
if (isEditing) { if (isEditing) {
@@ -207,15 +209,7 @@ export const Hyperlink = ({
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }
}; };
}, [ }, [appState, element, isEditing, setAppState, elementsMap]);
appState,
element,
isEditing,
setAppState,
elementsMap,
device.viewport.isMobile,
device.isTouchScreen,
]);
const handleRemove = useCallback(() => { const handleRemove = useCallback(() => {
trackEvent("hyperlink", "delete"); trackEvent("hyperlink", "delete");
@@ -32,9 +32,8 @@ import {
actionToggleTheme, actionToggleTheme,
} from "../../actions"; } from "../../actions";
import clsx from "clsx"; import clsx from "clsx";
import { useSetAtom } from "jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog"; import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import { jotaiScope } from "../../jotai"; import { useSetAtom } from "../../editor-jotai";
import { useUIAppState } from "../../context/ui-appState"; import { useUIAppState } from "../../context/ui-appState";
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState"; import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
import Trans from "../Trans"; import Trans from "../Trans";
@@ -189,10 +188,7 @@ Help.displayName = "Help";
export const ClearCanvas = () => { export const ClearCanvas = () => {
const { t } = useI18n(); const { t } = useI18n();
const setActiveConfirmDialog = useSetAtom( const setActiveConfirmDialog = useSetAtom(activeConfirmDialogAtom);
activeConfirmDialogAtom,
jotaiScope,
);
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
if (!actionManager.isActionEnabled(actionClearCanvas)) { if (!actionManager.isActionEnabled(actionClearCanvas)) {
+1
View File
@@ -417,6 +417,7 @@ export const LIBRARY_DISABLED_TYPES = new Set([
// use these constants to easily identify reference sites // use these constants to easily identify reference sites
export const TOOL_TYPE = { export const TOOL_TYPE = {
selection: "selection", selection: "selection",
lassoSelection: "lassoSelection",
rectangle: "rectangle", rectangle: "rectangle",
diamond: "diamond", diamond: "diamond",
ellipse: "ellipse", ellipse: "ellipse",
+7 -2
View File
@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import tunnel from "tunnel-rat"; import tunnel from "tunnel-rat";
import { createIsolation } from "jotai-scope";
export type Tunnel = ReturnType<typeof tunnel>; export type Tunnel = ReturnType<typeof tunnel>;
@@ -14,13 +15,17 @@ type TunnelsContextValue = {
DefaultSidebarTabTriggersTunnel: Tunnel; DefaultSidebarTabTriggersTunnel: Tunnel;
OverwriteConfirmDialogTunnel: Tunnel; OverwriteConfirmDialogTunnel: Tunnel;
TTDDialogTriggerTunnel: Tunnel; TTDDialogTriggerTunnel: Tunnel;
jotaiScope: symbol; // this can be removed once we create jotai stores per each editor
// instance
tunnelsJotai: ReturnType<typeof createIsolation>;
}; };
export const TunnelsContext = React.createContext<TunnelsContextValue>(null!); export const TunnelsContext = React.createContext<TunnelsContextValue>(null!);
export const useTunnels = () => React.useContext(TunnelsContext); export const useTunnels = () => React.useContext(TunnelsContext);
const tunnelsJotai = createIsolation();
export const useInitializeTunnels = () => { export const useInitializeTunnels = () => {
return React.useMemo((): TunnelsContextValue => { return React.useMemo((): TunnelsContextValue => {
return { return {
@@ -34,7 +39,7 @@ export const useInitializeTunnels = () => {
DefaultSidebarTabTriggersTunnel: tunnel(), DefaultSidebarTabTriggersTunnel: tunnel(),
OverwriteConfirmDialogTunnel: tunnel(), OverwriteConfirmDialogTunnel: tunnel(),
TTDDialogTriggerTunnel: tunnel(), TTDDialogTriggerTunnel: tunnel(),
jotaiScope: Symbol(), tunnelsJotai,
}; };
}, []); }, []);
}; };
@@ -95,7 +95,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 33.519031369643244, "height": 35,
"id": Any<String>, "id": Any<String>,
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
@@ -109,8 +109,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
0.5, 0.5,
], ],
[ [
382.47606040672997, 394.5,
34.019031369643244, 34.5,
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -128,9 +128,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 7, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 381.97606040672997, "width": 395,
"x": 247, "x": 247,
"y": 420, "y": 420,
} }
@@ -167,7 +167,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
0, 0,
], ],
[ [
389.5, 399.5,
0, 0,
], ],
], ],
@@ -186,10 +186,10 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 6, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 390, "width": 400,
"x": 237, "x": 227,
"y": 450, "y": 450,
} }
`; `;
@@ -319,7 +319,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"verticalAlign": "top", "verticalAlign": "top",
"width": 100, "width": 100,
"x": 560, "x": 560,
"y": 236.95454545454544, "y": 226.5,
} }
`; `;
@@ -339,13 +339,13 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"endBinding": { "endBinding": {
"elementId": "text-2", "elementId": "text-2",
"fixedPoint": null, "fixedPoint": null,
"focus": 1.625925925925924, "focus": 0,
"gap": 14, "gap": 14,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 18.278619528619487, "height": 0,
"id": Any<String>, "id": Any<String>,
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
@@ -356,11 +356,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"points": [ "points": [
[ [
0.5, 0.5,
-0.5, 0,
], ],
[ [
357.2037037037038, 99.5,
-17.778619528619487, 0,
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -378,11 +378,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 6, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 357.7037037037038, "width": 100,
"x": 171, "x": 255,
"y": 249.45454545454544, "y": 239,
} }
`; `;
@@ -482,7 +482,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 6, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 100, "width": 100,
"x": 255, "x": 255,
@@ -660,7 +660,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 6, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 100, "width": 100,
"x": 255, "x": 255,
@@ -1505,7 +1505,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
0, 0,
], ],
[ [
270.98528125, 272.485,
0, 0,
], ],
], ],
@@ -1526,10 +1526,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 7, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 270.48528125, "width": 272.985,
"x": 112.76171875, "x": 111.262,
"y": 57, "y": 57,
} }
`; `;
@@ -1587,11 +1587,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 6, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 0, "width": 0,
"x": 83.015625, "x": 77.017,
"y": 81.5, "y": 79,
} }
`; `;
+105
View File
@@ -0,0 +1,105 @@
import { validateLibraryUrl } from "./library";
describe("validateLibraryUrl", () => {
it("should validate hostname & pathname", () => {
// valid hostnames
// -------------------------------------------------------------------------
expect(
validateLibraryUrl("https://www.excalidraw.com", ["excalidraw.com"]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com", ["excalidraw.com"]),
).toBe(true);
expect(
validateLibraryUrl("https://library.excalidraw.com", ["excalidraw.com"]),
).toBe(true);
expect(
validateLibraryUrl("https://library.excalidraw.com", [
"library.excalidraw.com",
]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/", ["excalidraw.com/"]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com", ["excalidraw.com/"]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/", ["excalidraw.com"]),
).toBe(true);
// valid pathnames
// -------------------------------------------------------------------------
expect(
validateLibraryUrl("https://excalidraw.com/path", ["excalidraw.com"]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/path/", ["excalidraw.com"]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/specific/path", [
"excalidraw.com/specific/path",
]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/specific/path/", [
"excalidraw.com/specific/path",
]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/specific/path", [
"excalidraw.com/specific/path/",
]),
).toBe(true);
expect(
validateLibraryUrl("https://excalidraw.com/specific/path/other", [
"excalidraw.com/specific/path",
]),
).toBe(true);
// invalid hostnames
// -------------------------------------------------------------------------
expect(() =>
validateLibraryUrl("https://xexcalidraw.com", ["excalidraw.com"]),
).toThrow();
expect(() =>
validateLibraryUrl("https://x-excalidraw.com", ["excalidraw.com"]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.comx", ["excalidraw.com"]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.comx", ["excalidraw.com"]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.com.mx", ["excalidraw.com"]),
).toThrow();
// protocol must be https
expect(() =>
validateLibraryUrl("http://excalidraw.com.mx", ["excalidraw.com"]),
).toThrow();
// invalid pathnames
// -------------------------------------------------------------------------
expect(() =>
validateLibraryUrl("https://excalidraw.com/specific/other/path", [
"excalidraw.com/specific/path",
]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.com/specific/paths", [
"excalidraw.com/specific/path",
]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.com/specific/path-s", [
"excalidraw.com/specific/path",
]),
).toThrow();
expect(() =>
validateLibraryUrl("https://excalidraw.com/some/specific/path", [
"excalidraw.com/specific/path",
]),
).toThrow();
});
});
+38 -17
View File
@@ -8,8 +8,7 @@ import type {
} from "../types"; } from "../types";
import { restoreLibraryItems } from "./restore"; import { restoreLibraryItems } from "./restore";
import type App from "../components/App"; import type App from "../components/App";
import { atom } from "jotai"; import { atom, editorJotaiStore } from "../editor-jotai";
import { jotaiStore } from "../jotai";
import type { ExcalidrawElement } from "../element/types"; import type { ExcalidrawElement } from "../element/types";
import { getCommonBoundingBox } from "../element/bounds"; import { getCommonBoundingBox } from "../element/bounds";
import { AbortError } from "../errors"; import { AbortError } from "../errors";
@@ -37,7 +36,18 @@ import { Queue } from "../queue";
import { hashElementsVersion, hashString } from "../element"; import { hashElementsVersion, hashString } from "../element";
import { toValidURL } from "./url"; import { toValidURL } from "./url";
const ALLOWED_LIBRARY_HOSTNAMES = ["excalidraw.com"]; /**
* format: hostname or hostname/pathname
*
* Both hostname and pathname are matched partially,
* hostname from the end, pathname from the start, with subdomain/path
* boundaries
**/
const ALLOWED_LIBRARY_URLS = [
"excalidraw.com",
// when installing from github PRs
"raw.githubusercontent.com/excalidraw/excalidraw-libraries",
];
type LibraryUpdate = { type LibraryUpdate = {
/** deleted library items since last onLibraryChange event */ /** deleted library items since last onLibraryChange event */
@@ -191,13 +201,13 @@ class Library {
private notifyListeners = () => { private notifyListeners = () => {
if (this.updateQueue.length > 0) { if (this.updateQueue.length > 0) {
jotaiStore.set(libraryItemsAtom, (s) => ({ editorJotaiStore.set(libraryItemsAtom, (s) => ({
status: "loading", status: "loading",
libraryItems: this.currLibraryItems, libraryItems: this.currLibraryItems,
isInitialized: s.isInitialized, isInitialized: s.isInitialized,
})); }));
} else { } else {
jotaiStore.set(libraryItemsAtom, { editorJotaiStore.set(libraryItemsAtom, {
status: "loaded", status: "loaded",
libraryItems: this.currLibraryItems, libraryItems: this.currLibraryItems,
isInitialized: true, isInitialized: true,
@@ -225,7 +235,7 @@ class Library {
destroy = () => { destroy = () => {
this.updateQueue = []; this.updateQueue = [];
this.currLibraryItems = []; this.currLibraryItems = [];
jotaiStore.set(libraryItemSvgsCache, new Map()); editorJotaiStore.set(libraryItemSvgsCache, new Map());
// TODO uncomment after/if we make jotai store scoped to each excal instance // TODO uncomment after/if we make jotai store scoped to each excal instance
// jotaiStore.set(libraryItemsAtom, { // jotaiStore.set(libraryItemsAtom, {
// status: "loading", // status: "loading",
@@ -470,26 +480,37 @@ export const distributeLibraryItemsOnSquareGrid = (
return resElements; return resElements;
}; };
const validateLibraryUrl = ( export const validateLibraryUrl = (
libraryUrl: string, libraryUrl: string,
/** /**
* If supplied, takes precedence over the default whitelist. * @returns `true` if the URL is valid, throws otherwise.
* Return `true` if the URL is valid.
*/ */
validator?: (libraryUrl: string) => boolean, validator:
): boolean => { | ((libraryUrl: string) => boolean)
| string[] = ALLOWED_LIBRARY_URLS,
): true => {
if ( if (
validator typeof validator === "function"
? validator(libraryUrl) ? validator(libraryUrl)
: ALLOWED_LIBRARY_HOSTNAMES.includes( : validator.some((allowedUrlDef) => {
new URL(libraryUrl).hostname.split(".").slice(-2).join("."), const allowedUrl = new URL(
) `https://${allowedUrlDef.replace(/^https?:\/\//, "")}`,
);
const { hostname, pathname } = new URL(libraryUrl);
return (
new RegExp(`(^|\\.)${allowedUrl.hostname}$`).test(hostname) &&
new RegExp(
`^${allowedUrl.pathname.replace(/\/+$/, "")}(/+|$)`,
).test(pathname)
);
})
) { ) {
return true; return true;
} }
console.error(`Invalid or disallowed library URL: "${libraryUrl}"`); throw new Error(`Invalid or disallowed library URL: "${libraryUrl}"`);
throw new Error("Invalid or disallowed library URL");
}; };
export const parseLibraryTokensFromUrl = () => { export const parseLibraryTokensFromUrl = () => {
+41 -12
View File
@@ -1,5 +1,6 @@
import type { import type {
ExcalidrawArrowElement, ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawElementType, ExcalidrawElementType,
ExcalidrawLinearElement, ExcalidrawLinearElement,
@@ -69,6 +70,7 @@ export const AllowedExcalidrawActiveTools: Record<
boolean boolean
> = { > = {
selection: true, selection: true,
lassoSelection: true,
text: true, text: true,
rectangle: true, rectangle: true,
diamond: true, diamond: true,
@@ -101,23 +103,38 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
return DEFAULT_FONT_FAMILY; return DEFAULT_FONT_FAMILY;
}; };
const repairBinding = ( const repairBinding = <T extends ExcalidrawLinearElement>(
element: ExcalidrawLinearElement, element: T,
binding: PointBinding | FixedPointBinding | null, binding: PointBinding | FixedPointBinding | null,
): PointBinding | FixedPointBinding | null => { ): T extends ExcalidrawElbowArrowElement
? FixedPointBinding | null
: PointBinding | FixedPointBinding | null => {
if (!binding) { if (!binding) {
return null; return null;
} }
return { const focus = binding.focus || 0;
...binding,
focus: binding.focus || 0, if (isElbowArrow(element)) {
...(isElbowArrow(element) && isFixedPointBinding(binding) const fixedPointBinding:
| ExcalidrawElbowArrowElement["startBinding"]
| ExcalidrawElbowArrowElement["endBinding"] = isFixedPointBinding(binding)
? { ? {
...binding,
focus,
fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]), fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]),
} }
: {}), : null;
};
return fixedPointBinding;
}
return {
...binding,
focus,
} as T extends ExcalidrawElbowArrowElement
? FixedPointBinding | null
: PointBinding | FixedPointBinding | null;
}; };
const restoreElementWithProperties = < const restoreElementWithProperties = <
@@ -308,8 +325,7 @@ const restoreElement = (
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element)); ({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
} }
// TODO: Separate arrow from linear element const base = {
return restoreElementWithProperties(element as ExcalidrawArrowElement, {
type: element.type, type: element.type,
startBinding: repairBinding(element, element.startBinding), startBinding: repairBinding(element, element.startBinding),
endBinding: repairBinding(element, element.endBinding), endBinding: repairBinding(element, element.endBinding),
@@ -321,7 +337,20 @@ const restoreElement = (
y, y,
elbowed: (element as ExcalidrawArrowElement).elbowed, elbowed: (element as ExcalidrawArrowElement).elbowed,
...getSizeFromPoints(points), ...getSizeFromPoints(points),
}); } as const;
// TODO: Separate arrow from linear element
return isElbowArrow(element)
? restoreElementWithProperties(element as ExcalidrawElbowArrowElement, {
...base,
elbowed: true,
startBinding: repairBinding(element, element.startBinding),
endBinding: repairBinding(element, element.endBinding),
fixedSegments: element.fixedSegments,
startIsSpecial: element.startIsSpecial,
endIsSpecial: element.endIsSpecial,
})
: restoreElementWithProperties(element as ExcalidrawArrowElement, base);
} }
// generic elements // generic elements
+13
View File
@@ -0,0 +1,13 @@
// eslint-disable-next-line no-restricted-imports
import { atom, createStore, type PrimitiveAtom } from "jotai";
import { createIsolation } from "jotai-scope";
const jotai = createIsolation();
export { atom, PrimitiveAtom };
export const { useAtom, useSetAtom, useAtomValue, useStore } = jotai;
export const EditorJotaiProvider: ReturnType<
typeof createIsolation
>["Provider"] = jotai.Provider;
export const editorJotaiStore: ReturnType<typeof createStore> = createStore();
+12 -31
View File
@@ -504,12 +504,6 @@ export const bindLinearElement = (
}), }),
}); });
} }
// update bound elements to make sure the binding tips are in sync with
// the normalized gap from above
if (!isElbowArrow(linearElement)) {
updateBoundElements(hoveredElement, elementsMap);
}
}; };
// Don't bind both ends of a simple segment // Don't bind both ends of a simple segment
@@ -629,11 +623,9 @@ export const updateBoundElements = (
simultaneouslyUpdated?: readonly ExcalidrawElement[]; simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number }; newSize?: { width: number; height: number };
changedElements?: Map<string, OrderedExcalidrawElement>; changedElements?: Map<string, OrderedExcalidrawElement>;
zoom?: AppState["zoom"];
}, },
) => { ) => {
const { newSize, simultaneouslyUpdated, changedElements, zoom } = const { newSize, simultaneouslyUpdated } = options ?? {};
options ?? {};
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds( const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
simultaneouslyUpdated, simultaneouslyUpdated,
); );
@@ -667,7 +659,7 @@ export const updateBoundElements = (
// `linearElement` is being moved/scaled already, just update the binding // `linearElement` is being moved/scaled already, just update the binding
if (simultaneouslyUpdatedElementIds.has(element.id)) { if (simultaneouslyUpdatedElementIds.has(element.id)) {
mutateElement(element, bindings); mutateElement(element, bindings, true);
return; return;
} }
@@ -709,23 +701,14 @@ export const updateBoundElements = (
}> => update !== null, }> => update !== null,
); );
LinearElementEditor.movePoints( LinearElementEditor.movePoints(element, updates, {
element, ...(changedElement.id === element.startBinding?.elementId
updates, ? { startBinding: bindings.startBinding }
elementsMap, : {}),
{ ...(changedElement.id === element.endBinding?.elementId
...(changedElement.id === element.startBinding?.elementId ? { endBinding: bindings.endBinding }
? { startBinding: bindings.startBinding } : {}),
: {}), });
...(changedElement.id === element.endBinding?.elementId
? { endBinding: bindings.endBinding }
: {}),
},
{
changedElements,
zoom,
},
);
const boundText = getBoundTextElement(element, elementsMap); const boundText = getBoundTextElement(element, elementsMap);
if (boundText && !boundText.isDeleted) { if (boundText && !boundText.isDeleted) {
@@ -784,9 +767,7 @@ export const getHeadingForElbowArrowSnap = (
); );
} }
const pointHeading = headingForPointFromElement(bindableElement, aabb, p); return headingForPointFromElement(bindableElement, aabb, p);
return pointHeading;
}; };
const getDistanceForBinding = ( const getDistanceForBinding = (
@@ -2289,7 +2270,7 @@ export const getGlobalFixedPointForBindableElement = (
); );
}; };
const getGlobalFixedPoints = ( export const getGlobalFixedPoints = (
arrow: ExcalidrawElbowArrowElement, arrow: ExcalidrawElbowArrowElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
): [GlobalPoint, GlobalPoint] => { ): [GlobalPoint, GlobalPoint] => {
+10
View File
@@ -15,6 +15,7 @@ import { generateRoughOptions } from "../scene/Shape";
import { import {
isArrowElement, isArrowElement,
isBoundToContainer, isBoundToContainer,
isFrameLikeElement,
isFreeDrawElement, isFreeDrawElement,
isLinearElement, isLinearElement,
isTextElement, isTextElement,
@@ -324,6 +325,15 @@ export const getElementLineSegments = (
]; ];
} }
if (isFrameLikeElement(element)) {
return [
lineSegment(nw, ne),
lineSegment(ne, se),
lineSegment(se, sw),
lineSegment(sw, nw),
];
}
return [ return [
lineSegment(nw, ne), lineSegment(nw, ne),
lineSegment(sw, se), lineSegment(sw, se),
+18 -9
View File
@@ -42,9 +42,20 @@ export const dragSelectedElements = (
return; return;
} }
const selectedElements = _selectedElements.filter( const selectedElements = _selectedElements.filter((element) => {
(el) => !(isElbowArrow(el) && el.startBinding && el.endBinding), if (isElbowArrow(element) && element.startBinding && element.endBinding) {
); const startElement = _selectedElements.find(
(el) => el.id === element.startBinding?.elementId,
);
const endElement = _selectedElements.find(
(el) => el.id === element.endBinding?.elementId,
);
return startElement && endElement;
}
return true;
});
// we do not want a frame and its elements to be selected at the same time // 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 // but when it happens (due to some bug), we want to avoid updating element
@@ -78,10 +89,8 @@ export const dragSelectedElements = (
elementsToUpdate.forEach((element) => { elementsToUpdate.forEach((element) => {
updateElementCoords(pointerDownState, element, adjustedOffset); updateElementCoords(pointerDownState, element, adjustedOffset);
if ( if (!isArrowElement(element)) {
// skip arrow labels since we calculate its position during render // skip arrow labels since we calculate its position during render
!isArrowElement(element)
) {
const textElement = getBoundTextElement( const textElement = getBoundTextElement(
element, element,
scene.getNonDeletedElementsMap(), scene.getNonDeletedElementsMap(),
@@ -89,10 +98,10 @@ export const dragSelectedElements = (
if (textElement) { if (textElement) {
updateElementCoords(pointerDownState, textElement, adjustedOffset); updateElementCoords(pointerDownState, textElement, adjustedOffset);
} }
updateBoundElements(element, scene.getElementsMapIncludingDeleted(), {
simultaneouslyUpdated: Array.from(elementsToUpdate),
});
} }
updateBoundElements(element, scene.getElementsMapIncludingDeleted(), {
simultaneouslyUpdated: Array.from(elementsToUpdate),
});
}); });
}; };
@@ -9,20 +9,121 @@ import {
render, render,
} from "../tests/test-utils"; } from "../tests/test-utils";
import { bindLinearElement } from "./binding"; import { bindLinearElement } from "./binding";
import { Excalidraw } from "../index"; import { Excalidraw, mutateElement } from "../index";
import { mutateElbowArrow } from "./routing";
import type { import type {
ExcalidrawArrowElement, ExcalidrawArrowElement,
ExcalidrawBindableElement, ExcalidrawBindableElement,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
} from "./types"; } from "./types";
import { ARROW_TYPE } from "../constants"; import { ARROW_TYPE } from "../constants";
import type { LocalPoint } from "../../math";
import { pointFrom } from "../../math"; import { pointFrom } from "../../math";
const { h } = window; const { h } = window;
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
describe("elbow arrow segment move", () => {
beforeEach(async () => {
localStorage.clear();
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("can move the second segment of a fully connected elbow arrow", () => {
UI.createElement("rectangle", {
x: -100,
y: -50,
width: 100,
height: 100,
});
UI.createElement("rectangle", {
x: 200,
y: 150,
width: 100,
height: 100,
});
UI.clickTool("arrow");
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.moveTo(0, 0);
mouse.click();
mouse.moveTo(200, 200);
mouse.click();
mouse.reset();
mouse.moveTo(100, 100);
mouse.down();
mouse.moveTo(115, 100);
mouse.up();
const arrow = h.scene.getSelectedElements(
h.state,
)[0] as ExcalidrawElbowArrowElement;
expect(h.state.selectedElementIds).toEqual({ [arrow.id]: true });
expect(arrow.fixedSegments?.length).toBe(1);
expect(arrow.points).toCloselyEqualPoints([
[0, 0],
[110, 0],
[110, 200],
[190, 200],
]);
mouse.reset();
mouse.moveTo(105, 74.275);
mouse.doubleClick();
expect(arrow.points).toCloselyEqualPoints([
[0, 0],
[110, 0],
[110, 200],
[190, 200],
]);
});
it("can move the second segment of an unconnected elbow arrow", () => {
UI.clickTool("arrow");
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.moveTo(0, 0);
mouse.click();
mouse.moveTo(250, 200);
mouse.click();
mouse.reset();
mouse.moveTo(125, 100);
mouse.down();
mouse.moveTo(130, 100);
mouse.up();
const arrow = h.scene.getSelectedElements(
h.state,
)[0] as ExcalidrawArrowElement;
expect(arrow.points).toCloselyEqualPoints([
[0, 0],
[130, 0],
[130, 200],
[250, 200],
]);
mouse.reset();
mouse.moveTo(130, 100);
mouse.doubleClick();
expect(arrow.points).toCloselyEqualPoints([
[0, 0],
[125, 0],
[125, 200],
[250, 200],
]);
});
});
describe("elbow arrow routing", () => { describe("elbow arrow routing", () => {
it("can properly generate orthogonal arrow points", () => { it("can properly generate orthogonal arrow points", () => {
const scene = new Scene(); const scene = new Scene();
@@ -31,10 +132,12 @@ describe("elbow arrow routing", () => {
elbowed: true, elbowed: true,
}) as ExcalidrawElbowArrowElement; }) as ExcalidrawElbowArrowElement;
scene.insertElement(arrow); scene.insertElement(arrow);
mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [ mutateElement(arrow, {
pointFrom(-45 - arrow.x, -100.1 - arrow.y), points: [
pointFrom(45 - arrow.x, 99.9 - arrow.y), pointFrom<LocalPoint>(-45 - arrow.x, -100.1 - arrow.y),
]); pointFrom<LocalPoint>(45 - arrow.x, 99.9 - arrow.y),
],
});
expect(arrow.points).toEqual([ expect(arrow.points).toEqual([
[0, 0], [0, 0],
[0, 100], [0, 100],
@@ -81,7 +184,9 @@ describe("elbow arrow routing", () => {
expect(arrow.startBinding).not.toBe(null); expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null); expect(arrow.endBinding).not.toBe(null);
mutateElbowArrow(arrow, elementsMap, [pointFrom(0, 0), pointFrom(90, 200)]); mutateElement(arrow, {
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
});
expect(arrow.points).toEqual([ expect(arrow.points).toEqual([
[0, 0], [0, 0],
@@ -182,8 +287,6 @@ describe("elbow arrow ui", () => {
expect(arrow.points.map((point) => point.map(Math.round))).toEqual([ expect(arrow.points.map((point) => point.map(Math.round))).toEqual([
[0, 0], [0, 0],
[35, 0], [35, 0],
[35, 90],
[35, 90], // Note that coordinates are rounded above!
[35, 165], [35, 165],
[103, 165], [103, 165],
]); ]);
File diff suppressed because it is too large Load Diff
+16 -16
View File
@@ -254,6 +254,9 @@ const addNewNode = (
backgroundColor: element.backgroundColor, backgroundColor: element.backgroundColor,
strokeColor: element.strokeColor, strokeColor: element.strokeColor,
strokeWidth: element.strokeWidth, strokeWidth: element.strokeWidth,
opacity: element.opacity,
fillStyle: element.fillStyle,
strokeStyle: element.strokeStyle,
}); });
invariant( invariant(
@@ -329,6 +332,9 @@ export const addNewNodes = (
backgroundColor: startNode.backgroundColor, backgroundColor: startNode.backgroundColor,
strokeColor: startNode.strokeColor, strokeColor: startNode.strokeColor,
strokeWidth: startNode.strokeWidth, strokeWidth: startNode.strokeWidth,
opacity: startNode.opacity,
fillStyle: startNode.fillStyle,
strokeStyle: startNode.strokeStyle,
}); });
invariant( invariant(
@@ -416,11 +422,13 @@ const createBindingArrow = (
type: "arrow", type: "arrow",
x: startX, x: startX,
y: startY, y: startY,
startArrowhead: appState.currentItemStartArrowhead, startArrowhead: null,
endArrowhead: appState.currentItemEndArrowhead, endArrowhead: appState.currentItemEndArrowhead,
strokeColor: appState.currentItemStrokeColor, strokeColor: startBindingElement.strokeColor,
strokeStyle: appState.currentItemStrokeStyle, strokeStyle: startBindingElement.strokeStyle,
strokeWidth: appState.currentItemStrokeWidth, strokeWidth: startBindingElement.strokeWidth,
opacity: startBindingElement.opacity,
roughness: startBindingElement.roughness,
points: [pointFrom(0, 0), pointFrom(endX, endY)], points: [pointFrom(0, 0), pointFrom(endX, endY)],
elbowed: true, elbowed: true,
}); });
@@ -452,20 +460,12 @@ const createBindingArrow = (
bindingArrow as OrderedExcalidrawElement, bindingArrow as OrderedExcalidrawElement,
); );
LinearElementEditor.movePoints( LinearElementEditor.movePoints(bindingArrow, [
bindingArrow,
[
{
index: 1,
point: bindingArrow.points[1],
},
],
elementsMap as NonDeletedSceneElementsMap,
undefined,
{ {
changedElements, index: 1,
point: bindingArrow.points[1],
}, },
); ]);
return bindingArrow; return bindingArrow;
}; };
+31 -7
View File
@@ -11,6 +11,7 @@ import {
pointScaleFromOrigin, pointScaleFromOrigin,
radiansToDegrees, radiansToDegrees,
triangleIncludesPoint, triangleIncludesPoint,
vectorFromPoint,
} from "../../math"; } from "../../math";
import { getCenterForBounds, type Bounds } from "./bounds"; import { getCenterForBounds, type Bounds } from "./bounds";
import type { ExcalidrawBindableElement } from "./types"; import type { ExcalidrawBindableElement } from "./types";
@@ -52,9 +53,24 @@ export const vectorToHeading = (vec: Vector): Heading => {
return HEADING_UP; return HEADING_UP;
}; };
export const headingForPoint = <P extends GlobalPoint | LocalPoint>(
p: P,
o: P,
) => vectorToHeading(vectorFromPoint<P>(p, o));
export const headingForPointIsHorizontal = <P extends GlobalPoint | LocalPoint>(
p: P,
o: P,
) => headingIsHorizontal(headingForPoint<P>(p, o));
export const compareHeading = (a: Heading, b: Heading) => export const compareHeading = (a: Heading, b: Heading) =>
a[0] === b[0] && a[1] === b[1]; a[0] === b[0] && a[1] === b[1];
export const headingIsHorizontal = (a: Heading) =>
compareHeading(a, HEADING_RIGHT) || compareHeading(a, HEADING_LEFT);
export const headingIsVertical = (a: Heading) => !headingIsHorizontal(a);
// Gets the heading for the point by creating a bounding box around the rotated // 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 // close fitting bounding box, then creating 4 search cones around the center of
// the external bbox. // the external bbox.
@@ -63,7 +79,7 @@ export const headingForPointFromElement = <
>( >(
element: Readonly<ExcalidrawBindableElement>, element: Readonly<ExcalidrawBindableElement>,
aabb: Readonly<Bounds>, aabb: Readonly<Bounds>,
p: Readonly<LocalPoint | GlobalPoint>, p: Readonly<Point>,
): Heading => { ): Heading => {
const SEARCH_CONE_MULTIPLIER = 2; const SEARCH_CONE_MULTIPLIER = 2;
@@ -117,14 +133,22 @@ export const headingForPointFromElement = <
element.angle, element.angle,
); );
if (triangleIncludesPoint([top, right, midPoint] as Triangle<Point>, p)) { if (
triangleIncludesPoint<Point>([top, right, midPoint] as Triangle<Point>, p)
) {
return headingForDiamond(top, right); return headingForDiamond(top, right);
} else if ( } else if (
triangleIncludesPoint([right, bottom, midPoint] as Triangle<Point>, p) triangleIncludesPoint<Point>(
[right, bottom, midPoint] as Triangle<Point>,
p,
)
) { ) {
return headingForDiamond(right, bottom); return headingForDiamond(right, bottom);
} else if ( } else if (
triangleIncludesPoint([bottom, left, midPoint] as Triangle<Point>, p) triangleIncludesPoint<Point>(
[bottom, left, midPoint] as Triangle<Point>,
p,
)
) { ) {
return headingForDiamond(bottom, left); return headingForDiamond(bottom, left);
} }
@@ -153,17 +177,17 @@ export const headingForPointFromElement = <
SEARCH_CONE_MULTIPLIER, SEARCH_CONE_MULTIPLIER,
) as Point; ) as Point;
return triangleIncludesPoint( return triangleIncludesPoint<Point>(
[topLeft, topRight, midPoint] as Triangle<Point>, [topLeft, topRight, midPoint] as Triangle<Point>,
p, p,
) )
? HEADING_UP ? HEADING_UP
: triangleIncludesPoint( : triangleIncludesPoint<Point>(
[topRight, bottomRight, midPoint] as Triangle<Point>, [topRight, bottomRight, midPoint] as Triangle<Point>,
p, p,
) )
? HEADING_RIGHT ? HEADING_RIGHT
: triangleIncludesPoint( : triangleIncludesPoint<Point>(
[bottomRight, bottomLeft, midPoint] as Triangle<Point>, [bottomRight, bottomLeft, midPoint] as Triangle<Point>,
p, p,
) )
+183 -131
View File
@@ -7,9 +7,10 @@ import type {
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
ElementsMap, ElementsMap,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
OrderedExcalidrawElement,
FixedPointBinding, FixedPointBinding,
SceneElementsMap, SceneElementsMap,
FixedSegment,
ExcalidrawElbowArrowElement,
} from "./types"; } from "./types";
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from "."; import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
import type { Bounds } from "./bounds"; import type { Bounds } from "./bounds";
@@ -24,6 +25,7 @@ import type {
InteractiveCanvasAppState, InteractiveCanvasAppState,
AppClassProperties, AppClassProperties,
NullableGridSize, NullableGridSize,
Zoom,
} from "../types"; } from "../types";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
@@ -32,7 +34,7 @@ import {
getHoveredElementForBinding, getHoveredElementForBinding,
isBindingEnabled, isBindingEnabled,
} from "./binding"; } from "./binding";
import { invariant, toBrandedType, tupleToCoors } from "../utils"; import { invariant, tupleToCoors } from "../utils";
import { import {
isBindingElement, isBindingElement,
isElbowArrow, isElbowArrow,
@@ -44,7 +46,6 @@ import { DRAGGING_THRESHOLD } from "../constants";
import type { Mutable } from "../utility-types"; import type { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache"; import { ShapeCache } from "../scene/ShapeCache";
import type { Store } from "../store"; import type { Store } from "../store";
import { mutateElbowArrow } from "./routing";
import type Scene from "../scene/Scene"; import type Scene from "../scene/Scene";
import type { Radians } from "../../math"; import type { Radians } from "../../math";
import { import {
@@ -56,6 +57,8 @@ import {
type GlobalPoint, type GlobalPoint,
type LocalPoint, type LocalPoint,
pointDistance, pointDistance,
pointTranslate,
vectorFromPoint,
} from "../../math"; } from "../../math";
import { import {
getBezierCurveLength, getBezierCurveLength,
@@ -65,6 +68,7 @@ import {
mapIntervalToBezierT, mapIntervalToBezierT,
} from "../shapes"; } from "../shapes";
import { getGridPoint } from "../snapping"; import { getGridPoint } from "../snapping";
import { headingIsHorizontal, vectorToHeading } from "./heading";
const editorMidPointsCache: { const editorMidPointsCache: {
version: number | null; version: number | null;
@@ -144,13 +148,13 @@ export class LinearElementEditor {
* @param id the `elementId` from the instance of this class (so that we can * @param id the `elementId` from the instance of this class (so that we can
* statically guarantee this method returns an ExcalidrawLinearElement) * statically guarantee this method returns an ExcalidrawLinearElement)
*/ */
static getElement( static getElement<T extends ExcalidrawLinearElement>(
id: InstanceType<typeof LinearElementEditor>["elementId"], id: InstanceType<typeof LinearElementEditor>["elementId"],
elementsMap: ElementsMap, elementsMap: ElementsMap,
) { ): T | null {
const element = elementsMap.get(id); const element = elementsMap.get(id);
if (element) { if (element) {
return element as NonDeleted<ExcalidrawLinearElement>; return element as NonDeleted<T>;
} }
return null; return null;
} }
@@ -291,20 +295,16 @@ export class LinearElementEditor {
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
); );
LinearElementEditor.movePoints( LinearElementEditor.movePoints(element, [
element, {
[ index: selectedIndex,
{ point: pointFrom(
index: selectedIndex, width + referencePoint[0],
point: pointFrom( height + referencePoint[1],
width + referencePoint[0], ),
height + referencePoint[1], isDragging: selectedIndex === lastClickedPoint,
), },
isDragging: selectedIndex === lastClickedPoint, ]);
},
],
elementsMap,
);
} else { } else {
const newDraggingPointPosition = LinearElementEditor.createPointAt( const newDraggingPointPosition = LinearElementEditor.createPointAt(
element, element,
@@ -339,7 +339,6 @@ export class LinearElementEditor {
isDragging: pointIndex === lastClickedPoint, isDragging: pointIndex === lastClickedPoint,
}; };
}), }),
elementsMap,
); );
} }
@@ -422,19 +421,15 @@ export class LinearElementEditor {
selectedPoint === element.points.length - 1 selectedPoint === element.points.length - 1
) { ) {
if (isPathALoop(element.points, appState.zoom.value)) { if (isPathALoop(element.points, appState.zoom.value)) {
LinearElementEditor.movePoints( LinearElementEditor.movePoints(element, [
element, {
[ index: selectedPoint,
{ point:
index: selectedPoint, selectedPoint === 0
point: ? element.points[element.points.length - 1]
selectedPoint === 0 : element.points[0],
? element.points[element.points.length - 1] },
: element.points[0], ]);
},
],
elementsMap,
);
} }
const bindingElement = isBindingEnabled(appState) const bindingElement = isBindingEnabled(appState)
@@ -495,6 +490,7 @@ export class LinearElementEditor {
// Since its not needed outside editor unless 2 pointer lines or bound text // Since its not needed outside editor unless 2 pointer lines or bound text
if ( if (
!isElbowArrow(element) &&
!appState.editingLinearElement && !appState.editingLinearElement &&
element.points.length > 2 && element.points.length > 2 &&
!boundText !boundText
@@ -533,6 +529,7 @@ export class LinearElementEditor {
element, element,
element.points[index], element.points[index],
element.points[index + 1], element.points[index + 1],
index,
appState.zoom, appState.zoom,
) )
) { ) {
@@ -573,19 +570,23 @@ export class LinearElementEditor {
scenePointer.x, scenePointer.x,
scenePointer.y, scenePointer.y,
); );
if (clickedPointIndex >= 0) { if (!isElbowArrow(element) && clickedPointIndex >= 0) {
return null; return null;
} }
const points = LinearElementEditor.getPointsGlobalCoordinates( const points = LinearElementEditor.getPointsGlobalCoordinates(
element, element,
elementsMap, elementsMap,
); );
if (points.length >= 3 && !appState.editingLinearElement) { if (
points.length >= 3 &&
!appState.editingLinearElement &&
!isElbowArrow(element)
) {
return null; return null;
} }
const threshold = const threshold =
LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value; (LinearElementEditor.POINT_HANDLE_SIZE + 1) / appState.zoom.value;
const existingSegmentMidpointHitCoords = const existingSegmentMidpointHitCoords =
linearElementEditor.segmentMidPointHoveredCoords; linearElementEditor.segmentMidPointHoveredCoords;
@@ -604,10 +605,11 @@ export class LinearElementEditor {
let index = 0; let index = 0;
const midPoints: typeof editorMidPointsCache["points"] = const midPoints: typeof editorMidPointsCache["points"] =
LinearElementEditor.getEditorMidPoints(element, elementsMap, appState); LinearElementEditor.getEditorMidPoints(element, elementsMap, appState);
while (index < midPoints.length) { while (index < midPoints.length) {
if (midPoints[index] !== null) { if (midPoints[index] !== null) {
const distance = pointDistance( const distance = pointDistance(
pointFrom(midPoints[index]![0], midPoints[index]![1]), midPoints[index]!,
pointFrom(scenePointer.x, scenePointer.y), pointFrom(scenePointer.x, scenePointer.y),
); );
if (distance <= threshold) { if (distance <= threshold) {
@@ -620,16 +622,25 @@ export class LinearElementEditor {
return null; return null;
}; };
static isSegmentTooShort( static isSegmentTooShort<P extends GlobalPoint | LocalPoint>(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
startPoint: GlobalPoint | LocalPoint, startPoint: P,
endPoint: GlobalPoint | LocalPoint, endPoint: P,
zoom: AppState["zoom"], index: number,
zoom: Zoom,
) { ) {
let distance = pointDistance( if (isElbowArrow(element)) {
pointFrom(startPoint[0], startPoint[1]), if (index >= 0 && index < element.points.length) {
pointFrom(endPoint[0], endPoint[1]), return (
); pointDistance(startPoint, endPoint) * zoom.value <
LinearElementEditor.POINT_HANDLE_SIZE / 2
);
}
return false;
}
let distance = pointDistance(startPoint, endPoint);
if (element.points.length > 2 && element.roundness) { if (element.points.length > 2 && element.roundness) {
distance = getBezierCurveLength(element, endPoint); distance = getBezierCurveLength(element, endPoint);
} }
@@ -748,12 +759,8 @@ export class LinearElementEditor {
segmentMidpoint, segmentMidpoint,
elementsMap, elementsMap,
); );
} } else if (event.altKey && appState.editingLinearElement) {
if (event.altKey && appState.editingLinearElement) { if (linearElementEditor.lastUncommittedPoint == null) {
if (
linearElementEditor.lastUncommittedPoint == null &&
!isElbowArrow(element)
) {
mutateElement(element, { mutateElement(element, {
points: [ points: [
...element.points, ...element.points,
@@ -909,12 +916,7 @@ export class LinearElementEditor {
if (!event.altKey) { if (!event.altKey) {
if (lastPoint === lastUncommittedPoint) { if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.deletePoints( LinearElementEditor.deletePoints(element, [points.length - 1]);
element,
[points.length - 1],
elementsMap,
app.state.zoom,
);
} }
return { return {
...appState.editingLinearElement, ...appState.editingLinearElement,
@@ -952,23 +954,14 @@ export class LinearElementEditor {
} }
if (lastPoint === lastUncommittedPoint) { if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.movePoints( LinearElementEditor.movePoints(element, [
element, {
[ index: element.points.length - 1,
{ point: newPoint,
index: element.points.length - 1, },
point: newPoint, ]);
},
],
elementsMap,
);
} else { } else {
LinearElementEditor.addPoints( LinearElementEditor.addPoints(element, [{ point: newPoint }]);
element,
[{ point: newPoint }],
elementsMap,
app.state.zoom,
);
} }
return { return {
...appState.editingLinearElement, ...appState.editingLinearElement,
@@ -1197,16 +1190,12 @@ export class LinearElementEditor {
// potentially expanding the bounding box // potentially expanding the bounding box
if (pointAddedToEnd) { if (pointAddedToEnd) {
const lastPoint = element.points[element.points.length - 1]; const lastPoint = element.points[element.points.length - 1];
LinearElementEditor.movePoints( LinearElementEditor.movePoints(element, [
element, {
[ index: element.points.length - 1,
{ point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
index: element.points.length - 1, },
point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30), ]);
},
],
elementsMap,
);
} }
return { return {
@@ -1221,8 +1210,6 @@ export class LinearElementEditor {
static deletePoints( static deletePoints(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
pointIndices: readonly number[], pointIndices: readonly number[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
zoom: AppState["zoom"],
) { ) {
let offsetX = 0; let offsetX = 0;
let offsetY = 0; let offsetY = 0;
@@ -1252,47 +1239,27 @@ export class LinearElementEditor {
return acc; return acc;
}, []); }, []);
LinearElementEditor._updatePoints( LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
element,
nextPoints,
offsetX,
offsetY,
elementsMap,
);
} }
static addPoints( static addPoints(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
targetPoints: { point: LocalPoint }[], targetPoints: { point: LocalPoint }[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
zoom: AppState["zoom"],
) { ) {
const offsetX = 0; const offsetX = 0;
const offsetY = 0; const offsetY = 0;
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)]; const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
LinearElementEditor._updatePoints( LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
element,
nextPoints,
offsetX,
offsetY,
elementsMap,
);
} }
static movePoints( static movePoints(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[], targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
otherUpdates?: { otherUpdates?: {
startBinding?: PointBinding | null; startBinding?: PointBinding | null;
endBinding?: PointBinding | null; endBinding?: PointBinding | null;
}, },
options?: {
changedElements?: Map<string, OrderedExcalidrawElement>;
isDragging?: boolean;
zoom?: AppState["zoom"];
},
) { ) {
const { points } = element; const { points } = element;
@@ -1335,7 +1302,6 @@ export class LinearElementEditor {
nextPoints, nextPoints,
offsetX, offsetX,
offsetY, offsetY,
elementsMap,
otherUpdates, otherUpdates,
{ {
isDragging: targetPoints.reduce( isDragging: targetPoints.reduce(
@@ -1343,8 +1309,6 @@ export class LinearElementEditor {
dragging || targetPoint.isDragging === true, dragging || targetPoint.isDragging === true,
false, false,
), ),
changedElements: options?.changedElements,
zoom: options?.zoom,
}, },
); );
} }
@@ -1451,54 +1415,49 @@ export class LinearElementEditor {
nextPoints: readonly LocalPoint[], nextPoints: readonly LocalPoint[],
offsetX: number, offsetX: number,
offsetY: number, offsetY: number,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
otherUpdates?: { otherUpdates?: {
startBinding?: PointBinding | null; startBinding?: PointBinding | null;
endBinding?: PointBinding | null; endBinding?: PointBinding | null;
}, },
options?: { options?: {
changedElements?: Map<string, OrderedExcalidrawElement>;
isDragging?: boolean; isDragging?: boolean;
zoom?: AppState["zoom"]; zoom?: AppState["zoom"];
}, },
) { ) {
if (isElbowArrow(element)) { if (isElbowArrow(element)) {
const bindings: { const updates: {
startBinding?: FixedPointBinding | null; startBinding?: FixedPointBinding | null;
endBinding?: FixedPointBinding | null; endBinding?: FixedPointBinding | null;
points?: LocalPoint[];
} = {}; } = {};
if (otherUpdates?.startBinding !== undefined) { if (otherUpdates?.startBinding !== undefined) {
bindings.startBinding = updates.startBinding =
otherUpdates.startBinding !== null && otherUpdates.startBinding !== null &&
isFixedPointBinding(otherUpdates.startBinding) isFixedPointBinding(otherUpdates.startBinding)
? otherUpdates.startBinding ? otherUpdates.startBinding
: null; : null;
} }
if (otherUpdates?.endBinding !== undefined) { if (otherUpdates?.endBinding !== undefined) {
bindings.endBinding = updates.endBinding =
otherUpdates.endBinding !== null && otherUpdates.endBinding !== null &&
isFixedPointBinding(otherUpdates.endBinding) isFixedPointBinding(otherUpdates.endBinding)
? otherUpdates.endBinding ? otherUpdates.endBinding
: null; : null;
} }
const mergedElementsMap = options?.changedElements updates.points = Array.from(nextPoints);
? toBrandedType<SceneElementsMap>( updates.points[0] = pointTranslate(
new Map([...elementsMap, ...options.changedElements]), updates.points[0],
)
: elementsMap;
mutateElbowArrow(
element,
mergedElementsMap,
nextPoints,
vector(offsetX, offsetY), vector(offsetX, offsetY),
bindings,
{
isDragging: options?.isDragging,
zoom: options?.zoom,
},
); );
updates.points[updates.points.length - 1] = pointTranslate(
updates.points[updates.points.length - 1],
vector(offsetX, offsetY),
);
mutateElement(element, updates, true, {
isDragging: options?.isDragging,
});
} else { } else {
const nextCoords = getElementPointsCoords(element, nextPoints); const nextCoords = getElementPointsCoords(element, nextPoints);
const prevCoords = getElementPointsCoords(element, element.points); const prevCoords = getElementPointsCoords(element, element.points);
@@ -1773,6 +1732,99 @@ export class LinearElementEditor {
return coords; return coords;
}; };
static moveFixedSegment(
linearElement: LinearElementEditor,
index: number,
x: number,
y: number,
elementsMap: ElementsMap,
): LinearElementEditor {
const element = LinearElementEditor.getElement(
linearElement.elementId,
elementsMap,
);
if (!element || !isElbowArrow(element)) {
return linearElement;
}
if (index && index > 0 && index < element.points.length) {
const isHorizontal = headingIsHorizontal(
vectorToHeading(
vectorFromPoint(element.points[index], element.points[index - 1]),
),
);
const fixedSegments = (element.fixedSegments ?? []).reduce(
(segments, s) => {
segments[s.index] = s;
return segments;
},
{} as Record<number, FixedSegment>,
);
fixedSegments[index] = {
index,
start: pointFrom<LocalPoint>(
!isHorizontal ? x - element.x : element.points[index - 1][0],
isHorizontal ? y - element.y : element.points[index - 1][1],
),
end: pointFrom<LocalPoint>(
!isHorizontal ? x - element.x : element.points[index][0],
isHorizontal ? y - element.y : element.points[index][1],
),
};
const nextFixedSegments = Object.values(fixedSegments).sort(
(a, b) => a.index - b.index,
);
const offset = nextFixedSegments
.map((segment) => segment.index)
.reduce((count, idx) => (idx < index ? count + 1 : count), 0);
mutateElement(element, {
fixedSegments: nextFixedSegments,
});
const point = pointFrom<GlobalPoint>(
element.x +
(element.fixedSegments![offset].start[0] +
element.fixedSegments![offset].end[0]) /
2,
element.y +
(element.fixedSegments![offset].start[1] +
element.fixedSegments![offset].end[1]) /
2,
);
return {
...linearElement,
segmentMidPointHoveredCoords: point,
pointerDownState: {
...linearElement.pointerDownState,
segmentMidpoint: {
added: false,
index: element.fixedSegments![offset].index,
value: point,
},
},
};
}
return linearElement;
}
static deleteFixedSegment(
element: ExcalidrawElbowArrowElement,
index: number,
): void {
mutateElement(element, {
fixedSegments: element.fixedSegments?.filter(
(segment) => segment.index !== index,
),
});
mutateElement(element, {}, true);
}
} }
const normalizeSelectedPoints = ( const normalizeSelectedPoints = (
+42 -4
View File
@@ -1,10 +1,13 @@
import type { ExcalidrawElement } from "./types"; import type { ExcalidrawElement, SceneElementsMap } from "./types";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { getSizeFromPoints } from "../points"; import { getSizeFromPoints } from "../points";
import { randomInteger } from "../random"; import { randomInteger } from "../random";
import { getUpdatedTimestamp } from "../utils"; import { getUpdatedTimestamp, toBrandedType } from "../utils";
import type { Mutable } from "../utility-types"; import type { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache"; import { ShapeCache } from "../scene/ShapeCache";
import { isElbowArrow } from "./typeChecks";
import { updateElbowArrowPoints } from "./elbowArrow";
import type { Radians } from "../../math";
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit< export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>, Partial<TElement>,
@@ -19,14 +22,49 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element: TElement, element: TElement,
updates: ElementUpdate<TElement>, updates: ElementUpdate<TElement>,
informMutation = true, informMutation = true,
options?: {
// Currently only for elbow arrows.
// If true, the elbow arrow tries to bind to the nearest element. If false
// it tries to keep the same bound element, if any.
isDragging?: boolean;
},
): TElement => { ): TElement => {
let didChange = false; let didChange = false;
// casting to any because can't use `in` operator // casting to any because can't use `in` operator
// (see https://github.com/microsoft/TypeScript/issues/21732) // (see https://github.com/microsoft/TypeScript/issues/21732)
const { points, fileId } = updates as any; const { points, fixedSegments, fileId } = updates as any;
if (typeof points !== "undefined") { if (
isElbowArrow(element) &&
(Object.keys(updates).length === 0 || // normalization case
typeof points !== "undefined" || // repositioning
typeof fixedSegments !== "undefined") // segment fixing
) {
const elementsMap = toBrandedType<SceneElementsMap>(
Scene.getScene(element)?.getNonDeletedElementsMap() ?? new Map(),
);
updates = {
...updates,
angle: 0 as Radians,
...updateElbowArrowPoints(
{
...element,
x: updates.x || element.x,
y: updates.y || element.y,
},
elementsMap,
{
fixedSegments,
points,
},
{
isDragging: options?.isDragging,
},
),
};
} else if (typeof points !== "undefined") {
updates = { ...getSizeFromPoints(points), ...updates }; updates = { ...getSizeFromPoints(points), ...updates };
} }
+28 -5
View File
@@ -18,6 +18,8 @@ import type {
ExcalidrawIframeElement, ExcalidrawIframeElement,
ElementsMap, ElementsMap,
ExcalidrawArrowElement, ExcalidrawArrowElement,
FixedSegment,
ExcalidrawElbowArrowElement,
} from "./types"; } from "./types";
import { import {
arrayToMap, arrayToMap,
@@ -450,15 +452,34 @@ export const newLinearElement = (
}; };
}; };
export const newArrowElement = ( export const newArrowElement = <T extends boolean>(
opts: { opts: {
type: ExcalidrawArrowElement["type"]; type: ExcalidrawArrowElement["type"];
startArrowhead?: Arrowhead | null; startArrowhead?: Arrowhead | null;
endArrowhead?: Arrowhead | null; endArrowhead?: Arrowhead | null;
points?: ExcalidrawArrowElement["points"]; points?: ExcalidrawArrowElement["points"];
elbowed?: boolean; elbowed?: T;
fixedSegments?: FixedSegment[] | null;
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawArrowElement> => { ): T extends true
? NonDeleted<ExcalidrawElbowArrowElement>
: NonDeleted<ExcalidrawArrowElement> => {
if (opts.elbowed) {
return {
..._newElementBase<ExcalidrawElbowArrowElement>(opts.type, opts),
points: opts.points || [],
lastCommittedPoint: null,
startBinding: null,
endBinding: null,
startArrowhead: opts.startArrowhead || null,
endArrowhead: opts.endArrowhead || null,
elbowed: true,
fixedSegments: opts.fixedSegments || [],
startIsSpecial: false,
endIsSpecial: false,
} as NonDeleted<ExcalidrawElbowArrowElement>;
}
return { return {
..._newElementBase<ExcalidrawArrowElement>(opts.type, opts), ..._newElementBase<ExcalidrawArrowElement>(opts.type, opts),
points: opts.points || [], points: opts.points || [],
@@ -467,8 +488,10 @@ export const newArrowElement = (
endBinding: null, endBinding: null,
startArrowhead: opts.startArrowhead || null, startArrowhead: opts.startArrowhead || null,
endArrowhead: opts.endArrowhead || null, endArrowhead: opts.endArrowhead || null,
elbowed: opts.elbowed || false, elbowed: false,
}; } as T extends true
? NonDeleted<ExcalidrawElbowArrowElement>
: NonDeleted<ExcalidrawArrowElement>;
}; };
export const newImageElement = ( export const newImageElement = (
+52 -7
View File
@@ -10,6 +10,7 @@ import type {
ExcalidrawImageElement, ExcalidrawImageElement,
ElementsMap, ElementsMap,
SceneElementsMap, SceneElementsMap,
ExcalidrawElbowArrowElement,
} from "./types"; } from "./types";
import type { Mutable } from "../utility-types"; import type { Mutable } from "../utility-types";
import { import {
@@ -53,7 +54,6 @@ import {
import { wrapText } from "./textWrapping"; import { wrapText } from "./textWrapping";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { isInGroup } from "../groups"; import { isInGroup } from "../groups";
import { mutateElbowArrow } from "./routing";
import type { GlobalPoint } from "../../math"; import type { GlobalPoint } from "../../math";
import { import {
pointCenter, pointCenter,
@@ -177,10 +177,10 @@ export const transformElements = (
elementsMap, elementsMap,
transformHandleType, transformHandleType,
scene, scene,
originalElements,
{ {
shouldResizeFromCenter, shouldResizeFromCenter,
shouldMaintainAspectRatio, shouldMaintainAspectRatio,
originalElementsMap: originalElements,
flipByX, flipByX,
flipByY, flipByY,
nextWidth, nextWidth,
@@ -531,8 +531,10 @@ const rotateMultipleElements = (
); );
if (isElbowArrow(element)) { if (isElbowArrow(element)) {
const points = getArrowLocalFixedPoints(element, elementsMap); // Needed to re-route the arrow
mutateElbowArrow(element, elementsMap, points); mutateElement(element, {
points: getArrowLocalFixedPoints(element, elementsMap),
});
} else { } else {
mutateElement( mutateElement(
element, element,
@@ -1201,6 +1203,7 @@ export const resizeMultipleElements = (
elementsMap: ElementsMap, elementsMap: ElementsMap,
handleDirection: TransformHandleDirection, handleDirection: TransformHandleDirection,
scene: Scene, scene: Scene,
originalElementsMap: ElementsMap,
{ {
shouldMaintainAspectRatio = false, shouldMaintainAspectRatio = false,
shouldResizeFromCenter = false, shouldResizeFromCenter = false,
@@ -1208,7 +1211,6 @@ export const resizeMultipleElements = (
flipByY = false, flipByY = false,
nextHeight, nextHeight,
nextWidth, nextWidth,
originalElementsMap,
originalBoundingBox, originalBoundingBox,
}: { }: {
nextWidth?: number; nextWidth?: number;
@@ -1217,7 +1219,6 @@ export const resizeMultipleElements = (
shouldResizeFromCenter?: boolean; shouldResizeFromCenter?: boolean;
flipByX?: boolean; flipByX?: boolean;
flipByY?: boolean; flipByY?: boolean;
originalElementsMap?: ElementsMap;
// added to improve performance // added to improve performance
originalBoundingBox?: BoundingBox; originalBoundingBox?: BoundingBox;
} = {}, } = {},
@@ -1387,6 +1388,9 @@ export const resizeMultipleElements = (
fontSize?: ExcalidrawTextElement["fontSize"]; fontSize?: ExcalidrawTextElement["fontSize"];
scale?: ExcalidrawImageElement["scale"]; scale?: ExcalidrawImageElement["scale"];
boundTextFontSize?: ExcalidrawTextElement["fontSize"]; boundTextFontSize?: ExcalidrawTextElement["fontSize"];
startBinding?: ExcalidrawElbowArrowElement["startBinding"];
endBinding?: ExcalidrawElbowArrowElement["endBinding"];
fixedSegments?: ExcalidrawElbowArrowElement["fixedSegments"];
}; };
}[] = []; }[] = [];
@@ -1427,6 +1431,44 @@ export const resizeMultipleElements = (
...rescaledPoints, ...rescaledPoints,
}; };
if (isElbowArrow(orig)) {
// Mirror fixed point binding for elbow arrows
// when resize goes into the negative direction
if (orig.startBinding) {
update.startBinding = {
...orig.startBinding,
fixedPoint: [
flipByX
? -orig.startBinding.fixedPoint[0] + 1
: orig.startBinding.fixedPoint[0],
flipByY
? -orig.startBinding.fixedPoint[1] + 1
: orig.startBinding.fixedPoint[1],
],
};
}
if (orig.endBinding) {
update.endBinding = {
...orig.endBinding,
fixedPoint: [
flipByX
? -orig.endBinding.fixedPoint[0] + 1
: orig.endBinding.fixedPoint[0],
flipByY
? -orig.endBinding.fixedPoint[1] + 1
: orig.endBinding.fixedPoint[1],
],
};
}
if (orig.fixedSegments && rescaledPoints.points) {
update.fixedSegments = orig.fixedSegments.map((segment) => ({
...segment,
start: rescaledPoints.points[segment.index - 1],
end: rescaledPoints.points[segment.index],
}));
}
}
if (isImageElement(orig)) { if (isImageElement(orig)) {
update.scale = [ update.scale = [
orig.scale[0] * flipFactorX, orig.scale[0] * flipFactorX,
@@ -1472,7 +1514,10 @@ export const resizeMultipleElements = (
} of elementsAndUpdates) { } of elementsAndUpdates) {
const { width, height, angle } = update; const { width, height, angle } = update;
mutateElement(element, update, false); mutateElement(element, update, false, {
// needed for the fixed binding point udpate to take effect
isDragging: true,
});
updateBoundElements(element, elementsMap as SceneElementsMap, { updateBoundElements(element, elementsMap as SceneElementsMap, {
simultaneouslyUpdated: elementsToUpdate, simultaneouslyUpdated: elementsToUpdate,
File diff suppressed because it is too large Load Diff
+23
View File
@@ -319,6 +319,12 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
endArrowhead: Arrowhead | null; endArrowhead: Arrowhead | null;
}>; }>;
export type FixedSegment = {
start: LocalPoint;
end: LocalPoint;
index: number;
};
export type ExcalidrawArrowElement = ExcalidrawLinearElement & export type ExcalidrawArrowElement = ExcalidrawLinearElement &
Readonly<{ Readonly<{
type: "arrow"; type: "arrow";
@@ -331,6 +337,23 @@ export type ExcalidrawElbowArrowElement = Merge<
elbowed: true; elbowed: true;
startBinding: FixedPointBinding | null; startBinding: FixedPointBinding | null;
endBinding: FixedPointBinding | null; endBinding: FixedPointBinding | null;
fixedSegments: FixedSegment[] | null;
/**
* Marks that the 3rd point should be used as the 2nd point of the arrow in
* order to temporarily hide the first segment of the arrow without losing
* the data from the points array. It allows creating the expected arrow
* path when the arrow with fixed segments is bound on a horizontal side and
* moved to a vertical and vica versa.
*/
startIsSpecial: boolean | null;
/**
* Marks that the 3rd point backwards from the end should be used as the 2nd
* point of the arrow in order to temporarily hide the last segment of the
* arrow without losing the data from the points array. It allows creating
* the expected arrow path when the arrow with fixed segments is bound on a
* horizontal side and moved to a vertical and vica versa.
*/
endIsSpecial: boolean | null;
} }
>; >;
-1
View File
@@ -190,7 +190,6 @@ export const syncInvalidIndices = (
): OrderedExcalidrawElement[] => { ): OrderedExcalidrawElement[] => {
const indicesGroups = getInvalidIndicesGroups(elements); const indicesGroups = getInvalidIndicesGroups(elements);
const elementsUpdates = generateIndices(elements, indicesGroups); const elementsUpdates = generateIndices(elements, indicesGroups);
for (const [element, update] of elementsUpdates) { for (const [element, update] of elementsUpdates) {
mutateElement(element, update, false); mutateElement(element, update, false);
} }
+214 -44
View File
@@ -95,12 +95,11 @@ export const getElementsCompletelyInFrame = (
); );
export const isElementContainingFrame = ( export const isElementContainingFrame = (
elements: readonly ExcalidrawElement[],
element: ExcalidrawElement, element: ExcalidrawElement,
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ) => {
return getElementsWithinSelection(elements, element, elementsMap).some( return getElementsWithinSelection([frame], element, elementsMap).some(
(e) => e.id === frame.id, (e) => e.id === frame.id,
); );
}; };
@@ -144,7 +143,7 @@ export const elementOverlapsWithFrame = (
return ( return (
elementsAreInFrameBounds([element], frame, elementsMap) || elementsAreInFrameBounds([element], frame, elementsMap) ||
isElementIntersectingFrame(element, frame, elementsMap) || isElementIntersectingFrame(element, frame, elementsMap) ||
isElementContainingFrame([frame], element, frame, elementsMap) isElementContainingFrame(element, frame, elementsMap)
); );
}; };
@@ -283,7 +282,7 @@ export const getElementsInResizingFrame = (
const elementsCompletelyInFrame = new Set([ const elementsCompletelyInFrame = new Set([
...getElementsCompletelyInFrame(allElements, frame, elementsMap), ...getElementsCompletelyInFrame(allElements, frame, elementsMap),
...prevElementsInFrame.filter((element) => ...prevElementsInFrame.filter((element) =>
isElementContainingFrame(allElements, element, frame, elementsMap), isElementContainingFrame(element, frame, elementsMap),
), ),
]); ]);
@@ -370,12 +369,57 @@ export const getElementsInNewFrame = (
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ) => {
return omitGroupsContainingFrameLikes( return omitPartialGroups(
elements, omitGroupsContainingFrameLikes(
getElementsCompletelyInFrame(elements, frame, elementsMap), elements,
getElementsCompletelyInFrame(elements, frame, elementsMap),
),
frame,
elementsMap,
); );
}; };
export const omitPartialGroups = (
elements: ExcalidrawElement[],
frame: ExcalidrawFrameLikeElement,
allElementsMap: ElementsMap,
) => {
const elementsToReturn = [];
const checkedGroups = new Map<string, boolean>();
for (const element of elements) {
let shouldOmit = false;
if (element.groupIds.length > 0) {
// if some partial group should be omitted, then all elements in that group should be omitted
if (element.groupIds.some((gid) => checkedGroups.get(gid))) {
shouldOmit = true;
} else {
const allElementsInGroup = new Set(
element.groupIds.flatMap((gid) =>
getElementsInGroup(allElementsMap, gid),
),
);
shouldOmit = !elementsAreInFrameBounds(
Array.from(allElementsInGroup),
frame,
allElementsMap,
);
}
element.groupIds.forEach((gid) => {
checkedGroups.set(gid, shouldOmit);
});
}
if (!shouldOmit) {
elementsToReturn.push(element);
}
}
return elementsToReturn;
};
export const getContainingFrame = ( export const getContainingFrame = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
@@ -454,6 +498,7 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
allElements: T, allElements: T,
elementsToAdd: NonDeletedExcalidrawElement[], elementsToAdd: NonDeletedExcalidrawElement[],
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
appState: AppState,
): T => { ): T => {
const elementsMap = arrayToMap(allElements); const elementsMap = arrayToMap(allElements);
const currTargetFrameChildrenMap = new Map<ExcalidrawElement["id"], true>(); const currTargetFrameChildrenMap = new Map<ExcalidrawElement["id"], true>();
@@ -489,6 +534,17 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
continue; continue;
} }
// if the element is already in another frame (which is also in elementsToAdd),
// it means that frame and children are selected at the same time
// => keep original frame membership, do not add to the target frame
if (
element.frameId &&
appState.selectedElementIds[element.id] &&
appState.selectedElementIds[element.frameId]
) {
continue;
}
if (!currTargetFrameChildrenMap.has(element.id)) { if (!currTargetFrameChildrenMap.has(element.id)) {
finalElementsToAdd.push(element); finalElementsToAdd.push(element);
} }
@@ -577,6 +633,7 @@ export const replaceAllElementsInFrame = <T extends ExcalidrawElement>(
removeAllElementsFromFrame(allElements, frame), removeAllElementsFromFrame(allElements, frame),
nextElementsInFrame, nextElementsInFrame,
frame, frame,
app.state,
).slice(); ).slice();
}; };
@@ -683,6 +740,16 @@ export const getTargetFrame = (
? getContainerElement(element, elementsMap) || element ? getContainerElement(element, elementsMap) || element
: element; : element;
// if the element and its containing frame are both selected, then
// the containing frame is the target frame
if (
_element.frameId &&
appState.selectedElementIds[_element.id] &&
appState.selectedElementIds[_element.frameId]
) {
return getContainingFrame(_element, elementsMap);
}
return appState.selectedElementIds[_element.id] && return appState.selectedElementIds[_element.id] &&
appState.selectedElementsAreBeingDragged appState.selectedElementsAreBeingDragged
? appState.frameToHighlight ? appState.frameToHighlight
@@ -695,61 +762,151 @@ export const isElementInFrame = (
element: ExcalidrawElement, element: ExcalidrawElement,
allElementsMap: ElementsMap, allElementsMap: ElementsMap,
appState: StaticCanvasAppState, appState: StaticCanvasAppState,
opts?: {
targetFrame?: ExcalidrawFrameLikeElement;
checkedGroups?: Map<string, boolean>;
},
) => { ) => {
const frame = getTargetFrame(element, allElementsMap, appState); const frame =
opts?.targetFrame ?? getTargetFrame(element, allElementsMap, appState);
if (!frame) {
return false;
}
const _element = isTextElement(element) const _element = isTextElement(element)
? getContainerElement(element, allElementsMap) || element ? getContainerElement(element, allElementsMap) || element
: element; : element;
if (frame) { const setGroupsInFrame = (isInFrame: boolean) => {
// Perf improvement: if (opts?.checkedGroups) {
// For an element that's already in a frame, if it's not being dragged _element.groupIds.forEach((groupId) => {
// then there is no need to refer to geometry (which, yes, is slow) to check if it's in a frame. opts.checkedGroups?.set(groupId, isInFrame);
// It has to be in its containing frame. });
if ( }
!appState.selectedElementIds[element.id] || };
!appState.selectedElementsAreBeingDragged
) { if (
// if the element is not selected, or it is selected but not being dragged,
// frame membership won't update, so return true
!appState.selectedElementIds[_element.id] ||
!appState.selectedElementsAreBeingDragged ||
// if both frame and element are selected, won't update membership, so return true
(appState.selectedElementIds[_element.id] &&
appState.selectedElementIds[frame.id])
) {
return true;
}
if (_element.groupIds.length === 0) {
return elementOverlapsWithFrame(_element, frame, allElementsMap);
}
for (const gid of _element.groupIds) {
if (opts?.checkedGroups?.has(gid)) {
return opts.checkedGroups.get(gid)!!;
}
}
const allElementsInGroup = new Set(
_element.groupIds
.filter((gid) => {
if (opts?.checkedGroups) {
return !opts.checkedGroups.has(gid);
}
return true;
})
.flatMap((gid) => getElementsInGroup(allElementsMap, gid)),
);
if (appState.editingGroupId && appState.selectedElementsAreBeingDragged) {
const selectedElements = new Set(
getSelectedElements(allElementsMap, appState),
);
const editingGroupOverlapsFrame = appState.frameToHighlight !== null;
if (editingGroupOverlapsFrame) {
return true; return true;
} }
if (_element.groupIds.length === 0) { selectedElements.forEach((selectedElement) => {
return elementOverlapsWithFrame(_element, frame, allElementsMap); allElementsInGroup.delete(selectedElement);
});
}
for (const elementInGroup of allElementsInGroup) {
if (isFrameLikeElement(elementInGroup)) {
setGroupsInFrame(false);
return false;
}
}
for (const elementInGroup of allElementsInGroup) {
if (elementOverlapsWithFrame(elementInGroup, frame, allElementsMap)) {
setGroupsInFrame(true);
return true;
}
}
return false;
};
export const shouldApplyFrameClip = (
element: ExcalidrawElement,
frame: ExcalidrawFrameLikeElement,
appState: StaticCanvasAppState,
elementsMap: ElementsMap,
checkedGroups?: Map<string, boolean>,
) => {
if (!appState.frameRendering || !appState.frameRendering.clip) {
return false;
}
// for individual elements, only clip when the element is
// a. overlapping with the frame, or
// b. containing the frame, for example when an element is used as a background
// and is therefore bigger than the frame and completely contains the frame
const shouldClipElementItself =
isElementIntersectingFrame(element, frame, elementsMap) ||
isElementContainingFrame(element, frame, elementsMap);
if (shouldClipElementItself) {
for (const groupId of element.groupIds) {
checkedGroups?.set(groupId, true);
} }
const allElementsInGroup = new Set( return true;
_element.groupIds.flatMap((gid) => }
getElementsInGroup(allElementsMap, gid),
),
);
if (appState.editingGroupId && appState.selectedElementsAreBeingDragged) { // if an element is outside the frame, but is part of a group that has some elements
const selectedElements = new Set( // "in" the frame, we should clip the element
getSelectedElements(allElementsMap, appState), if (
); !shouldClipElementItself &&
element.groupIds.length > 0 &&
!elementsAreInFrameBounds([element], frame, elementsMap)
) {
let shouldClip = false;
const editingGroupOverlapsFrame = appState.frameToHighlight !== null; // if no elements are being dragged, we can skip the geometry check
// because we know if the element is in the given frame or not
if (editingGroupOverlapsFrame) { if (!appState.selectedElementsAreBeingDragged) {
return true; shouldClip = element.frameId === frame.id;
for (const groupId of element.groupIds) {
checkedGroups?.set(groupId, shouldClip);
} }
} else {
selectedElements.forEach((selectedElement) => { shouldClip = isElementInFrame(element, elementsMap, appState, {
allElementsInGroup.delete(selectedElement); targetFrame: frame,
checkedGroups,
}); });
} }
for (const elementInGroup of allElementsInGroup) { for (const groupId of element.groupIds) {
if (isFrameLikeElement(elementInGroup)) { checkedGroups?.set(groupId, shouldClip);
return false;
}
} }
for (const elementInGroup of allElementsInGroup) { return shouldClip;
if (elementOverlapsWithFrame(elementInGroup, frame, allElementsMap)) {
return true;
}
}
} }
return false; return false;
@@ -779,3 +936,16 @@ export const getElementsOverlappingFrame = (
.filter((el) => !el.frameId || el.frameId === frame.id) .filter((el) => !el.frameId || el.frameId === frame.id)
); );
}; };
export const frameAndChildrenSelectedTogether = (
selectedElements: readonly ExcalidrawElement[],
) => {
const selectedElementsMap = arrayToMap(selectedElements);
return (
selectedElements.length > 1 &&
selectedElements.some(
(element) => element.frameId && selectedElementsMap.has(element.frameId),
)
);
};
+4
View File
@@ -105,6 +105,10 @@ export const selectGroupsForSelectedElements = (function () {
const groupElementsIndex: Record<GroupId, string[]> = {}; const groupElementsIndex: Record<GroupId, string[]> = {};
const selectedElementIdsInGroups = elements.reduce( const selectedElementIdsInGroups = elements.reduce(
(acc: Record<string, true>, element) => { (acc: Record<string, true>, element) => {
if (element.isDeleted) {
return acc;
}
const groupId = element.groupIds.find((id) => selectedGroupIds[id]); const groupId = element.groupIds.find((id) => selectedGroupIds[id]);
if (groupId) { if (groupId) {
@@ -1,7 +1,6 @@
import { atom, useAtom } from "jotai";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { COLOR_PALETTE } from "../colors"; import { COLOR_PALETTE } from "../colors";
import { jotaiScope } from "../jotai"; import { atom, useAtom } from "../editor-jotai";
import { exportToSvg } from "../../utils/export"; import { exportToSvg } from "../../utils/export";
import type { LibraryItem } from "../types"; import type { LibraryItem } from "../types";
@@ -64,7 +63,7 @@ export const useLibraryItemSvg = (
}; };
export const useLibraryCache = () => { export const useLibraryCache = () => {
const [svgCache] = useAtom(libraryItemSvgsCache, jotaiScope); const [svgCache] = useAtom(libraryItemSvgsCache);
const clearLibraryCache = () => svgCache.clear(); const clearLibraryCache = () => svgCache.clear();
@@ -1,5 +1,5 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "../editor-jotai";
import throttle from "lodash.throttle"; import throttle from "lodash.throttle";
const scrollPositionAtom = atom<number>(0); const scrollPositionAtom = atom<number>(0);
+3 -4
View File
@@ -1,7 +1,6 @@
import fallbackLangData from "./locales/en.json"; import fallbackLangData from "./locales/en.json";
import percentages from "./locales/percentages.json"; import percentages from "./locales/percentages.json";
import { jotaiScope, jotaiStore } from "./jotai"; import { useAtomValue, editorJotaiStore, atom } from "./editor-jotai";
import { atom, useAtomValue } from "jotai";
import type { NestedKeyOf } from "./utility-types"; import type { NestedKeyOf } from "./utility-types";
const COMPLETION_THRESHOLD = 85; const COMPLETION_THRESHOLD = 85;
@@ -103,7 +102,7 @@ export const setLanguage = async (lang: Language) => {
} }
} }
jotaiStore.set(editorLangCodeAtom, lang.code); editorJotaiStore.set(editorLangCodeAtom, lang.code);
}; };
export const getLanguage = () => currentLang; export const getLanguage = () => currentLang;
@@ -165,6 +164,6 @@ const editorLangCodeAtom = atom(defaultLang.code);
// - component is rendered internally by <Excalidraw>, but the component // - component is rendered internally by <Excalidraw>, but the component
// is memoized w/o being updated on `langCode`, `AppState`, or `UIAppState` // is memoized w/o being updated on `langCode`, `AppState`, or `UIAppState`
export const useI18n = () => { export const useI18n = () => {
const langCode = useAtomValue(editorLangCodeAtom, jotaiScope); const langCode = useAtomValue(editorLangCodeAtom);
return { t, langCode }; return { t, langCode };
}; };
+3 -4
View File
@@ -11,8 +11,7 @@ import "./fonts/fonts.css";
import type { AppProps, ExcalidrawProps } from "./types"; import type { AppProps, ExcalidrawProps } from "./types";
import { defaultLang } from "./i18n"; import { defaultLang } from "./i18n";
import { DEFAULT_UI_OPTIONS } from "./constants"; import { DEFAULT_UI_OPTIONS } from "./constants";
import { Provider } from "jotai"; import { EditorJotaiProvider, editorJotaiStore } from "./editor-jotai";
import { jotaiScope, jotaiStore } from "./jotai";
import Footer from "./components/footer/FooterCenter"; import Footer from "./components/footer/FooterCenter";
import MainMenu from "./components/main-menu/MainMenu"; import MainMenu from "./components/main-menu/MainMenu";
import WelcomeScreen from "./components/welcome-screen/WelcomeScreen"; import WelcomeScreen from "./components/welcome-screen/WelcomeScreen";
@@ -108,7 +107,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
}, []); }, []);
return ( return (
<Provider unstable_createStore={() => jotaiStore} scope={jotaiScope}> <EditorJotaiProvider store={editorJotaiStore}>
<InitializeApp langCode={langCode} theme={theme}> <InitializeApp langCode={langCode} theme={theme}>
<App <App
onChange={onChange} onChange={onChange}
@@ -145,7 +144,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
{children} {children}
</App> </App>
</InitializeApp> </InitializeApp>
</Provider> </EditorJotaiProvider>
); );
}; };
-28
View File
@@ -1,28 +0,0 @@
import type { PrimitiveAtom } from "jotai";
import { unstable_createStore, useAtom } from "jotai";
import { useLayoutEffect } from "react";
export const jotaiScope = Symbol();
export const jotaiStore = unstable_createStore();
export const useAtomWithInitialValue = <
T extends unknown,
A extends PrimitiveAtom<T>,
>(
atom: A,
initialValue: T | (() => T),
) => {
const [value, setValue] = useAtom(atom);
useLayoutEffect(() => {
if (typeof initialValue === "function") {
// @ts-ignore
setValue(initialValue());
} else {
setValue(initialValue);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return [value, setValue] as const;
};
+308
View File
@@ -0,0 +1,308 @@
/**
* all things related to lasso selection
* - lasso selection
* - intersection and enclosure checks
*/
import {
type GlobalPoint,
type LineSegment,
type LocalPoint,
type Polygon,
pointFrom,
pointsEqual,
polygonFromPoints,
segmentsIntersectAt,
} from "../math";
import { isPointInShape } from "../utils/collision";
import {
type GeometricShape,
polylineFromPoints,
} from "../utils/geometry/shape";
import { AnimatedTrail } from "./animated-trail";
import { type AnimationFrameHandler } from "./animation-frame-handler";
import type App from "./components/App";
import { getElementLineSegments } from "./element/bounds";
import type { ElementsMap, ExcalidrawElement } from "./element/types";
import type { InteractiveCanvasRenderConfig } from "./scene/types";
import type { InteractiveCanvasAppState } from "./types";
import { easeOut } from "./utils";
export type LassoPath = {
x: number;
y: number;
points: LocalPoint[];
intersectedElements: Set<ExcalidrawElement["id"]>;
enclosedElements: Set<ExcalidrawElement["id"]>;
};
export const renderLassoSelection = (
lassoPath: LassoPath,
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
selectionColor: InteractiveCanvasRenderConfig["selectionColor"],
) => {
context.save();
context.translate(
lassoPath.x + appState.scrollX,
lassoPath.y + appState.scrollY,
);
const firstPoint = lassoPath.points[0];
if (firstPoint) {
context.beginPath();
context.moveTo(firstPoint[0], firstPoint[1]);
for (let i = 1; i < lassoPath.points.length; i++) {
context.lineTo(lassoPath.points[i][0], lassoPath.points[i][1]);
}
context.strokeStyle = selectionColor;
context.lineWidth = 3 / appState.zoom.value;
if (
lassoPath.points.length >= 3 &&
pointsEqual(
lassoPath.points[0],
lassoPath.points[lassoPath.points.length - 1],
)
) {
context.closePath();
}
context.stroke();
}
context.restore();
};
// export class LassoSelection {
// static createLassoPath = (x: number, y: number): LassoPath => {
// return {
// x,
// y,
// points: [],
// intersectedElements: new Set(),
// enclosedElements: new Set(),
// };
// };
// static updateLassoPath = (
// lassoPath: LassoPath,
// pointerCoords: { x: number; y: number },
// elementsMap: ElementsMap,
// ): LassoPath => {
// const points = lassoPath.points;
// const dx = pointerCoords.x - lassoPath.x;
// const dy = pointerCoords.y - lassoPath.y;
// const lastPoint = points.length > 0 && points[points.length - 1];
// const discardPoint =
// lastPoint && lastPoint[0] === dx && lastPoint[1] === dy;
// if (!discardPoint) {
// const nextLassoPath = {
// ...lassoPath,
// points: [...points, pointFrom<LocalPoint>(dx, dy)],
// };
// // nextLassoPath.enclosedElements.clear();
// // const enclosedLassoPath = LassoSelection.closeLassoPath(
// // nextLassoPath,
// // elementsMap,
// // );
// // for (const [id, element] of elementsMap) {
// // if (!lassoPath.intersectedElements.has(element.id)) {
// // const intersects = intersect(nextLassoPath, element, elementsMap);
// // if (intersects) {
// // lassoPath.intersectedElements.add(element.id);
// // } else {
// // // check if the lasso path encloses the element
// // const enclosed = enclose(enclosedLassoPath, element, elementsMap);
// // if (enclosed) {
// // lassoPath.enclosedElements.add(element.id);
// // }
// // }
// // }
// // }
// return nextLassoPath;
// }
// return lassoPath;
// };
// private static closeLassoPath = (
// lassoPath: LassoPath,
// elementsMap: ElementsMap,
// ) => {
// const finalPoints = [...lassoPath.points, lassoPath.points[0]];
// // TODO: check if the lasso path encloses or intersects with any element
// const finalLassoPath = {
// ...lassoPath,
// points: finalPoints,
// };
// return finalLassoPath;
// };
// static finalizeLassoPath = (
// lassoPath: LassoPath,
// elementsMap: ElementsMap,
// ) => {
// const enclosedLassoPath = LassoSelection.closeLassoPath(
// lassoPath,
// elementsMap,
// );
// enclosedLassoPath.enclosedElements.clear();
// enclosedLassoPath.intersectedElements.clear();
// // for (const [id, element] of elementsMap) {
// // const intersects = intersect(enclosedLassoPath, element, elementsMap);
// // if (intersects) {
// // enclosedLassoPath.intersectedElements.add(element.id);
// // } else {
// // const enclosed = enclose(enclosedLassoPath, element, elementsMap);
// // if (enclosed) {
// // enclosedLassoPath.enclosedElements.add(element.id);
// // }
// // }
// // }
// return enclosedLassoPath;
// };
// }
const intersectionTest = (
lassoPath: GlobalPoint[],
element: ExcalidrawElement,
elementsMap: ElementsMap,
): boolean => {
const elementLineSegments = getElementLineSegments(element, elementsMap);
const lassoSegments = lassoPath.reduce((acc, point, index) => {
if (index === 0) {
return acc;
}
const prevPoint = pointFrom<GlobalPoint>(
lassoPath[index - 1][0],
lassoPath[index - 1][1],
);
const currentPoint = pointFrom<GlobalPoint>(point[0], point[1]);
acc.push([prevPoint, currentPoint] as LineSegment<GlobalPoint>);
return acc;
}, [] as LineSegment<GlobalPoint>[]);
for (const lassoSegment of lassoSegments) {
for (const elementSegment of elementLineSegments) {
if (segmentsIntersectAt(lassoSegment, elementSegment)) {
return true;
}
}
}
return false;
};
const enclosureTest = (
lassoPath: GlobalPoint[],
element: ExcalidrawElement,
elementsMap: ElementsMap,
): boolean => {
const polyline = polylineFromPoints(lassoPath);
const closedPathShape: GeometricShape<GlobalPoint> = {
type: "polygon",
data: polygonFromPoints(polyline.flat()),
} as {
type: "polygon";
data: Polygon<GlobalPoint>;
};
const elementSegments = getElementLineSegments(element, elementsMap);
for (const segment of elementSegments) {
if (segment.some((point) => isPointInShape(point, closedPathShape))) {
return true;
}
}
return false;
};
export class LassoTrail extends AnimatedTrail {
private intersectedElements: Set<ExcalidrawElement["id"]> = new Set();
private enclosedElements: Set<ExcalidrawElement["id"]> = new Set();
constructor(animationFrameHandler: AnimationFrameHandler, app: App) {
super(animationFrameHandler, app, {
simplify: 0,
streamline: 0.4,
sizeMapping: (c) => {
const DECAY_TIME = Infinity;
const DECAY_LENGTH = 5000;
const t = Math.max(
0,
1 - (performance.now() - c.pressure) / DECAY_TIME,
);
const l =
(DECAY_LENGTH -
Math.min(DECAY_LENGTH, c.totalLength - c.currentIndex)) /
DECAY_LENGTH;
return Math.min(easeOut(l), easeOut(t));
},
fill: () => "rgb(0,118,255)",
});
}
startPath(x: number, y: number) {
super.startPath(x, y);
this.intersectedElements.clear();
this.enclosedElements.clear();
}
addPointToPath(x: number, y: number) {
super.addPointToPath(x, y);
const lassoPath = super
.getCurrentTrail()
?.originalPoints?.map((p) => pointFrom<GlobalPoint>(p[0], p[1]));
if (lassoPath) {
// TODO: further OPT: do not check elements that are "far away"
const elementsMap = this.app.scene.getNonDeletedElementsMap();
const closedPath = polygonFromPoints(lassoPath);
// need to clear the enclosed elements as path might change
this.enclosedElements.clear();
for (const [, element] of elementsMap) {
if (!this.intersectedElements.has(element.id)) {
const intersects = intersectionTest(lassoPath, element, elementsMap);
if (intersects) {
this.intersectedElements.add(element.id);
} else {
// TODO: check bounding box is at least in the lasso path area first
// BUT: need to compare bounding box check with enclosure check performance
const enclosed = enclosureTest(closedPath, element, elementsMap);
if (enclosed) {
this.enclosedElements.add(element.id);
}
}
}
}
}
return {
intersectedElementIds: this.intersectedElements,
enclosedElementIds: this.enclosedElements,
};
}
endPath(): void {
super.endPath();
super.clearTrails();
this.intersectedElements.clear();
this.enclosedElements.clear();
}
}
+2 -1
View File
@@ -164,7 +164,8 @@
"imageCropping": "Image cropping", "imageCropping": "Image cropping",
"unCroppedDimension": "Uncropped dimension", "unCroppedDimension": "Uncropped dimension",
"copyElementLink": "Copy link to object", "copyElementLink": "Copy link to object",
"linkToElement": "Link to object" "linkToElement": "Link to object",
"wrapSelectionInFrame": "Wrap selection in frame"
}, },
"elementLink": { "elementLink": {
"title": "Link to object", "title": "Link to object",
+3 -1
View File
@@ -70,7 +70,8 @@
"fractional-indexing": "3.2.0", "fractional-indexing": "3.2.0",
"fuzzy": "0.1.3", "fuzzy": "0.1.3",
"image-blob-reduce": "3.0.1", "image-blob-reduce": "3.0.1",
"jotai": "1.13.1", "jotai": "2.11.0",
"jotai-scope": "0.7.2",
"lodash.throttle": "4.1.1", "lodash.throttle": "4.1.1",
"nanoid": "3.3.3", "nanoid": "3.3.3",
"open-color": "1.9.1", "open-color": "1.9.1",
@@ -116,6 +117,7 @@
"fonteditor-core": "2.4.1", "fonteditor-core": "2.4.1",
"harfbuzzjs": "0.3.6", "harfbuzzjs": "0.3.6",
"import-meta-loader": "1.1.0", "import-meta-loader": "1.1.0",
"jest-diff": "29.7.0",
"mini-css-extract-plugin": "2.6.1", "mini-css-extract-plugin": "2.6.1",
"postcss-loader": "7.0.1", "postcss-loader": "7.0.1",
"sass-loader": "13.0.2", "sass-loader": "13.0.2",
@@ -29,7 +29,7 @@ import {
getOmitSidesForDevice, getOmitSidesForDevice,
shouldShowBoundingBox, shouldShowBoundingBox,
} from "../element/transformHandles"; } from "../element/transformHandles";
import { arrayToMap, throttleRAF } from "../utils"; import { arrayToMap, invariant, throttleRAF } from "../utils";
import { import {
DEFAULT_TRANSFORM_HANDLE_SPACING, DEFAULT_TRANSFORM_HANDLE_SPACING,
FRAME_STYLE, FRAME_STYLE,
@@ -78,8 +78,32 @@ import type {
InteractiveSceneRenderConfig, InteractiveSceneRenderConfig,
RenderableElementsMap, RenderableElementsMap,
} from "../scene/types"; } from "../scene/types";
import type { GlobalPoint, LocalPoint, Radians } from "../../math"; import {
pointFrom,
type GlobalPoint,
type LocalPoint,
type Radians,
} from "../../math";
import { getCornerRadius } from "../shapes"; import { getCornerRadius } from "../shapes";
import { renderLassoSelection } from "../lasso";
const renderElbowArrowMidPointHighlight = (
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
) => {
invariant(appState.selectedLinearElement, "selectedLinearElement is null");
const { segmentMidPointHoveredCoords } = appState.selectedLinearElement;
invariant(segmentMidPointHoveredCoords, "midPointCoords is null");
context.save();
context.translate(appState.scrollX, appState.scrollY);
highlightPoint(segmentMidPointHoveredCoords, context, appState);
context.restore();
};
const renderLinearElementPointHighlight = ( const renderLinearElementPointHighlight = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
@@ -490,7 +514,7 @@ const renderLinearPointHandles = (
context.save(); context.save();
context.translate(appState.scrollX, appState.scrollY); context.translate(appState.scrollX, appState.scrollY);
context.lineWidth = 1 / appState.zoom.value; context.lineWidth = 1 / appState.zoom.value;
const points = LinearElementEditor.getPointsGlobalCoordinates( const points: GlobalPoint[] = LinearElementEditor.getPointsGlobalCoordinates(
element, element,
elementsMap, elementsMap,
); );
@@ -510,55 +534,57 @@ const renderLinearPointHandles = (
renderSingleLinearPoint(context, appState, point, radius, isSelected); renderSingleLinearPoint(context, appState, point, radius, isSelected);
}); });
//Rendering segment mid points // Rendering segment mid points
const midPoints = LinearElementEditor.getEditorMidPoints( if (isElbowArrow(element)) {
element, const fixedSegments =
elementsMap, element.fixedSegments?.map((segment) => segment.index) || [];
appState, points.slice(0, -1).forEach((p, idx) => {
).filter((midPoint): midPoint is GlobalPoint => midPoint !== null); if (
!LinearElementEditor.isSegmentTooShort(
midPoints.forEach((segmentMidPoint) => { element,
if ( points[idx + 1],
appState?.selectedLinearElement?.segmentMidPointHoveredCoords && points[idx],
LinearElementEditor.arePointsEqual( idx,
segmentMidPoint, appState.zoom,
appState.selectedLinearElement.segmentMidPointHoveredCoords, )
) ) {
) {
// The order of renderingSingleLinearPoint and highLight points is different
// inside vs outside editor as hover states are different,
// in editor when hovered the original point is not visible as hover state fully covers it whereas outside the
// editor original point is visible and hover state is just an outer circle.
if (appState.editingLinearElement) {
renderSingleLinearPoint( renderSingleLinearPoint(
context, context,
appState, appState,
segmentMidPoint, pointFrom<GlobalPoint>(
radius, (p[0] + points[idx + 1][0]) / 2,
false, (p[1] + points[idx + 1][1]) / 2,
); ),
highlightPoint(segmentMidPoint, context, appState); POINT_HANDLE_SIZE / 2,
} else {
highlightPoint(segmentMidPoint, context, appState);
renderSingleLinearPoint(
context,
appState,
segmentMidPoint,
radius,
false, false,
!fixedSegments.includes(idx + 1),
); );
} }
} else if (appState.editingLinearElement || points.length === 2) { });
renderSingleLinearPoint( } else {
context, const midPoints = LinearElementEditor.getEditorMidPoints(
appState, element,
segmentMidPoint, elementsMap,
POINT_HANDLE_SIZE / 2, appState,
false, ).filter(
true, (midPoint, idx, midPoints): midPoint is GlobalPoint =>
); midPoint !== null &&
} !(isElbowArrow(element) && (idx === 0 || idx === midPoints.length - 1)),
}); );
midPoints.forEach((segmentMidPoint) => {
if (appState.editingLinearElement || points.length === 2) {
renderSingleLinearPoint(
context,
appState,
segmentMidPoint,
POINT_HANDLE_SIZE / 2,
false,
true,
);
}
});
}
context.restore(); context.restore();
}; };
@@ -864,6 +890,12 @@ const _renderInteractiveScene = ({
} }
if ( if (
isElbowArrow(selectedElements[0]) &&
appState.selectedLinearElement &&
appState.selectedLinearElement.segmentMidPointHoveredCoords
) {
renderElbowArrowMidPointHighlight(context, appState);
} else if (
appState.selectedLinearElement && appState.selectedLinearElement &&
appState.selectedLinearElement.hoverPointIndex >= 0 && appState.selectedLinearElement.hoverPointIndex >= 0 &&
!( !(
@@ -875,6 +907,7 @@ const _renderInteractiveScene = ({
) { ) {
renderLinearElementPointHighlight(context, appState, elementsMap); renderLinearElementPointHighlight(context, appState, elementsMap);
} }
// Paint selected elements // Paint selected elements
if (!appState.multiElement && !appState.editingLinearElement) { if (!appState.multiElement && !appState.editingLinearElement) {
const showBoundingBox = shouldShowBoundingBox(selectedElements, appState); const showBoundingBox = shouldShowBoundingBox(selectedElements, appState);
+23 -5
View File
@@ -4,7 +4,7 @@ import { getElementAbsoluteCoords } from "../element";
import { import {
elementOverlapsWithFrame, elementOverlapsWithFrame,
getTargetFrame, getTargetFrame,
isElementInFrame, shouldApplyFrameClip,
} from "../frame"; } from "../frame";
import { import {
isEmbeddableElement, isEmbeddableElement,
@@ -273,6 +273,8 @@ const _renderStaticScene = ({
} }
}); });
const inFrameGroupsMap = new Map<string, boolean>();
// Paint visible elements // Paint visible elements
visibleElements visibleElements
.filter((el) => !isIframeLikeElement(el)) .filter((el) => !isIframeLikeElement(el))
@@ -297,9 +299,16 @@ const _renderStaticScene = ({
appState.frameRendering.clip appState.frameRendering.clip
) { ) {
const frame = getTargetFrame(element, elementsMap, appState); const frame = getTargetFrame(element, elementsMap, appState);
if (
// TODO do we need to check isElementInFrame here? frame &&
if (frame && isElementInFrame(element, elementsMap, appState)) { shouldApplyFrameClip(
element,
frame,
appState,
elementsMap,
inFrameGroupsMap,
)
) {
frameClip(frame, context, renderConfig, appState); frameClip(frame, context, renderConfig, appState);
} }
renderElement( renderElement(
@@ -400,7 +409,16 @@ const _renderStaticScene = ({
const frame = getTargetFrame(element, elementsMap, appState); const frame = getTargetFrame(element, elementsMap, appState);
if (frame && isElementInFrame(element, elementsMap, appState)) { if (
frame &&
shouldApplyFrameClip(
element,
frame,
appState,
elementsMap,
inFrameGroupsMap,
)
) {
frameClip(frame, context, renderConfig, appState); frameClip(frame, context, renderConfig, appState);
} }
render(); render();
+26 -22
View File
@@ -23,13 +23,9 @@ import {
} from "../element/typeChecks"; } from "../element/typeChecks";
import { canChangeRoundness } from "./comparisons"; import { canChangeRoundness } from "./comparisons";
import type { EmbedsValidationStatus } from "../types"; import type { EmbedsValidationStatus } from "../types";
import { import { pointFrom, pointDistance, type LocalPoint } from "../../math";
pointFrom,
pointDistance,
type GlobalPoint,
type LocalPoint,
} from "../../math";
import { getCornerRadius, isPathALoop } from "../shapes"; import { getCornerRadius, isPathALoop } from "../shapes";
import { headingForPointIsHorizontal } from "../element/heading";
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth]; const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
@@ -527,45 +523,53 @@ export const _generateElementShape = (
} }
}; };
const generateElbowArrowShape = <Point extends GlobalPoint | LocalPoint>( const generateElbowArrowShape = (
points: readonly Point[], points: readonly LocalPoint[],
radius: number, radius: number,
) => { ) => {
const subpoints = [] as [number, number][]; const subpoints = [] as [number, number][];
for (let i = 1; i < points.length - 1; i += 1) { for (let i = 1; i < points.length - 1; i += 1) {
const prev = points[i - 1]; const prev = points[i - 1];
const next = points[i + 1]; const next = points[i + 1];
const point = points[i];
const prevIsHorizontal = headingForPointIsHorizontal(point, prev);
const nextIsHorizontal = headingForPointIsHorizontal(next, point);
const corner = Math.min( const corner = Math.min(
radius, radius,
pointDistance(points[i], next) / 2, pointDistance(points[i], next) / 2,
pointDistance(points[i], prev) / 2, pointDistance(points[i], prev) / 2,
); );
if (prev[0] < points[i][0] && prev[1] === points[i][1]) { if (prevIsHorizontal) {
// LEFT if (prev[0] < point[0]) {
subpoints.push([points[i][0] - corner, points[i][1]]); // LEFT
} else if (prev[0] === points[i][0] && prev[1] < points[i][1]) { subpoints.push([points[i][0] - corner, points[i][1]]);
} else {
// RIGHT
subpoints.push([points[i][0] + corner, points[i][1]]);
}
} else if (prev[1] < point[1]) {
// UP // UP
subpoints.push([points[i][0], points[i][1] - corner]); 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 { } else {
subpoints.push([points[i][0], points[i][1] + corner]); subpoints.push([points[i][0], points[i][1] + corner]);
} }
subpoints.push(points[i] as [number, number]); subpoints.push(points[i] as [number, number]);
if (next[0] < points[i][0] && next[1] === points[i][1]) { if (nextIsHorizontal) {
// LEFT if (next[0] < point[0]) {
subpoints.push([points[i][0] - corner, points[i][1]]); // LEFT
} else if (next[0] === points[i][0] && next[1] < points[i][1]) { subpoints.push([points[i][0] - corner, points[i][1]]);
} else {
// RIGHT
subpoints.push([points[i][0] + corner, points[i][1]]);
}
} else if (next[1] < point[1]) {
// UP // UP
subpoints.push([points[i][0], points[i][1] - corner]); 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 { } else {
// DOWN
subpoints.push([points[i][0], points[i][1] + corner]); subpoints.push([points[i][0], points[i][1] + corner]);
} }
} }
+5 -2
View File
@@ -183,10 +183,12 @@ export const getSelectedElements = (
includeElementsInFrames?: boolean; includeElementsInFrames?: boolean;
}, },
) => { ) => {
const addedElements = new Set<ExcalidrawElement["id"]>();
const selectedElements: ExcalidrawElement[] = []; const selectedElements: ExcalidrawElement[] = [];
for (const element of elements.values()) { for (const element of elements.values()) {
if (appState.selectedElementIds[element.id]) { if (appState.selectedElementIds[element.id]) {
selectedElements.push(element); selectedElements.push(element);
addedElements.add(element.id);
continue; continue;
} }
if ( if (
@@ -195,6 +197,7 @@ export const getSelectedElements = (
appState.selectedElementIds[element?.containerId] appState.selectedElementIds[element?.containerId]
) { ) {
selectedElements.push(element); selectedElements.push(element);
addedElements.add(element.id);
continue; continue;
} }
} }
@@ -203,8 +206,8 @@ export const getSelectedElements = (
const elementsToInclude: ExcalidrawElement[] = []; const elementsToInclude: ExcalidrawElement[] = [];
selectedElements.forEach((element) => { selectedElements.forEach((element) => {
if (isFrameLikeElement(element)) { if (isFrameLikeElement(element)) {
getFrameChildren(elements, element.id).forEach((e) => getFrameChildren(elements, element.id).forEach(
elementsToInclude.push(e), (e) => !addedElements.has(e.id) && elementsToInclude.push(e),
); );
} }
elementsToInclude.push(element); elementsToInclude.push(element);
@@ -97,6 +97,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"category": "element", "category": "element",
}, },
}, },
"separator",
{ {
"label": "labels.selectAllElementsInFrame", "label": "labels.selectAllElementsInFrame",
"name": "selectAllElementsInFrame", "name": "selectAllElementsInFrame",
@@ -115,6 +116,15 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"category": "history", "category": "history",
}, },
}, },
{
"label": "labels.wrapSelectionInFrame",
"name": "wrapSelectionInFrame",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
},
"separator", "separator",
{ {
"PanelComponent": [Function], "PanelComponent": [Function],
@@ -4731,6 +4741,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"category": "element", "category": "element",
}, },
}, },
"separator",
{ {
"label": "labels.selectAllElementsInFrame", "label": "labels.selectAllElementsInFrame",
"name": "selectAllElementsInFrame", "name": "selectAllElementsInFrame",
@@ -4749,6 +4760,15 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"category": "history", "category": "history",
}, },
}, },
{
"label": "labels.wrapSelectionInFrame",
"name": "wrapSelectionInFrame",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
},
"separator", "separator",
{ {
"PanelComponent": [Function], "PanelComponent": [Function],
@@ -5942,6 +5962,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"category": "element", "category": "element",
}, },
}, },
"separator",
{ {
"label": "labels.selectAllElementsInFrame", "label": "labels.selectAllElementsInFrame",
"name": "selectAllElementsInFrame", "name": "selectAllElementsInFrame",
@@ -5960,6 +5981,15 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"category": "history", "category": "history",
}, },
}, },
{
"label": "labels.wrapSelectionInFrame",
"name": "wrapSelectionInFrame",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
},
"separator", "separator",
{ {
"PanelComponent": [Function], "PanelComponent": [Function],
@@ -7876,6 +7906,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"category": "element", "category": "element",
}, },
}, },
"separator",
{ {
"label": "labels.selectAllElementsInFrame", "label": "labels.selectAllElementsInFrame",
"name": "selectAllElementsInFrame", "name": "selectAllElementsInFrame",
@@ -7894,6 +7925,15 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"category": "history", "category": "history",
}, },
}, },
{
"label": "labels.wrapSelectionInFrame",
"name": "wrapSelectionInFrame",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
},
"separator", "separator",
{ {
"PanelComponent": [Function], "PanelComponent": [Function],
@@ -8854,6 +8894,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"category": "element", "category": "element",
}, },
}, },
"separator",
{ {
"label": "labels.selectAllElementsInFrame", "label": "labels.selectAllElementsInFrame",
"name": "selectAllElementsInFrame", "name": "selectAllElementsInFrame",
@@ -8872,6 +8913,15 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"category": "history", "category": "history",
}, },
}, },
{
"label": "labels.wrapSelectionInFrame",
"name": "wrapSelectionInFrame",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
},
"separator", "separator",
{ {
"PanelComponent": [Function], "PanelComponent": [Function],
@@ -197,7 +197,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 125, "height": 99,
"id": "id166", "id": "id166",
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
@@ -211,8 +211,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0, 0,
], ],
[ [
125, "98.20800",
125, 99,
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -226,9 +226,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 47, "version": 40,
"width": 125, "width": "98.20800",
"x": 0, "x": 1,
"y": 0, "y": 0,
} }
`; `;
@@ -298,7 +298,7 @@ History {
"focus": "0.00990", "focus": "0.00990",
"gap": 1, "gap": 1,
}, },
"height": "0.98000", "height": "0.98017",
"points": [ "points": [
[ [
0, 0,
@@ -306,7 +306,7 @@ History {
], ],
[ [
98, 98,
"-0.98000", "-0.98017",
], ],
], ],
"startBinding": { "startBinding": {
@@ -320,10 +320,10 @@ History {
"endBinding": { "endBinding": {
"elementId": "id165", "elementId": "id165",
"fixedPoint": null, "fixedPoint": null,
"focus": "-0.02040", "focus": "-0.02000",
"gap": 1, "gap": 1,
}, },
"height": "0.02000", "height": "0.00169",
"points": [ "points": [
[ [
0, 0,
@@ -331,13 +331,13 @@ History {
], ],
[ [
98, 98,
"0.02000", "0.00169",
], ],
], ],
"startBinding": { "startBinding": {
"elementId": "id164", "elementId": "id164",
"fixedPoint": null, "fixedPoint": null,
"focus": "0.01959", "focus": "0.02000",
"gap": 1, "gap": 1,
}, },
}, },
@@ -393,20 +393,18 @@ History {
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
"height": 125, "height": 99,
"points": [ "points": [
[ [
0, 0,
0, 0,
], ],
[ [
125, "98.20800",
125, 99,
], ],
], ],
"startBinding": null, "startBinding": null,
"width": 125,
"x": 0,
"y": 0, "y": 0,
}, },
"inserted": { "inserted": {
@@ -416,7 +414,7 @@ History {
"focus": "0.00990", "focus": "0.00990",
"gap": 1, "gap": 1,
}, },
"height": "0.98000", "height": "0.98161",
"points": [ "points": [
[ [
0, 0,
@@ -424,7 +422,7 @@ History {
], ],
[ [
98, 98,
"-0.98000", "-0.98161",
], ],
], ],
"startBinding": { "startBinding": {
@@ -433,9 +431,7 @@ History {
"focus": "0.02970", "focus": "0.02970",
"gap": 1, "gap": 1,
}, },
"width": 98, "y": "0.99245",
"x": 1,
"y": "0.99000",
}, },
}, },
"id169" => Delta { "id169" => Delta {
@@ -827,9 +823,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 37, "version": 30,
"width": 100, "width": 0,
"x": 150, "x": 200,
"y": 0, "y": 0,
} }
`; `;
@@ -866,8 +862,6 @@ History {
0, 0,
], ],
], ],
"width": 0,
"x": 149,
}, },
"inserted": { "inserted": {
"points": [ "points": [
@@ -876,12 +870,10 @@ History {
0, 0,
], ],
[ [
"98.00000", 100,
0, 0,
], ],
], ],
"width": "98.00000",
"x": "1.00000",
}, },
}, },
}, },
@@ -938,8 +930,6 @@ History {
], ],
], ],
"startBinding": null, "startBinding": null,
"width": 100,
"x": 150,
}, },
"inserted": { "inserted": {
"endBinding": { "endBinding": {
@@ -964,8 +954,6 @@ History {
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
"width": 0,
"x": 149,
}, },
}, },
}, },
@@ -2375,9 +2363,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 12, "version": 10,
"width": 498, "width": 498,
"x": "1.00000", "x": 1,
"y": 0, "y": 0,
} }
`; `;
@@ -2516,7 +2504,7 @@ History {
0, 0,
], ],
[ [
"98.00000", 100,
0, 0,
], ],
], ],
@@ -2535,8 +2523,8 @@ History {
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"width": "98.00000", "width": 100,
"x": 1, "x": 0,
"y": 0, "y": 0,
}, },
"inserted": { "inserted": {
@@ -10908,12 +10896,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
{ {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [],
{
"id": "6Rm4g567UQM4WjLwej2Vc",
"type": "arrow",
},
],
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@@ -10921,7 +10904,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"height": 126, "height": 126,
"id": "KPrBI4g_v9qUB1XxYLgSz", "id": "KPrBI4g_v9qUB1XxYLgSz",
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": true,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@@ -10934,7 +10917,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 6, "version": 4,
"width": 157, "width": 157,
"x": 600, "x": 600,
"y": 0, "y": 0,
@@ -10945,12 +10928,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
{ {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [],
{
"id": "6Rm4g567UQM4WjLwej2Vc",
"type": "arrow",
},
],
"customData": undefined, "customData": undefined,
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@@ -10958,7 +10936,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"height": 129, "height": 129,
"id": "u2JGnnmoJ0VATV4vCNJE5", "id": "u2JGnnmoJ0VATV4vCNJE5",
"index": "a1", "index": "a1",
"isDeleted": false, "isDeleted": true,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@@ -10971,7 +10949,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"strokeWidth": 2, "strokeWidth": 2,
"type": "diamond", "type": "diamond",
"updated": 1, "updated": 1,
"version": 6, "version": 4,
"width": 124, "width": 124,
"x": 1152, "x": 1152,
"y": 516, "y": 516,
@@ -10995,13 +10973,15 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"focus": "-0.00161", "focus": "-0.00161",
"gap": "3.53708", "gap": "3.53708",
}, },
"endIsSpecial": false,
"fillStyle": "solid", "fillStyle": "solid",
"fixedSegments": [],
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "448.10100", "height": "236.10000",
"id": "6Rm4g567UQM4WjLwej2Vc", "id": "6Rm4g567UQM4WjLwej2Vc",
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": true,
"lastCommittedPoint": null, "lastCommittedPoint": null,
"link": null, "link": null,
"locked": false, "locked": false,
@@ -11012,12 +10992,12 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
0, 0,
], ],
[ [
"451.90000", "178.90000",
0, 0,
], ],
[ [
"451.90000", "178.90000",
"448.10100", "236.10000",
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -11034,15 +11014,16 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"focus": "-0.00159", "focus": "-0.00159",
"gap": 5, "gap": 5,
}, },
"startIsSpecial": false,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 6, "version": 3,
"width": "451.90000", "width": "178.90000",
"x": 762, "x": 1035,
"y": "62.90000", "y": "274.90000",
} }
`; `;
@@ -11054,8 +11035,7 @@ History {
[Function], [Function],
], ],
}, },
"redoStack": [], "redoStack": [
"undoStack": [
HistoryEntry { HistoryEntry {
"appStateChange": AppStateChange { "appStateChange": AppStateChange {
"delta": Delta { "delta": Delta {
@@ -11064,86 +11044,12 @@ History {
}, },
}, },
"elementsChange": ElementsChange { "elementsChange": ElementsChange {
"added": Map {}, "added": Map {
"removed": Map {
"KPrBI4g_v9qUB1XxYLgSz" => Delta {
"deleted": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 126,
"index": "a0",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": {
"type": 3,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"width": 157,
"x": 873,
"y": 212,
},
"inserted": {
"isDeleted": true,
},
},
"u2JGnnmoJ0VATV4vCNJE5" => Delta {
"deleted": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 129,
"index": "a1",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": {
"type": 3,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "diamond",
"width": 124,
"x": 1152,
"y": 516,
},
"inserted": {
"isDeleted": true,
},
},
},
"updated": Map {},
},
},
HistoryEntry {
"appStateChange": AppStateChange {
"delta": Delta {
"deleted": {},
"inserted": {},
},
},
"elementsChange": ElementsChange {
"added": Map {},
"removed": Map {
"6Rm4g567UQM4WjLwej2Vc" => Delta { "6Rm4g567UQM4WjLwej2Vc" => Delta {
"deleted": { "deleted": {
"isDeleted": true,
},
"inserted": {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
@@ -11159,7 +11065,9 @@ History {
"focus": "-0.00161", "focus": "-0.00161",
"gap": "3.53708", "gap": "3.53708",
}, },
"endIsSpecial": false,
"fillStyle": "solid", "fillStyle": "solid",
"fixedSegments": [],
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "236.10000", "height": "236.10000",
@@ -11197,6 +11105,7 @@ History {
"focus": "-0.00159", "focus": "-0.00159",
"gap": 5, "gap": 5,
}, },
"startIsSpecial": false,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
@@ -11205,14 +11114,15 @@ History {
"x": 1035, "x": 1035,
"y": "274.90000", "y": "274.90000",
}, },
"inserted": {
"isDeleted": true,
},
}, },
}, },
"removed": Map {},
"updated": Map { "updated": Map {
"KPrBI4g_v9qUB1XxYLgSz" => Delta { "KPrBI4g_v9qUB1XxYLgSz" => Delta {
"deleted": { "deleted": {
"boundElements": [],
},
"inserted": {
"boundElements": [ "boundElements": [
{ {
"id": "6Rm4g567UQM4WjLwej2Vc", "id": "6Rm4g567UQM4WjLwej2Vc",
@@ -11220,12 +11130,12 @@ History {
}, },
], ],
}, },
"inserted": {
"boundElements": [],
},
}, },
"u2JGnnmoJ0VATV4vCNJE5" => Delta { "u2JGnnmoJ0VATV4vCNJE5" => Delta {
"deleted": { "deleted": {
"boundElements": [],
},
"inserted": {
"boundElements": [ "boundElements": [
{ {
"id": "6Rm4g567UQM4WjLwej2Vc", "id": "6Rm4g567UQM4WjLwej2Vc",
@@ -11233,14 +11143,88 @@ History {
}, },
], ],
}, },
"inserted": {
"boundElements": [],
},
}, },
}, },
}, },
}, },
HistoryEntry {
"appStateChange": AppStateChange {
"delta": Delta {
"deleted": {},
"inserted": {},
},
},
"elementsChange": ElementsChange {
"added": Map {
"KPrBI4g_v9qUB1XxYLgSz" => Delta {
"deleted": {
"isDeleted": true,
},
"inserted": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 126,
"index": "a0",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": {
"type": 3,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"width": 157,
"x": 600,
"y": 0,
},
},
"u2JGnnmoJ0VATV4vCNJE5" => Delta {
"deleted": {
"isDeleted": true,
},
"inserted": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 129,
"index": "a1",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": {
"type": 3,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "diamond",
"width": 124,
"x": 1152,
"y": 516,
},
},
},
"removed": Map {},
"updated": Map {},
},
},
], ],
"undoStack": [],
} }
`; `;
@@ -15179,9 +15163,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 12, "version": 10,
"width": "98.00000", "width": "98.00000",
"x": "1.00000", "x": 1,
"y": 0, "y": 0,
} }
`; `;
@@ -15220,7 +15204,7 @@ History {
0, 0,
], ],
[ [
"98.00000", 100,
0, 0,
], ],
], ],
@@ -15233,7 +15217,7 @@ History {
0, 0,
], ],
[ [
"98.00000", 100,
0, 0,
], ],
], ],
@@ -15529,7 +15513,7 @@ History {
0, 0,
], ],
[ [
"98.00000", 100,
0, 0,
], ],
], ],
@@ -15548,8 +15532,8 @@ History {
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"width": "98.00000", "width": 100,
"x": 1, "x": 0,
"y": 0, "y": 0,
}, },
"inserted": { "inserted": {
@@ -15878,9 +15862,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 12, "version": 10,
"width": "98.00000", "width": "98.00000",
"x": "1.00000", "x": 1,
"y": 0, "y": 0,
} }
`; `;
@@ -16152,7 +16136,7 @@ History {
0, 0,
], ],
[ [
"98.00000", 100,
0, 0,
], ],
], ],
@@ -16171,8 +16155,8 @@ History {
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"width": "98.00000", "width": 100,
"x": 1, "x": 0,
"y": 0, "y": 0,
}, },
"inserted": { "inserted": {
@@ -16501,9 +16485,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 12, "version": 10,
"width": "98.00000", "width": "98.00000",
"x": "1.00000", "x": 1,
"y": 0, "y": 0,
} }
`; `;
@@ -16775,7 +16759,7 @@ History {
0, 0,
], ],
[ [
"98.00000", 100,
0, 0,
], ],
], ],
@@ -16794,8 +16778,8 @@ History {
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"width": "98.00000", "width": 100,
"x": 1, "x": 0,
"y": 0, "y": 0,
}, },
"inserted": { "inserted": {
@@ -17122,9 +17106,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 12, "version": 10,
"width": "98.00000", "width": "98.00000",
"x": "1.00000", "x": 1,
"y": 0, "y": 0,
} }
`; `;
@@ -17180,7 +17164,7 @@ History {
0, 0,
], ],
[ [
"98.00000", 100,
0, 0,
], ],
], ],
@@ -17198,7 +17182,7 @@ History {
0, 0,
], ],
[ [
"98.00000", 100,
0, 0,
], ],
], ],
@@ -17467,7 +17451,7 @@ History {
0, 0,
], ],
[ [
"98.00000", 100,
0, 0,
], ],
], ],
@@ -17486,8 +17470,8 @@ History {
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"width": "98.00000", "width": 100,
"x": 1, "x": 0,
"y": 0, "y": 0,
}, },
"inserted": { "inserted": {
@@ -17840,9 +17824,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 13, "version": 11,
"width": "98.00000", "width": "98.00000",
"x": "1.00000", "x": 1,
"y": 0, "y": 0,
} }
`; `;
@@ -17913,7 +17897,7 @@ History {
0, 0,
], ],
[ [
"98.00000", 100,
0, 0,
], ],
], ],
@@ -17932,7 +17916,7 @@ History {
0, 0,
], ],
[ [
"98.00000", 100,
0, 0,
], ],
], ],
@@ -18201,7 +18185,7 @@ History {
0, 0,
], ],
[ [
"98.00000", 100,
0, 0,
], ],
], ],
@@ -18220,8 +18204,8 @@ History {
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"width": "98.00000", "width": 100,
"x": 1, "x": 0,
"y": 0, "y": 0,
}, },
"inserted": { "inserted": {
@@ -173,7 +173,7 @@ exports[`move element > rectangles with binding arrow 6`] = `
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 7, "version": 7,
"versionNonce": 2066753033, "versionNonce": 745419401,
"width": 300, "width": 300,
"x": 201, "x": 201,
"y": 2, "y": 2,
@@ -232,8 +232,8 @@ exports[`move element > rectangles with binding arrow 7`] = `
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 15, "version": 11,
"versionNonce": 271613161, "versionNonce": 1051383431,
"width": 81, "width": 81,
"x": 110, "x": 110,
"y": 50, "y": 50,
@@ -4307,14 +4307,20 @@ History {
"appStateChange": AppStateChange { "appStateChange": AppStateChange {
"delta": Delta { "delta": Delta {
"deleted": { "deleted": {
"editingGroupId": null,
"selectedElementIds": { "selectedElementIds": {
"id1": true, "id1": true,
}, },
"selectedGroupIds": {
"id4": false,
},
}, },
"inserted": { "inserted": {
"editingGroupId": "id4",
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
"selectedGroupIds": {},
}, },
}, },
}, },
@@ -4337,14 +4343,16 @@ History {
"appStateChange": AppStateChange { "appStateChange": AppStateChange {
"delta": Delta { "delta": Delta {
"deleted": { "deleted": {
"editingGroupId": null,
"selectedElementIds": {}, "selectedElementIds": {},
"selectedGroupIds": {},
}, },
"inserted": { "inserted": {
"editingGroupId": "id4",
"selectedElementIds": { "selectedElementIds": {
"id1": true, "id1": true,
}, },
"selectedGroupIds": {
"id4": false,
},
}, },
}, },
}, },
@@ -120,6 +120,7 @@ describe("contextMenu element", () => {
"cut", "cut",
"copy", "copy",
"paste", "paste",
"wrapSelectionInFrame",
"copyStyles", "copyStyles",
"pasteStyles", "pasteStyles",
"deleteSelectedElements", "deleteSelectedElements",
@@ -213,6 +214,7 @@ describe("contextMenu element", () => {
"cut", "cut",
"copy", "copy",
"paste", "paste",
"wrapSelectionInFrame",
"copyStyles", "copyStyles",
"pasteStyles", "pasteStyles",
"deleteSelectedElements", "deleteSelectedElements",
@@ -269,6 +271,7 @@ describe("contextMenu element", () => {
"cut", "cut",
"copy", "copy",
"paste", "paste",
"wrapSelectionInFrame",
"copyStyles", "copyStyles",
"pasteStyles", "pasteStyles",
"deleteSelectedElements", "deleteSelectedElements",
@@ -171,8 +171,8 @@ describe("Crop an image", () => {
// test corner handle aspect ratio preserving // test corner handle aspect ratio preserving
UI.crop(image, "se", naturalWidth, naturalHeight, [initialWidth, 0], true); UI.crop(image, "se", naturalWidth, naturalHeight, [initialWidth, 0], true);
expect(image.width / image.height).toBe(resizedWidth / resizedHeight); expect(image.width / image.height).toBe(resizedWidth / resizedHeight);
expect(image.width).toBeLessThanOrEqual(initialWidth); expect(image.width).toBeLessThanOrEqual(initialWidth + 0.0001);
expect(image.height).toBeLessThanOrEqual(initialHeight); expect(image.height).toBeLessThanOrEqual(initialHeight + 0.0001);
// reset // reset
image = API.createElement({ type: "image", width: 200, height: 100 }); image = API.createElement({ type: "image", width: 200, height: 100 });
@@ -194,7 +194,7 @@ describe("Crop an image", () => {
expect(image.width).toBeCloseTo(image.height); expect(image.width).toBeCloseTo(image.height);
// max height should be reached // max height should be reached
expect(image.height).toBeCloseTo(initialHeight); expect(image.height).toBeCloseTo(initialHeight);
expect(image.width).toBe(initialHeight); expect(image.width).toBeCloseTo(initialHeight);
}); });
}); });
+2
View File
@@ -11,6 +11,7 @@ import type {
ExcalidrawMagicFrameElement, ExcalidrawMagicFrameElement,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
ExcalidrawArrowElement, ExcalidrawArrowElement,
FixedSegment,
} from "../../element/types"; } from "../../element/types";
import { newElement, newTextElement, newLinearElement } from "../../element"; import { newElement, newTextElement, newLinearElement } from "../../element";
import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS } from "../../constants"; import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS } from "../../constants";
@@ -197,6 +198,7 @@ export class API {
? ExcalidrawArrowElement["endArrowhead"] | ExcalidrawElbowArrowElement["endArrowhead"] ? ExcalidrawArrowElement["endArrowhead"] | ExcalidrawElbowArrowElement["endArrowhead"]
: never; : never;
elbowed?: boolean; elbowed?: boolean;
fixedSegments?: FixedSegment[] | null;
}): T extends "arrow" | "line" }): T extends "arrow" | "line"
? ExcalidrawLinearElement ? ExcalidrawLinearElement
: T extends "freedraw" : T extends "freedraw"
+4 -4
View File
@@ -2077,15 +2077,15 @@ describe("history", () => {
storeAction: StoreAction.UPDATE, storeAction: StoreAction.UPDATE,
}); });
Keyboard.redo(); Keyboard.undo();
const modifiedArrow = h.elements.filter( const modifiedArrow = h.elements.filter(
(el) => el.type === "arrow", (el) => el.type === "arrow",
)[0] as ExcalidrawElbowArrowElement; )[0] as ExcalidrawElbowArrowElement;
expect(modifiedArrow.points).toEqual([ expect(modifiedArrow.points).toCloselyEqualPoints([
[0, 0], [0, 0],
[451.9000000000001, 0], [178.9, 0],
[451.9000000000001, 448.10100010002003], [178.9, 236.1],
]); ]);
}); });
@@ -5,7 +5,6 @@ import type {
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
FontString, FontString,
SceneElementsMap,
} from "../element/types"; } from "../element/types";
import { Excalidraw, mutateElement } from "../index"; import { Excalidraw, mutateElement } from "../index";
import { reseed } from "../random"; import { reseed } from "../random";
@@ -1353,23 +1352,19 @@ describe("Test Linear Elements", () => {
const [origStartX, origStartY] = [line.x, line.y]; const [origStartX, origStartY] = [line.x, line.y];
act(() => { act(() => {
LinearElementEditor.movePoints( LinearElementEditor.movePoints(line, [
line, {
[ index: 0,
{ point: pointFrom(line.points[0][0] + 10, line.points[0][1] + 10),
index: 0, },
point: pointFrom(line.points[0][0] + 10, line.points[0][1] + 10), {
}, index: line.points.length - 1,
{ point: pointFrom(
index: line.points.length - 1, line.points[line.points.length - 1][0] - 10,
point: pointFrom( line.points[line.points.length - 1][1] - 10,
line.points[line.points.length - 1][0] - 10, ),
line.points[line.points.length - 1][1] - 10, },
), ]);
},
],
new Map() as SceneElementsMap,
);
}); });
expect(line.x).toBe(origStartX + 10); expect(line.x).toBe(origStartX + 10);
expect(line.y).toBe(origStartY + 10); expect(line.y).toBe(origStartY + 10);
+1 -1
View File
@@ -535,7 +535,7 @@ describe("arrow element", () => {
UI.resize([rectangle, arrow], "nw", [300, 350]); UI.resize([rectangle, arrow], "nw", [300, 350]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.144, 2); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.144);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
}); });
}); });
+31
View File
@@ -10,6 +10,7 @@ import { STORAGE_KEYS } from "../../../excalidraw-app/app_constants";
import { getSelectedElements } from "../scene/selection"; import { getSelectedElements } from "../scene/selection";
import type { ExcalidrawElement } from "../element/types"; import type { ExcalidrawElement } from "../element/types";
import { UI } from "./helpers/ui"; import { UI } from "./helpers/ui";
import { diffStringsUnified } from "jest-diff";
const customQueries = { const customQueries = {
...queries, ...queries,
@@ -246,6 +247,36 @@ expect.extend({
pass: false, pass: false,
}; };
}, },
toCloselyEqualPoints(received, expected, precision) {
if (!Array.isArray(received) || !Array.isArray(expected)) {
throw new Error("expected and received are not point arrays");
}
const COMPARE = 1 / Math.pow(10, precision || 2);
const pass = received.every(
(point, idx) =>
Math.abs(expected[idx]?.[0] - point[0]) < COMPARE &&
Math.abs(expected[idx]?.[1] - point[1]) < COMPARE,
);
if (!pass) {
return {
message: () => ` The provided array of points are not close enough.
${diffStringsUnified(
JSON.stringify(expected, undefined, 2),
JSON.stringify(received, undefined, 2),
)}`,
pass: false,
};
}
return {
message: () => `expected ${received} to not be close to ${expected}`,
pass: true,
};
},
}); });
/** /**

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