Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b5d62c8d6 | |||
| 4f74274d04 | |||
| 52eaf64591 | |||
| 7028daa44a | |||
| 65f218b100 | |||
| 807b3c59f2 | |||
| b8da5065fd | |||
| 49f1276ef2 | |||
| 8f20b29b73 | |||
| f87c2cde09 | |||
| 0bf234fcc9 | |||
| dd1b45a25a | |||
| ec06fbc1fc | |||
| fa05ae1230 | |||
| 91ebf8b0ea | |||
| 8551823da9 | |||
| ae6bee3403 | |||
| 46f42ef8d7 | |||
| 00b5b0a0ca | |||
| c92f3bebf5 |
+15
-1
@@ -3,6 +3,20 @@
|
||||
"rules": {
|
||||
"import/no-anonymous-default-export": "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
@@ -90,9 +90,13 @@ import {
|
||||
import { AppMainMenu } from "./components/AppMainMenu";
|
||||
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
|
||||
import { AppFooter } from "./components/AppFooter";
|
||||
import { Provider, useAtom, useAtomValue } from "jotai";
|
||||
import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
|
||||
import { appJotaiStore } from "./app-jotai";
|
||||
import {
|
||||
Provider,
|
||||
useAtom,
|
||||
useAtomValue,
|
||||
useAtomWithInitialValue,
|
||||
appJotaiStore,
|
||||
} from "./app-jotai";
|
||||
|
||||
import "./index.scss";
|
||||
import type { ResolutionType } from "../packages/excalidraw/utility-types";
|
||||
@@ -117,7 +121,7 @@ import {
|
||||
share,
|
||||
youtubeIcon,
|
||||
} from "../packages/excalidraw/components/icons";
|
||||
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
|
||||
import { useHandleAppTheme } from "./useHandleAppTheme";
|
||||
import { getPreferredLanguage } from "./app-language/language-detector";
|
||||
import { useAppLangCode } from "./app-language/language-state";
|
||||
import DebugCanvas, {
|
||||
@@ -328,8 +332,7 @@ const ExcalidrawWrapper = () => {
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const isCollabDisabled = isRunningInIframe();
|
||||
|
||||
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
|
||||
const { editorTheme } = useHandleAppTheme();
|
||||
const { editorTheme, appTheme, setAppTheme } = useHandleAppTheme();
|
||||
|
||||
const [langCode, setLangCode] = useAppLangCode();
|
||||
|
||||
@@ -1141,7 +1144,7 @@ const ExcalidrawApp = () => {
|
||||
|
||||
return (
|
||||
<TopErrorBoundary>
|
||||
<Provider unstable_createStore={() => appJotaiStore}>
|
||||
<Provider store={appJotaiStore}>
|
||||
<ExcalidrawWrapper />
|
||||
</Provider>
|
||||
</TopErrorBoundary>
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
import { useSetAtom } from "jotai";
|
||||
import React from "react";
|
||||
import { useI18n, languages } from "../../packages/excalidraw/i18n";
|
||||
import { useSetAtom } from "../app-jotai";
|
||||
import { appLangCodeAtom } from "./language-state";
|
||||
|
||||
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { atom, useAtom } from "../app-jotai";
|
||||
import { getPreferredLanguage, languageDetector } from "./language-detector";
|
||||
|
||||
export const appLangCodeAtom = atom(getPreferredLanguage());
|
||||
|
||||
@@ -79,8 +79,7 @@ import { newElementWith } from "../../packages/excalidraw/element/mutateElement"
|
||||
import { decryptData } from "../../packages/excalidraw/data/encryption";
|
||||
import { resetBrowserStateVersions } from "../data/tabSync";
|
||||
import { LocalData } from "../data/LocalData";
|
||||
import { atom } from "jotai";
|
||||
import { appJotaiStore } from "../app-jotai";
|
||||
import { appJotaiStore, atom } from "../app-jotai";
|
||||
import type { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
|
||||
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
|
||||
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
|
||||
|
||||
@@ -2,9 +2,9 @@ import { Tooltip } from "../../packages/excalidraw/components/Tooltip";
|
||||
import { warning } from "../../packages/excalidraw/components/icons";
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { atom } from "../app-jotai";
|
||||
|
||||
import "./CollabError.scss";
|
||||
import { atom } from "jotai";
|
||||
|
||||
type ErrorIndicator = {
|
||||
message: string | null;
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"firebase": "8.3.3",
|
||||
"i18next-browser-languagedetector": "6.1.4",
|
||||
"idb-keyval": "6.0.3",
|
||||
"jotai": "1.13.1",
|
||||
"jotai": "2.11.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"socket.io-client": "4.7.2",
|
||||
|
||||
@@ -18,11 +18,11 @@ import { TextField } from "../../packages/excalidraw/components/TextField";
|
||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
||||
import type { CollabAPI } 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 { useCopyStatus } from "../../packages/excalidraw/hooks/useCopiedIndicator";
|
||||
import { atom, useAtom, useAtomValue } from "../app-jotai";
|
||||
|
||||
import "./ShareDialog.scss";
|
||||
|
||||
type OnExportToBackend = () => void;
|
||||
type ShareDialogType = "share" | "collaborationOnly";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useEffect, useLayoutEffect, useState } from "react";
|
||||
import { THEME } from "../packages/excalidraw";
|
||||
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 { 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 =>
|
||||
window.matchMedia?.("(prefers-color-scheme: dark)");
|
||||
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -66,5 +65,5 @@ export const useHandleAppTheme = () => {
|
||||
}
|
||||
}, [appTheme]);
|
||||
|
||||
return { editorTheme };
|
||||
return { editorTheme, appTheme, setAppTheme };
|
||||
};
|
||||
|
||||
@@ -21,10 +21,8 @@ import type { AppClassProperties, AppState, UIAppState } from "../types";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
const alignActionsPredicate = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
export const alignActionsPredicate = (
|
||||
appState: UIAppState,
|
||||
_: unknown,
|
||||
app: AppClassProperties,
|
||||
) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
@@ -48,6 +46,7 @@ const alignSelectedElements = (
|
||||
selectedElements,
|
||||
elementsMap,
|
||||
alignment,
|
||||
app.scene,
|
||||
);
|
||||
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
@@ -64,7 +63,8 @@ export const actionAlignTop = register({
|
||||
label: "labels.alignTop",
|
||||
icon: AlignTopIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
predicate: (elements, appState, appProps, app) =>
|
||||
alignActionsPredicate(appState, app),
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
@@ -79,7 +79,7 @@ export const actionAlignTop = register({
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
hidden={!alignActionsPredicate(appState, app)}
|
||||
type="button"
|
||||
icon={AlignTopIcon}
|
||||
onClick={() => updateData(null)}
|
||||
@@ -97,7 +97,8 @@ export const actionAlignBottom = register({
|
||||
label: "labels.alignBottom",
|
||||
icon: AlignBottomIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
predicate: (elements, appState, appProps, app) =>
|
||||
alignActionsPredicate(appState, app),
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
@@ -112,7 +113,7 @@ export const actionAlignBottom = register({
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
hidden={!alignActionsPredicate(appState, app)}
|
||||
type="button"
|
||||
icon={AlignBottomIcon}
|
||||
onClick={() => updateData(null)}
|
||||
@@ -130,7 +131,8 @@ export const actionAlignLeft = register({
|
||||
label: "labels.alignLeft",
|
||||
icon: AlignLeftIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
predicate: (elements, appState, appProps, app) =>
|
||||
alignActionsPredicate(appState, app),
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
@@ -145,7 +147,7 @@ export const actionAlignLeft = register({
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
hidden={!alignActionsPredicate(appState, app)}
|
||||
type="button"
|
||||
icon={AlignLeftIcon}
|
||||
onClick={() => updateData(null)}
|
||||
@@ -163,7 +165,8 @@ export const actionAlignRight = register({
|
||||
label: "labels.alignRight",
|
||||
icon: AlignRightIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
predicate: (elements, appState, appProps, app) =>
|
||||
alignActionsPredicate(appState, app),
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
@@ -178,7 +181,7 @@ export const actionAlignRight = register({
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
hidden={!alignActionsPredicate(appState, app)}
|
||||
type="button"
|
||||
icon={AlignRightIcon}
|
||||
onClick={() => updateData(null)}
|
||||
@@ -196,7 +199,8 @@ export const actionAlignVerticallyCentered = register({
|
||||
label: "labels.centerVertically",
|
||||
icon: CenterVerticallyIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
predicate: (elements, appState, appProps, app) =>
|
||||
alignActionsPredicate(appState, app),
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
@@ -209,7 +213,7 @@ export const actionAlignVerticallyCentered = register({
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
hidden={!alignActionsPredicate(appState, app)}
|
||||
type="button"
|
||||
icon={CenterVerticallyIcon}
|
||||
onClick={() => updateData(null)}
|
||||
@@ -225,7 +229,8 @@ export const actionAlignHorizontallyCentered = register({
|
||||
label: "labels.centerHorizontally",
|
||||
icon: CenterHorizontallyIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
predicate: (elements, appState, appProps, app) =>
|
||||
alignActionsPredicate(appState, app),
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
@@ -238,7 +243,7 @@ export const actionAlignHorizontallyCentered = register({
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
hidden={!alignActionsPredicate(appState, app)}
|
||||
type="button"
|
||||
icon={CenterHorizontallyIcon}
|
||||
onClick={() => updateData(null)}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getNonDeletedElements } from "../element";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||
import { getElementsInGroup } from "../groups";
|
||||
import { getElementsInGroup, selectGroupsForSelectedElements } from "../groups";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||
import {
|
||||
@@ -18,14 +18,12 @@ import {
|
||||
import { updateActiveTool } from "../utils";
|
||||
import { TrashIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { mutateElbowArrow } from "../element/routing";
|
||||
|
||||
const deleteSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
app: AppClassProperties,
|
||||
) => {
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const framesToBeDeleted = new Set(
|
||||
getSelectedElements(
|
||||
elements.filter((el) => isFrameLikeElement(el)),
|
||||
@@ -33,48 +31,99 @@ const deleteSelectedElements = (
|
||||
).map((el) => el.id),
|
||||
);
|
||||
|
||||
return {
|
||||
elements: elements.map((el) => {
|
||||
if (appState.selectedElementIds[el.id]) {
|
||||
if (el.boundElements) {
|
||||
el.boundElements.forEach((candidate) => {
|
||||
const bound = app.scene
|
||||
.getNonDeletedElementsMap()
|
||||
.get(candidate.id);
|
||||
if (bound && isElbowArrow(bound)) {
|
||||
mutateElement(bound, {
|
||||
startBinding:
|
||||
el.id === bound.startBinding?.elementId
|
||||
? null
|
||||
: bound.startBinding,
|
||||
endBinding:
|
||||
el.id === bound.endBinding?.elementId
|
||||
? null
|
||||
: bound.endBinding,
|
||||
});
|
||||
mutateElbowArrow(bound, elementsMap, bound.points);
|
||||
}
|
||||
});
|
||||
const selectedElementIds: Record<ExcalidrawElement["id"], true> = {};
|
||||
|
||||
let shouldSelectEditingGroup = true;
|
||||
|
||||
const nextElements = elements.map((el) => {
|
||||
if (appState.selectedElementIds[el.id]) {
|
||||
if (el.boundElements) {
|
||||
el.boundElements.forEach((candidate) => {
|
||||
const bound = app.scene.getNonDeletedElementsMap().get(candidate.id);
|
||||
if (bound && isElbowArrow(bound)) {
|
||||
mutateElement(bound, {
|
||||
startBinding:
|
||||
el.id === bound.startBinding?.elementId
|
||||
? null
|
||||
: bound.startBinding,
|
||||
endBinding:
|
||||
el.id === bound.endBinding?.elementId ? null : bound.endBinding,
|
||||
});
|
||||
mutateElement(bound, { points: 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 newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
|
||||
if (
|
||||
isBoundToContainer(el) &&
|
||||
appState.selectedElementIds[el.containerId]
|
||||
) {
|
||||
return newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
return el;
|
||||
}),
|
||||
return {
|
||||
elements: nextElements,
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
...selectGroupsForSelectedElements(
|
||||
{
|
||||
selectedElementIds,
|
||||
editingGroupId: nextEditingGroupId,
|
||||
},
|
||||
nextElements,
|
||||
appState,
|
||||
null,
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -157,12 +206,7 @@ export const actionDeleteSelected = register({
|
||||
: endBindingElement,
|
||||
};
|
||||
|
||||
LinearElementEditor.deletePoints(
|
||||
element,
|
||||
selectedPointsIndices,
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
);
|
||||
LinearElementEditor.deletePoints(element, selectedPointsIndices);
|
||||
|
||||
return {
|
||||
elements,
|
||||
|
||||
@@ -49,12 +49,13 @@ describe("flipping re-centers selection", () => {
|
||||
},
|
||||
startArrowhead: null,
|
||||
endArrowhead: "arrow",
|
||||
fixedSegments: null,
|
||||
points: [
|
||||
pointFrom(0, 0),
|
||||
pointFrom(0, -35),
|
||||
pointFrom(-90.9, -35),
|
||||
pointFrom(-90.9, 204.9),
|
||||
pointFrom(65.1, 204.9),
|
||||
pointFrom(-90, -35),
|
||||
pointFrom(-90, 204),
|
||||
pointFrom(66, 204),
|
||||
],
|
||||
elbowed: true,
|
||||
}),
|
||||
@@ -70,13 +71,13 @@ describe("flipping re-centers selection", () => {
|
||||
API.executeAction(actionFlipHorizontal);
|
||||
API.executeAction(actionFlipHorizontal);
|
||||
|
||||
const rec1 = h.elements.find((el) => el.id === "rec1");
|
||||
expect(rec1?.x).toBeCloseTo(100);
|
||||
expect(rec1?.y).toBeCloseTo(100);
|
||||
const rec1 = h.elements.find((el) => el.id === "rec1")!;
|
||||
expect(rec1.x).toBeCloseTo(100, 0);
|
||||
expect(rec1.y).toBeCloseTo(100, 0);
|
||||
|
||||
const rec2 = h.elements.find((el) => el.id === "rec2");
|
||||
expect(rec2?.x).toBeCloseTo(220);
|
||||
expect(rec2?.y).toBeCloseTo(250);
|
||||
const rec2 = h.elements.find((el) => el.id === "rec2")!;
|
||||
expect(rec2.x).toBeCloseTo(220, 0);
|
||||
expect(rec2.y).toBeCloseTo(250, 0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ import {
|
||||
isElbowArrow,
|
||||
isLinearElement,
|
||||
} from "../element/typeChecks";
|
||||
import { mutateElbowArrow } from "../element/routing";
|
||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||
import { deepCopyElement } from "../element/newElement";
|
||||
import { getCommonBoundingBox } from "../element/bounds";
|
||||
|
||||
export const actionFlipHorizontal = register({
|
||||
@@ -134,12 +134,24 @@ const flipElements = (
|
||||
|
||||
const { midX, midY } = getCommonBoundingBox(selectedElements);
|
||||
|
||||
resizeMultipleElements(selectedElements, elementsMap, "nw", app.scene, {
|
||||
flipByX: flipDirection === "horizontal",
|
||||
flipByY: flipDirection === "vertical",
|
||||
shouldResizeFromCenter: true,
|
||||
shouldMaintainAspectRatio: true,
|
||||
});
|
||||
resizeMultipleElements(
|
||||
selectedElements,
|
||||
elementsMap,
|
||||
"nw",
|
||||
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(
|
||||
selectedElements.filter(isLinearElement),
|
||||
@@ -181,16 +193,10 @@ const flipElements = (
|
||||
}),
|
||||
);
|
||||
elbowArrows.forEach((element) =>
|
||||
mutateElbowArrow(
|
||||
element,
|
||||
elementsMap,
|
||||
element.points,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
informMutation: false,
|
||||
},
|
||||
),
|
||||
mutateElement(element, {
|
||||
x: element.x + diffX,
|
||||
y: element.y + diffY,
|
||||
}),
|
||||
);
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { removeAllElementsFromFrame } from "../frame";
|
||||
import { addElementsToFrame, removeAllElementsFromFrame } from "../frame";
|
||||
import { getFrameChildren } from "../frame";
|
||||
import { KEYS } from "../keys";
|
||||
import type { AppClassProperties, AppState, UIAppState } from "../types";
|
||||
@@ -10,6 +10,10 @@ import { register } from "./register";
|
||||
import { isFrameLikeElement } from "../element/typeChecks";
|
||||
import { frameToolIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { newFrameElement } from "../element/newElement";
|
||||
import { getElementsInGroup } from "../groups";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
|
||||
const isSingleFrameSelected = (
|
||||
appState: UIAppState,
|
||||
@@ -144,3 +148,67 @@ export const actionSetFrameAsActiveTool = register({
|
||||
!event.altKey &&
|
||||
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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -25,8 +25,10 @@ import type {
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
import { isBoundToContainer } from "../element/typeChecks";
|
||||
import {
|
||||
frameAndChildrenSelectedTogether,
|
||||
getElementsInResizingFrame,
|
||||
getFrameLikeElements,
|
||||
getRootElements,
|
||||
groupByFrameLikes,
|
||||
removeElementsFromFrame,
|
||||
replaceAllElementsInFrame,
|
||||
@@ -60,8 +62,11 @@ const enableActionGroup = (
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
});
|
||||
|
||||
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} />,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
});
|
||||
const selectedElements = getRootElements(
|
||||
app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
}),
|
||||
);
|
||||
if (selectedElements.length < 2) {
|
||||
// nothing to group
|
||||
return { appState, elements, storeAction: StoreAction.NONE };
|
||||
|
||||
@@ -89,6 +89,7 @@ import type {
|
||||
FontFamilyValues,
|
||||
TextAlign,
|
||||
VerticalAlign,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "../element/types";
|
||||
import { getLanguage, t } from "../i18n";
|
||||
import { KEYS } from "../keys";
|
||||
@@ -115,11 +116,11 @@ import {
|
||||
bindPointToSnapToElementOutline,
|
||||
calculateFixedPointForElbowArrowBinding,
|
||||
getHoveredElementForBinding,
|
||||
updateBoundElements,
|
||||
} from "../element/binding";
|
||||
import { mutateElbowArrow } from "../element/routing";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import type { LocalPoint } from "../../math";
|
||||
import { pointFrom, vector } from "../../math";
|
||||
import { pointFrom } from "../../math";
|
||||
|
||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||
|
||||
@@ -219,33 +220,47 @@ const changeFontSize = (
|
||||
) => {
|
||||
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 {
|
||||
elements: 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,
|
||||
),
|
||||
elements: updatedElements,
|
||||
appState: {
|
||||
...appState,
|
||||
// update state only if we've set all select text elements to
|
||||
@@ -1560,152 +1575,162 @@ export const actionChangeArrowType = register({
|
||||
label: "Change arrow types",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) => {
|
||||
if (!isArrowElement(el)) {
|
||||
return el;
|
||||
}
|
||||
const newElement = newElementWith(el, {
|
||||
roundness:
|
||||
value === ARROW_TYPE.round
|
||||
? {
|
||||
type: ROUNDNESS.PROPORTIONAL_RADIUS,
|
||||
}
|
||||
: null,
|
||||
elbowed: value === ARROW_TYPE.elbow,
|
||||
points:
|
||||
value === ARROW_TYPE.elbow || el.elbowed
|
||||
? [el.points[0], el.points[el.points.length - 1]]
|
||||
: el.points,
|
||||
const newElements = changeProperty(elements, appState, (el) => {
|
||||
if (!isArrowElement(el)) {
|
||||
return el;
|
||||
}
|
||||
const newElement = newElementWith(el, {
|
||||
roundness:
|
||||
value === ARROW_TYPE.round
|
||||
? {
|
||||
type: ROUNDNESS.PROPORTIONAL_RADIUS,
|
||||
}
|
||||
: null,
|
||||
elbowed: value === ARROW_TYPE.elbow,
|
||||
points:
|
||||
value === ARROW_TYPE.elbow || el.elbowed
|
||||
? [el.points[0], el.points[el.points.length - 1]]
|
||||
: el.points,
|
||||
});
|
||||
|
||||
if (isElbowArrow(newElement)) {
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
|
||||
app.dismissLinearEditor();
|
||||
|
||||
const startGlobalPoint =
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
newElement,
|
||||
0,
|
||||
elementsMap,
|
||||
);
|
||||
const endGlobalPoint =
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
newElement,
|
||||
-1,
|
||||
elementsMap,
|
||||
);
|
||||
const startHoveredElement =
|
||||
!newElement.startBinding &&
|
||||
getHoveredElementForBinding(
|
||||
tupleToCoors(startGlobalPoint),
|
||||
elements,
|
||||
elementsMap,
|
||||
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)) {
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
LinearElementEditor.updateEditorMidPointsCache(
|
||||
newElement,
|
||||
elementsMap,
|
||||
app.state,
|
||||
);
|
||||
}
|
||||
|
||||
app.dismissLinearEditor();
|
||||
return newElement;
|
||||
});
|
||||
|
||||
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,
|
||||
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 newState = {
|
||||
...appState,
|
||||
currentItemArrowType: value,
|
||||
};
|
||||
|
||||
const finalStartPoint = startHoveredElement
|
||||
? bindPointToSnapToElementOutline(
|
||||
startGlobalPoint,
|
||||
endGlobalPoint,
|
||||
startHoveredElement,
|
||||
elementsMap,
|
||||
)
|
||||
: startGlobalPoint;
|
||||
const finalEndPoint = endHoveredElement
|
||||
? bindPointToSnapToElementOutline(
|
||||
endGlobalPoint,
|
||||
startGlobalPoint,
|
||||
endHoveredElement,
|
||||
elementsMap,
|
||||
)
|
||||
: endGlobalPoint;
|
||||
// Change the arrow type and update any other state settings for
|
||||
// the arrow.
|
||||
const selectedId = appState.selectedLinearElement?.elementId;
|
||||
if (selectedId) {
|
||||
const selected = newElements.find((el) => el.id === selectedId);
|
||||
if (selected) {
|
||||
newState.selectedLinearElement = new LinearElementEditor(
|
||||
selected as ExcalidrawLinearElement,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
startHoveredElement &&
|
||||
bindLinearElement(
|
||||
newElement,
|
||||
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,
|
||||
},
|
||||
return {
|
||||
elements: newElements,
|
||||
appState: newState,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -5,7 +5,6 @@ import { getNonDeletedElements, isTextElement } from "../element";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { excludeElementsInFramesFromSelection } from "../scene/selection";
|
||||
import { selectAllIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
@@ -20,17 +19,17 @@ export const actionSelectAll = register({
|
||||
return false;
|
||||
}
|
||||
|
||||
const selectedElementIds = excludeElementsInFramesFromSelection(
|
||||
elements.filter(
|
||||
const selectedElementIds = elements
|
||||
.filter(
|
||||
(element) =>
|
||||
!element.isDeleted &&
|
||||
!(isTextElement(element) && element.containerId) &&
|
||||
!element.locked,
|
||||
),
|
||||
).reduce((map: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
map[element.id] = true;
|
||||
return map;
|
||||
}, {});
|
||||
)
|
||||
.reduce((map: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
map[element.id] = true;
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
appState: {
|
||||
|
||||
@@ -47,6 +47,7 @@ export type ShortcutName =
|
||||
| "saveFileToDisk"
|
||||
| "saveToActiveFile"
|
||||
| "toggleShortcuts"
|
||||
| "wrapSelectionInFrame"
|
||||
>
|
||||
| "saveScene"
|
||||
| "imageExport"
|
||||
@@ -112,6 +113,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
|
||||
toggleShortcuts: [getShortcutKey("?")],
|
||||
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
|
||||
wrapSelectionInFrame: [],
|
||||
};
|
||||
|
||||
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
|
||||
|
||||
@@ -137,7 +137,8 @@ export type ActionName =
|
||||
| "searchMenu"
|
||||
| "copyElementLink"
|
||||
| "linkToElement"
|
||||
| "cropEditor";
|
||||
| "cropEditor"
|
||||
| "wrapSelectionInFrame";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { ElementsMap, ExcalidrawElement } from "./element/types";
|
||||
import { newElementWith } from "./element/mutateElement";
|
||||
import { mutateElement } from "./element/mutateElement";
|
||||
import type { BoundingBox } from "./element/bounds";
|
||||
import { getCommonBoundingBox } from "./element/bounds";
|
||||
import { getMaximumGroups } from "./groups";
|
||||
import { updateBoundElements } from "./element/binding";
|
||||
import type Scene from "./scene/Scene";
|
||||
|
||||
export interface Alignment {
|
||||
position: "start" | "center" | "end";
|
||||
@@ -13,6 +15,7 @@ export const alignElements = (
|
||||
selectedElements: ExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
alignment: Alignment,
|
||||
scene: Scene,
|
||||
): ExcalidrawElement[] => {
|
||||
const groups: ExcalidrawElement[][] = getMaximumGroups(
|
||||
selectedElements,
|
||||
@@ -26,12 +29,18 @@ export const alignElements = (
|
||||
selectionBoundingBox,
|
||||
alignment,
|
||||
);
|
||||
return group.map((element) =>
|
||||
newElementWith(element, {
|
||||
return group.map((element) => {
|
||||
// update element
|
||||
const updatedEle = mutateElement(element, {
|
||||
x: element.x + translation.x,
|
||||
y: element.y + translation.y,
|
||||
}),
|
||||
);
|
||||
});
|
||||
// update bound elements
|
||||
updateBoundElements(element, scene.getNonDeletedElementsMap(), {
|
||||
simultaneouslyUpdated: group,
|
||||
});
|
||||
return updatedEle;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface AnimatedTrailOptions {
|
||||
}
|
||||
|
||||
export class AnimatedTrail implements Trail {
|
||||
private currentTrail?: LaserPointer;
|
||||
currentTrail?: LaserPointer;
|
||||
private pastTrails: LaserPointer[] = [];
|
||||
|
||||
private container?: SVGSVGElement;
|
||||
@@ -28,7 +28,7 @@ export class AnimatedTrail implements Trail {
|
||||
|
||||
constructor(
|
||||
private animationFrameHandler: AnimationFrameHandler,
|
||||
private app: App,
|
||||
protected app: App,
|
||||
private options: Partial<LaserPointerOptions> &
|
||||
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() {
|
||||
this.start();
|
||||
}
|
||||
|
||||
@@ -120,6 +120,7 @@ export const getDefaultAppState = (): Omit<
|
||||
isCropping: false,
|
||||
croppingElementId: null,
|
||||
searchMatches: [],
|
||||
lassoSelectionEnabled: false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -244,6 +245,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
isCropping: { browser: false, export: false, server: false },
|
||||
croppingElementId: { browser: false, export: false, server: false },
|
||||
searchMatches: { browser: false, export: false, server: false },
|
||||
lassoSelectionEnabled: { browser: true, export: false, server: false },
|
||||
});
|
||||
|
||||
const _clearAppStateForStorage = <
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
import { KEYS } from "../keys";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
import { CLASSES } from "../constants";
|
||||
import { alignActionsPredicate } from "../actions/actionAlign";
|
||||
|
||||
export const canChangeStrokeColor = (
|
||||
appState: UIAppState,
|
||||
@@ -90,10 +91,12 @@ export const SelectedShapeActions = ({
|
||||
appState,
|
||||
elementsMap,
|
||||
renderAction,
|
||||
app,
|
||||
}: {
|
||||
appState: UIAppState;
|
||||
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
|
||||
renderAction: ActionManager["renderAction"];
|
||||
app: AppClassProperties;
|
||||
}) => {
|
||||
const targetElements = getTargetElements(elementsMap, appState);
|
||||
|
||||
@@ -133,6 +136,9 @@ export const SelectedShapeActions = ({
|
||||
targetElements.length === 1 &&
|
||||
isImageElement(targetElements[0]);
|
||||
|
||||
const showAlignActions =
|
||||
!isSingleElementBoundContainer && alignActionsPredicate(appState, app);
|
||||
|
||||
return (
|
||||
<div className="panelColumn">
|
||||
<div>
|
||||
@@ -200,7 +206,7 @@ export const SelectedShapeActions = ({
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{targetElements.length > 1 && !isSingleElementBoundContainer && (
|
||||
{showAlignActions && !isSingleElementBoundContainer && (
|
||||
<fieldset>
|
||||
<legend>{t("labels.align")}</legend>
|
||||
<div className="buttonList">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { actionClearCanvas } from "../actions";
|
||||
import { t } from "../i18n";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import { atom, useAtom } from "../editor-jotai";
|
||||
import { useExcalidrawActionManager } from "./App";
|
||||
import ConfirmDialog from "./ConfirmDialog";
|
||||
|
||||
@@ -10,7 +9,6 @@ export const activeConfirmDialogAtom = atom<"clearCanvas" | null>(null);
|
||||
export const ActiveConfirmDialog = () => {
|
||||
const [activeConfirmDialog, setActiveConfirmDialog] = useAtom(
|
||||
activeConfirmDialogAtom,
|
||||
jotaiScope,
|
||||
);
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
|
||||
@@ -91,7 +91,6 @@ import {
|
||||
DEFAULT_REDUCED_GLOBAL_ALPHA,
|
||||
isSafari,
|
||||
type EXPORT_IMAGE_TYPES,
|
||||
DOUBLE_CLICK_POINTERUP_TIMEOUT,
|
||||
} from "../constants";
|
||||
import type { ExportedElements } from "../data";
|
||||
import { exportCanvas, loadFromBlob } from "../data";
|
||||
@@ -166,6 +165,7 @@ import {
|
||||
isTextBindableContainer,
|
||||
isElbowArrow,
|
||||
isFlowchartNodeElement,
|
||||
isBindableElement,
|
||||
} from "../element/typeChecks";
|
||||
import type {
|
||||
ExcalidrawBindableElement,
|
||||
@@ -190,7 +190,6 @@ import type {
|
||||
MagicGenerationData,
|
||||
ExcalidrawNonSelectionElement,
|
||||
ExcalidrawArrowElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "../element/types";
|
||||
import { getCenter, getDistance } from "../gesture";
|
||||
import {
|
||||
@@ -293,7 +292,6 @@ import {
|
||||
getDateTime,
|
||||
isShallowEqual,
|
||||
arrayToMap,
|
||||
toBrandedType,
|
||||
} from "../utils";
|
||||
import {
|
||||
createSrcDoc,
|
||||
@@ -379,9 +377,10 @@ import { actionPaste } from "../actions/actionClipboard";
|
||||
import {
|
||||
actionRemoveAllElementsFromFrame,
|
||||
actionSelectAllElementsInFrame,
|
||||
actionWrapSelectionInFrame,
|
||||
} from "../actions/actionFrame";
|
||||
import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas";
|
||||
import { jotaiStore } from "../jotai";
|
||||
import { editorJotaiStore } from "../editor-jotai";
|
||||
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
||||
import { ImageSceneDataError } from "../errors";
|
||||
import {
|
||||
@@ -443,7 +442,6 @@ import { actionTextAutoResize } from "../actions/actionTextAutoResize";
|
||||
import { getVisibleSceneBounds } from "../element/bounds";
|
||||
import { isMaybeMermaidDefinition } from "../mermaid";
|
||||
import NewElementCanvas from "./canvases/NewElementCanvas";
|
||||
import { mutateElbowArrow, updateElbowArrow } from "../element/routing";
|
||||
import {
|
||||
FlowChartCreator,
|
||||
FlowChartNavigator,
|
||||
@@ -467,6 +465,7 @@ import { cropElement } from "../element/cropElement";
|
||||
import { wrapText } from "../element/textWrapping";
|
||||
import { actionCopyElementLink } from "../actions/actionElementLink";
|
||||
import { isElementLink, parseElementLinkFromURL } from "../element/elementLink";
|
||||
import { LassoTrail } from "../lasso";
|
||||
|
||||
const AppContext = React.createContext<AppClassProperties>(null!);
|
||||
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||
@@ -637,6 +636,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
: "rgba(255, 255, 255, 0.2)",
|
||||
});
|
||||
|
||||
lassoTrail = new LassoTrail(this.animationFrameHandler, this);
|
||||
|
||||
onChangeEmitter = new Emitter<
|
||||
[
|
||||
elements: readonly ExcalidrawElement[],
|
||||
@@ -1609,7 +1610,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
<div className="excalidraw-contextMenuContainer" />
|
||||
<div className="excalidraw-eye-dropper-container" />
|
||||
<SVGLayer
|
||||
trails={[this.laserTrails, this.eraserTrail]}
|
||||
trails={[
|
||||
this.laserTrails,
|
||||
this.eraserTrail,
|
||||
this.lassoTrail,
|
||||
]}
|
||||
/>
|
||||
{selectedElements.length === 1 &&
|
||||
this.state.openDialog?.name !==
|
||||
@@ -2077,7 +2082,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
};
|
||||
|
||||
private openEyeDropper = ({ type }: { type: "stroke" | "background" }) => {
|
||||
jotaiStore.set(activeEyeDropperAtom, {
|
||||
editorJotaiStore.set(activeEyeDropperAtom, {
|
||||
swapPreviewOnAlt: true,
|
||||
colorPickerType:
|
||||
type === "stroke" ? "elementStroke" : "elementBackground",
|
||||
@@ -3184,49 +3189,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
retainSeed?: boolean;
|
||||
fitToContent?: boolean;
|
||||
}) => {
|
||||
let elements = opts.elements.map((el, _, elements) => {
|
||||
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 elements = restoreElements(opts.elements, null, undefined);
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||
|
||||
const elementsCenterX = distance(minX, maxX) / 2;
|
||||
@@ -3279,7 +3242,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
newElements,
|
||||
topLayerFrame,
|
||||
);
|
||||
addElementsToFrame(nextElements, eligibleElements, topLayerFrame);
|
||||
addElementsToFrame(
|
||||
nextElements,
|
||||
eligibleElements,
|
||||
topLayerFrame,
|
||||
this.state,
|
||||
);
|
||||
}
|
||||
|
||||
this.scene.replaceAllElements(nextElements);
|
||||
@@ -3325,7 +3293,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
openSidebar:
|
||||
this.state.openSidebar &&
|
||||
this.device.editor.canFitSidebar &&
|
||||
jotaiStore.get(isSidebarDockedAtom)
|
||||
editorJotaiStore.get(isSidebarDockedAtom)
|
||||
? this.state.openSidebar
|
||||
: null,
|
||||
...selectGroupsForSelectedElements(
|
||||
@@ -4370,14 +4338,17 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
selectedElements.forEach((element) => {
|
||||
mutateElement(element, {
|
||||
x: element.x + offsetX,
|
||||
y: element.y + offsetY,
|
||||
});
|
||||
mutateElement(
|
||||
element,
|
||||
{
|
||||
x: element.x + offsetX,
|
||||
y: element.y + offsetY,
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
updateBoundElements(element, this.scene.getNonDeletedElementsMap(), {
|
||||
simultaneouslyUpdated: selectedElements,
|
||||
zoom: this.state.zoom,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4391,6 +4362,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
),
|
||||
});
|
||||
|
||||
this.scene.triggerUpdate();
|
||||
|
||||
event.preventDefault();
|
||||
} else if (event.key === KEYS.ENTER) {
|
||||
const selectedElements = this.scene.getSelectedElements(this.state);
|
||||
@@ -4549,11 +4522,19 @@ class App extends React.Component<AppProps, AppState> {
|
||||
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 (
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
|
||||
) {
|
||||
jotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
|
||||
editorJotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
|
||||
}
|
||||
|
||||
// eye dropper
|
||||
@@ -4696,7 +4677,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (nextActiveTool.type === "hand") {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
|
||||
} else if (!isHoldingSpace) {
|
||||
setCursorForShape(this.interactiveCanvas, this.state);
|
||||
setCursorForShape(this.interactiveCanvas, {
|
||||
...this.state,
|
||||
activeTool: nextActiveTool,
|
||||
});
|
||||
}
|
||||
if (isToolIcon(document.activeElement)) {
|
||||
this.focusContainer();
|
||||
@@ -5350,14 +5334,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
private handleCanvasDoubleClick = (
|
||||
event: React.MouseEvent<HTMLCanvasElement>,
|
||||
) => {
|
||||
if (
|
||||
this.lastPointerDownEvent &&
|
||||
event.timeStamp - this.lastPointerDownEvent.timeStamp >
|
||||
DOUBLE_CLICK_POINTERUP_TIMEOUT
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// case: double-clicking with arrow/line tool selected would both create
|
||||
// text and enter multiElement mode
|
||||
if (this.state.multiElement) {
|
||||
@@ -5370,6 +5346,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
const selectedElements = this.scene.getSelectedElements(this.state);
|
||||
|
||||
let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
||||
event,
|
||||
this.state,
|
||||
);
|
||||
|
||||
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
|
||||
if (
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
@@ -5383,6 +5364,64 @@ class App extends React.Component<AppProps, AppState> {
|
||||
editingLinearElement: new LinearElementEditor(selectedElements[0]),
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5393,11 +5432,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
resetCursor(this.interactiveCanvas);
|
||||
|
||||
let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
||||
event,
|
||||
this.state,
|
||||
);
|
||||
|
||||
const selectedGroupIds = getSelectedGroupIds(this.state);
|
||||
|
||||
if (selectedGroupIds.length > 0) {
|
||||
@@ -5854,41 +5888,23 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (isPathALoop(points, this.state.zoom.value)) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
|
||||
}
|
||||
if (isElbowArrow(multiElement)) {
|
||||
mutateElbowArrow(
|
||||
multiElement,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
[
|
||||
// update last uncommitted point
|
||||
mutateElement(
|
||||
multiElement,
|
||||
{
|
||||
points: [
|
||||
...points.slice(0, -1),
|
||||
pointFrom<LocalPoint>(
|
||||
lastCommittedX + dxFromLastCommitted,
|
||||
lastCommittedY + dyFromLastCommitted,
|
||||
),
|
||||
],
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
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,
|
||||
);
|
||||
}
|
||||
},
|
||||
false,
|
||||
{
|
||||
isDragging: true,
|
||||
},
|
||||
);
|
||||
|
||||
// in this path, we're mutating multiElement to reflect
|
||||
// how it will be after adding pointer position as the next point
|
||||
@@ -6054,7 +6070,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.setState({
|
||||
activeEmbeddable: { element: hitElement, state: "hover" },
|
||||
});
|
||||
} else {
|
||||
} else if (!hitElement || !isElbowArrow(hitElement)) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
||||
if (this.state.activeEmbeddable?.state === "hover") {
|
||||
this.setState({ activeEmbeddable: null });
|
||||
@@ -6240,14 +6256,18 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
if (hoverPointIndex >= 0 || segmentMidPointHoveredCoords) {
|
||||
const isHoveringAPointHandle = isElbowArrow(element)
|
||||
? hoverPointIndex === 0 ||
|
||||
hoverPointIndex === element.points.length - 1
|
||||
: hoverPointIndex >= 0;
|
||||
if (isHoveringAPointHandle || segmentMidPointHoveredCoords) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
|
||||
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
||||
}
|
||||
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
|
||||
if (
|
||||
// Ebow arrows can only be moved when unconnected
|
||||
!isElbowArrow(element) ||
|
||||
!(element.startBinding || element.endBinding)
|
||||
) {
|
||||
@@ -6288,7 +6308,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
event: React.PointerEvent<HTMLElement>,
|
||||
) => {
|
||||
this.maybeCleanupAfterMissingPointerUp(event.nativeEvent);
|
||||
|
||||
this.maybeUnfollowRemoteUser();
|
||||
|
||||
if (this.state.searchMatches) {
|
||||
@@ -6298,7 +6317,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
focus: false,
|
||||
})),
|
||||
}));
|
||||
jotaiStore.set(searchItemInFocusAtom, null);
|
||||
editorJotaiStore.set(searchItemInFocusAtom, null);
|
||||
}
|
||||
|
||||
// since contextMenu options are potentially evaluated on each render,
|
||||
@@ -6541,6 +6560,15 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state.activeTool.type,
|
||||
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") {
|
||||
setCursorForShape(this.interactiveCanvas, this.state);
|
||||
} else if (
|
||||
@@ -6978,6 +7006,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (
|
||||
selectedElements.length === 1 &&
|
||||
!this.state.editingLinearElement &&
|
||||
!isElbowArrow(selectedElements[0]) &&
|
||||
!(
|
||||
this.state.selectedLinearElement &&
|
||||
this.state.selectedLinearElement.hoverPointIndex !== -1
|
||||
@@ -7679,6 +7708,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
locked: false,
|
||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||
elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow,
|
||||
fixedSegments:
|
||||
this.state.currentItemArrowType === ARROW_TYPE.elbow
|
||||
? []
|
||||
: null,
|
||||
})
|
||||
: newLinearElement({
|
||||
type: elementType,
|
||||
@@ -7919,6 +7952,63 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return;
|
||||
}
|
||||
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 =
|
||||
this.lastPointerMoveCoords ?? pointerDownState.origin;
|
||||
this.lastPointerMoveCoords = pointerCoords;
|
||||
@@ -8265,13 +8355,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
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
|
||||
// able to select and interact with the text input
|
||||
!this.state.editingFrame &&
|
||||
if (!this.state.editingFrame) {
|
||||
dragSelectedElements(
|
||||
pointerDownState,
|
||||
selectedElements,
|
||||
@@ -8280,6 +8368,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
snapOffset,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
|
||||
);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
selectedElementsAreBeingDragged: true,
|
||||
@@ -8396,6 +8485,63 @@ class App extends React.Component<AppProps, AppState> {
|
||||
pointerDownState.lastCoords.x = pointerCoords.x;
|
||||
pointerDownState.lastCoords.y = pointerCoords.y;
|
||||
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 {
|
||||
// It is very important to read this.state within each move event,
|
||||
// otherwise we would read a stale one!
|
||||
@@ -8455,26 +8601,17 @@ class App extends React.Component<AppProps, AppState> {
|
||||
},
|
||||
false,
|
||||
);
|
||||
} else if (points.length > 1 && isElbowArrow(newElement)) {
|
||||
mutateElbowArrow(
|
||||
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) {
|
||||
} else if (
|
||||
points.length === 2 ||
|
||||
(points.length > 1 && isElbowArrow(newElement))
|
||||
) {
|
||||
mutateElement(
|
||||
newElement,
|
||||
{
|
||||
points: [...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
|
||||
},
|
||||
false,
|
||||
{ isDragging: true },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8541,6 +8678,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
elements,
|
||||
this.state.selectionElement,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
false,
|
||||
)
|
||||
: [];
|
||||
|
||||
@@ -8658,6 +8796,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
originSnapOffset: null,
|
||||
}));
|
||||
|
||||
this.lassoTrail.endPath();
|
||||
|
||||
this.lastPointerMoveCoords = null;
|
||||
|
||||
SnapCache.setReferenceSnapPoints(null);
|
||||
@@ -8669,6 +8809,24 @@ class App extends React.Component<AppProps, AppState> {
|
||||
selectedElementsAreBeingDragged: false,
|
||||
});
|
||||
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
|
||||
// and sets binding element
|
||||
if (this.state.editingLinearElement) {
|
||||
@@ -8693,6 +8851,17 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
}
|
||||
} 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 (
|
||||
pointerDownState.hit?.element?.id !==
|
||||
this.state.selectedLinearElement.elementId
|
||||
@@ -8795,6 +8964,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isImageElement(newElement)) {
|
||||
const imageElement = newElement;
|
||||
try {
|
||||
@@ -8940,6 +9110,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scene.getElementsMapIncludingDeleted(),
|
||||
elementsInsideFrame,
|
||||
newElement,
|
||||
this.state,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -9057,6 +9228,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
nextElements,
|
||||
elementsToAdd,
|
||||
topLayerFrame,
|
||||
this.state,
|
||||
);
|
||||
} else if (!topLayerFrame) {
|
||||
if (this.state.editingGroupId) {
|
||||
@@ -9132,10 +9304,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state.selectedLinearElement?.elementId !== hitElement?.id &&
|
||||
isLinearElement(hitElement)
|
||||
) {
|
||||
const selectedELements = this.scene.getSelectedElements(this.state);
|
||||
const selectedElements = this.scene.getSelectedElements(this.state);
|
||||
// set selectedLinearElement when no other element selected except
|
||||
// the one we've hit
|
||||
if (selectedELements.length === 1) {
|
||||
if (selectedElements.length === 1) {
|
||||
this.setState({
|
||||
selectedLinearElement: new LinearElementEditor(hitElement),
|
||||
});
|
||||
@@ -9343,6 +9515,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
if (
|
||||
// not elbow midpoint dragged
|
||||
!(hitElement && isElbowArrow(hitElement)) &&
|
||||
// not dragged
|
||||
!pointerDownState.drag.hasOccurred &&
|
||||
// not resized
|
||||
@@ -10671,8 +10845,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
actionCut,
|
||||
actionCopy,
|
||||
actionPaste,
|
||||
CONTEXT_MENU_SEPARATOR,
|
||||
actionSelectAllElementsInFrame,
|
||||
actionRemoveAllElementsFromFrame,
|
||||
actionWrapSelectionInFrame,
|
||||
CONTEXT_MENU_SEPARATOR,
|
||||
actionToggleCropEditor,
|
||||
CONTEXT_MENU_SEPARATOR,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getColor } from "./ColorPicker";
|
||||
import { useAtom } from "jotai";
|
||||
import type { ColorPickerType } from "./colorPickerUtils";
|
||||
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
||||
import { eyeDropperIcon } from "../icons";
|
||||
import { jotaiScope } from "../../jotai";
|
||||
import { useAtom } from "../../editor-jotai";
|
||||
import { KEYS } from "../../keys";
|
||||
import { activeEyeDropperAtom } from "../EyeDropper";
|
||||
import clsx from "clsx";
|
||||
@@ -57,10 +56,7 @@ export const ColorInput = ({
|
||||
}
|
||||
}, [activeSection]);
|
||||
|
||||
const [eyeDropperState, setEyeDropperState] = useAtom(
|
||||
activeEyeDropperAtom,
|
||||
jotaiScope,
|
||||
);
|
||||
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { TopPicks } from "./TopPicks";
|
||||
import { ButtonSeparator } from "../ButtonSeparator";
|
||||
import { Picker } from "./Picker";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { useAtom } from "jotai";
|
||||
import type { ColorPickerType } from "./colorPickerUtils";
|
||||
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
||||
import { useExcalidrawContainer } from "../App";
|
||||
@@ -15,7 +14,7 @@ import PickerHeading from "./PickerHeading";
|
||||
import { t } from "../../i18n";
|
||||
import clsx from "clsx";
|
||||
import { useRef } from "react";
|
||||
import { jotaiScope } from "../../jotai";
|
||||
import { useAtom } from "../../editor-jotai";
|
||||
import { ColorInput } from "./ColorInput";
|
||||
import { activeEyeDropperAtom } from "../EyeDropper";
|
||||
import { PropertiesPopover } from "../PropertiesPopover";
|
||||
@@ -76,10 +75,7 @@ const ColorPickerPopupContent = ({
|
||||
const { container } = useExcalidrawContainer();
|
||||
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
|
||||
|
||||
const [eyeDropperState, setEyeDropperState] = useAtom(
|
||||
activeEyeDropperAtom,
|
||||
jotaiScope,
|
||||
);
|
||||
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
|
||||
|
||||
const colorInputJSX = (
|
||||
<div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import clsx from "clsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { useAtom } from "../../editor-jotai";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
||||
import HotkeyLabel from "./HotkeyLabel";
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { ExcalidrawElement } from "../../element/types";
|
||||
import { ShadeList } from "./ShadeList";
|
||||
|
||||
import PickerColorList from "./PickerColorList";
|
||||
import { useAtom } from "jotai";
|
||||
import { useAtom } from "../../editor-jotai";
|
||||
import { CustomColorList } from "./CustomColorList";
|
||||
import { colorPickerKeyNavHandler } from "./keyboardNavHandlers";
|
||||
import PickerHeading from "./PickerHeading";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import clsx from "clsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { useAtom } from "../../editor-jotai";
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
activeColorPickerSectionAtom,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import clsx from "clsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { useAtom } from "../../editor-jotai";
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
activeColorPickerSectionAtom,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ExcalidrawElement } from "../../element/types";
|
||||
import { atom } from "jotai";
|
||||
import type { ColorPickerColor, ColorPaletteCustom } from "../../colors";
|
||||
import { MAX_CUSTOM_COLORS_USED_IN_CANVAS } from "../../colors";
|
||||
import { atom } from "../../editor-jotai";
|
||||
|
||||
export const getColorNameAndShadeFromColor = ({
|
||||
palette,
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
getShortcutKey,
|
||||
isWritableElement,
|
||||
} from "../../utils";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { atom, useAtom, editorJotaiStore } from "../../editor-jotai";
|
||||
import { deburr } from "../../deburr";
|
||||
import type { MarkRequired } from "../../utility-types";
|
||||
import { InlineIcon } from "../InlineIcon";
|
||||
@@ -48,7 +48,6 @@ import {
|
||||
actionLink,
|
||||
actionToggleSearchMenu,
|
||||
} from "../../actions";
|
||||
import { jotaiStore } from "../../jotai";
|
||||
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
||||
import type { CommandPaletteItem } from "./types";
|
||||
import * as defaultItems from "./defaultCommandPaletteItems";
|
||||
@@ -263,6 +262,7 @@ function CommandPaletteInner({
|
||||
actionManager.actions.cut,
|
||||
actionManager.actions.copy,
|
||||
actionManager.actions.deleteSelectedElements,
|
||||
actionManager.actions.wrapSelectionInFrame,
|
||||
actionManager.actions.copyStyles,
|
||||
actionManager.actions.pasteStyles,
|
||||
actionManager.actions.bringToFront,
|
||||
@@ -348,7 +348,7 @@ function CommandPaletteInner({
|
||||
keywords: ["delete", "destroy"],
|
||||
viewMode: false,
|
||||
perform: () => {
|
||||
jotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
|
||||
editorJotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5,10 +5,9 @@ import { Dialog } from "./Dialog";
|
||||
|
||||
import "./ConfirmDialog.scss";
|
||||
import DialogActionButton from "./DialogActionButton";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
|
||||
import { useExcalidrawContainer, useExcalidrawSetAppState } from "./App";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import { useSetAtom } from "../editor-jotai";
|
||||
|
||||
interface Props extends Omit<DialogProps, "onCloseRequest"> {
|
||||
onConfirm: () => void;
|
||||
@@ -27,7 +26,7 @@ const ConfirmDialog = (props: Props) => {
|
||||
...rest
|
||||
} = props;
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
|
||||
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
|
||||
const { container } = useExcalidrawContainer();
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,9 +11,8 @@ import "./Dialog.scss";
|
||||
import { Island } from "./Island";
|
||||
import { Modal } from "./Modal";
|
||||
import { queryFocusableElements } from "../utils";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import { useSetAtom } from "../editor-jotai";
|
||||
import { t } from "../i18n";
|
||||
import { CloseIcon } from "./icons";
|
||||
|
||||
@@ -92,7 +91,7 @@ export const Dialog = (props: DialogProps) => {
|
||||
}, [islandNode, props.autofocus]);
|
||||
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
|
||||
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
|
||||
|
||||
const onClose = () => {
|
||||
setAppState({ openMenu: null });
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { atom } from "jotai";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { rgbToHex } from "../colors";
|
||||
@@ -14,6 +13,7 @@ import { useStable } from "../hooks/useStable";
|
||||
import "./EyeDropper.scss";
|
||||
import type { ColorPickerType } from "./ColorPicker/colorPickerUtils";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { atom } from "../editor-jotai";
|
||||
|
||||
export type EyeDropperProperties = {
|
||||
keepOpenOnAlt: boolean;
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import React, { useEffect } from "react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
|
||||
import "./IconPicker.scss";
|
||||
import { isArrowKey, KEYS } from "../keys";
|
||||
import { getLanguage, t } from "../i18n";
|
||||
import clsx from "clsx";
|
||||
import Collapsible from "./Stats/Collapsible";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import { atom, useAtom } from "../editor-jotai";
|
||||
import { useDevice } from "..";
|
||||
|
||||
import "./IconPicker.scss";
|
||||
|
||||
const moreOptionsAtom = atom(false);
|
||||
|
||||
type Option<T> = {
|
||||
@@ -94,10 +93,7 @@ function Picker<T>({
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const [showMoreOptions, setShowMoreOptions] = useAtom(
|
||||
moreOptionsAtom,
|
||||
jotaiScope,
|
||||
);
|
||||
const [showMoreOptions, setShowMoreOptions] = useAtom(moreOptionsAtom);
|
||||
|
||||
const alwaysVisibleOptions = React.useMemo(
|
||||
() => options.slice(0, numberOfOptionsToAlwaysShow),
|
||||
|
||||
@@ -41,8 +41,7 @@ import { trackEvent } from "../analytics";
|
||||
import { useDevice } from "./App";
|
||||
import Footer from "./footer/Footer";
|
||||
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import { Provider, useAtom, useAtomValue } from "jotai";
|
||||
import { useAtom, useAtomValue } from "../editor-jotai";
|
||||
import MainMenu from "./main-menu/MainMenu";
|
||||
import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
|
||||
import { OverwriteConfirmDialog } from "./OverwriteConfirm/OverwriteConfirm";
|
||||
@@ -148,10 +147,9 @@ const LayerUI = ({
|
||||
const device = useDevice();
|
||||
const tunnels = useInitializeTunnels();
|
||||
|
||||
const [eyeDropperState, setEyeDropperState] = useAtom(
|
||||
activeEyeDropperAtom,
|
||||
jotaiScope,
|
||||
);
|
||||
const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider;
|
||||
|
||||
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
|
||||
|
||||
const renderJSONExportDialog = () => {
|
||||
if (!UIOptions.canvasActions.export) {
|
||||
@@ -221,6 +219,7 @@ const LayerUI = ({
|
||||
appState={appState}
|
||||
elementsMap={app.scene.getNonDeletedElementsMap()}
|
||||
renderAction={actionManager.renderAction}
|
||||
app={app}
|
||||
/>
|
||||
</Island>
|
||||
</Section>
|
||||
@@ -382,7 +381,7 @@ const LayerUI = ({
|
||||
);
|
||||
};
|
||||
|
||||
const isSidebarDocked = useAtomValue(isSidebarDockedAtom, jotaiScope);
|
||||
const isSidebarDocked = useAtomValue(isSidebarDockedAtom);
|
||||
|
||||
const layerUIJSX = (
|
||||
<>
|
||||
@@ -566,11 +565,11 @@ const LayerUI = ({
|
||||
|
||||
return (
|
||||
<UIAppStateContext.Provider value={appState}>
|
||||
<Provider scope={tunnels.jotaiScope}>
|
||||
<TunnelsJotaiProvider>
|
||||
<TunnelsContext.Provider value={tunnels}>
|
||||
{layerUIJSX}
|
||||
</TunnelsContext.Provider>
|
||||
</Provider>
|
||||
</TunnelsJotaiProvider>
|
||||
</UIAppStateContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,8 +14,7 @@ import type {
|
||||
} from "../types";
|
||||
import LibraryMenuItems from "./LibraryMenuItems";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import { atom, useAtom } from "../editor-jotai";
|
||||
import Spinner from "./Spinner";
|
||||
import {
|
||||
useApp,
|
||||
@@ -61,7 +60,7 @@ export const LibraryMenuContent = ({
|
||||
selectedItems: LibraryItem["id"][];
|
||||
onSelectItems: (id: LibraryItem["id"][]) => void;
|
||||
}) => {
|
||||
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
||||
const [libraryItemsData] = useAtom(libraryItemsAtom);
|
||||
|
||||
const _onAddToLibrary = useCallback(
|
||||
(elements: LibraryItem["elements"]) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import Trans from "./Trans";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import { useAtom } from "../editor-jotai";
|
||||
import type { LibraryItem, LibraryItems, UIAppState } from "../types";
|
||||
import { useApp, useExcalidrawSetAppState } from "./App";
|
||||
import { saveLibraryAsJSON } from "../data/json";
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { fileOpen } from "../data/filesystem";
|
||||
import { muteFSAbortError } from "../utils";
|
||||
import { useAtom } from "jotai";
|
||||
import ConfirmDialog from "./ConfirmDialog";
|
||||
import PublishLibrary from "./PublishLibrary";
|
||||
import { Dialog } from "./Dialog";
|
||||
@@ -51,10 +50,9 @@ export const LibraryDropdownMenuButton: React.FC<{
|
||||
appState,
|
||||
className,
|
||||
}) => {
|
||||
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
||||
const [libraryItemsData] = useAtom(libraryItemsAtom);
|
||||
const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom(
|
||||
isLibraryMenuOpenAtom,
|
||||
jotaiScope,
|
||||
);
|
||||
|
||||
const renderRemoveLibAlert = () => {
|
||||
@@ -286,7 +284,7 @@ export const LibraryDropdownMenu = ({
|
||||
const appState = useUIAppState();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
|
||||
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
||||
const [libraryItemsData] = useAtom(libraryItemsAtom);
|
||||
|
||||
const removeFromLibrary = async (libraryItems: LibraryItems) => {
|
||||
const nextItems = libraryItems.filter(
|
||||
|
||||
@@ -179,6 +179,7 @@ export const MobileMenu = ({
|
||||
appState={appState}
|
||||
elementsMap={app.scene.getNonDeletedElementsMap()}
|
||||
renderAction={actionManager.renderAction}
|
||||
app={app}
|
||||
/>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React from "react";
|
||||
import { useAtom } from "jotai";
|
||||
|
||||
import { useTunnels } from "../../context/tunnels";
|
||||
import { jotaiScope } from "../../jotai";
|
||||
import { useAtom } from "../../editor-jotai";
|
||||
import { Dialog } from "../Dialog";
|
||||
import { withInternalFallback } from "../hoc/withInternalFallback";
|
||||
import { overwriteConfirmStateAtom } from "./OverwriteConfirmState";
|
||||
@@ -23,7 +22,6 @@ const OverwriteConfirmDialog = Object.assign(
|
||||
const { OverwriteConfirmDialogTunnel } = useTunnels();
|
||||
const [overwriteConfirmState, setState] = useAtom(
|
||||
overwriteConfirmStateAtom,
|
||||
jotaiScope,
|
||||
);
|
||||
|
||||
if (!overwriteConfirmState.active) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { atom } from "jotai";
|
||||
import { jotaiStore } from "../../jotai";
|
||||
import { atom, editorJotaiStore } from "../../editor-jotai";
|
||||
import type React from "react";
|
||||
|
||||
export type OverwriteConfirmState =
|
||||
@@ -32,7 +31,7 @@ export async function openConfirmModal({
|
||||
color: "danger" | "warning";
|
||||
}) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
jotaiStore.set(overwriteConfirmStateAtom, {
|
||||
editorJotaiStore.set(overwriteConfirmStateAtom, {
|
||||
active: true,
|
||||
onConfirm: () => resolve(true),
|
||||
onClose: () => resolve(false),
|
||||
|
||||
@@ -11,8 +11,7 @@ import { measureText } from "../element/textElement";
|
||||
import { addEventListener, getFontString } from "../utils";
|
||||
import { KEYS } from "../keys";
|
||||
import clsx from "clsx";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import { atom, useAtom } from "../editor-jotai";
|
||||
import { t } from "../i18n";
|
||||
import { isElementCompletelyInViewport } from "../element/sizeHelpers";
|
||||
import { randomInteger } from "../random";
|
||||
@@ -58,7 +57,7 @@ export const SearchMenu = () => {
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [inputValue, setInputValue] = useAtom(searchQueryAtom, jotaiScope);
|
||||
const [inputValue, setInputValue] = useAtom(searchQueryAtom);
|
||||
const searchQuery = inputValue.trim() as SearchQuery;
|
||||
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
@@ -70,10 +69,7 @@ export const SearchMenu = () => {
|
||||
const searchedQueryRef = useRef<SearchQuery | null>(null);
|
||||
const lastSceneNonceRef = useRef<number | undefined>(undefined);
|
||||
|
||||
const [focusIndex, setFocusIndex] = useAtom(
|
||||
searchItemInFocusAtom,
|
||||
jotaiScope,
|
||||
);
|
||||
const [focusIndex, setFocusIndex] = useAtom(searchItemInFocusAtom);
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -8,8 +8,7 @@ import React, {
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { Island } from "../Island";
|
||||
import { atom, useSetAtom } from "jotai";
|
||||
import { jotaiScope } from "../../jotai";
|
||||
import { atom, useSetAtom } from "../../editor-jotai";
|
||||
import type { SidebarProps, SidebarPropsContextValue } from "./common";
|
||||
import { SidebarPropsContext } from "./common";
|
||||
import { SidebarHeader } from "./SidebarHeader";
|
||||
@@ -58,7 +57,7 @@ export const SidebarInner = forwardRef(
|
||||
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
|
||||
const setIsSidebarDockedAtom = useSetAtom(isSidebarDockedAtom, jotaiScope);
|
||||
const setIsSidebarDockedAtom = useSetAtom(isSidebarDockedAtom);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setIsSidebarDockedAtom(!!docked);
|
||||
|
||||
@@ -237,6 +237,7 @@ const MultiPosition = ({
|
||||
const [x1, y1] = getCommonBounds(elementsInUnit);
|
||||
return Math.round((property === "x" ? x1 : y1) * 100) / 100;
|
||||
}
|
||||
|
||||
const [el] = elementsInUnit;
|
||||
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 { getUncroppedWidthAndHeight } from "../../element/cropElement";
|
||||
import { round } from "../../../math";
|
||||
import { frameAndChildrenSelectedTogether } from "../../frame";
|
||||
|
||||
interface StatsProps {
|
||||
app: AppClassProperties;
|
||||
@@ -170,6 +171,10 @@ export const StatsInner = memo(
|
||||
return getAtomicUnits(selectedElements, appState);
|
||||
}, [selectedElements, appState]);
|
||||
|
||||
const _frameAndChildrenSelectedTogether = useMemo(() => {
|
||||
return frameAndChildrenSelectedTogether(selectedElements);
|
||||
}, [selectedElements]);
|
||||
|
||||
return (
|
||||
<div className="exc-stats">
|
||||
<Island padding={3}>
|
||||
@@ -226,7 +231,7 @@ export const StatsInner = memo(
|
||||
{renderCustomStats?.(elements, appState)}
|
||||
</Collapsible>
|
||||
|
||||
{selectedElements.length > 0 && (
|
||||
{!_frameAndChildrenSelectedTogether && selectedElements.length > 0 && (
|
||||
<div
|
||||
id="elementStats"
|
||||
style={{
|
||||
|
||||
@@ -25,7 +25,7 @@ import type { BinaryFiles } from "../../types";
|
||||
import { ArrowRightIcon } from "../icons";
|
||||
|
||||
import "./TTDDialog.scss";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { atom, useAtom } from "../../editor-jotai";
|
||||
import { trackEvent } from "../../analytics";
|
||||
import { InlineIcon } from "../InlineIcon";
|
||||
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
|
||||
|
||||
@@ -55,146 +55,152 @@ type ToolButtonProps =
|
||||
onPointerDown?(data: { pointerType: PointerType }): void;
|
||||
});
|
||||
|
||||
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
const { id: excalId } = useExcalidrawContainer();
|
||||
const innerRef = React.useRef(null);
|
||||
React.useImperativeHandle(ref, () => innerRef.current);
|
||||
const sizeCn = `ToolIcon_size_${props.size}`;
|
||||
export const ToolButton = React.forwardRef(
|
||||
(
|
||||
{
|
||||
size = "medium",
|
||||
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 ret = "onClick" in props && props.onClick?.(event);
|
||||
const onClick = async (event: React.MouseEvent) => {
|
||||
const ret = "onClick" in props && props.onClick?.(event);
|
||||
|
||||
if (isPromiseLike(ret)) {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await ret;
|
||||
} catch (error: any) {
|
||||
if (!(error instanceof AbortError)) {
|
||||
throw error;
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
if (isPromiseLike(ret)) {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await ret;
|
||||
} catch (error: any) {
|
||||
if (!(error instanceof AbortError)) {
|
||||
throw error;
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
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 (
|
||||
<button
|
||||
className={clsx(
|
||||
"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}
|
||||
<label
|
||||
className={clsx("ToolIcon", className)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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 });
|
||||
onPointerDown={(event) => {
|
||||
lastPointerTypeRef.current = event.pointerType || null;
|
||||
props.onPointerDown?.({ pointerType: event.pointerType || null });
|
||||
}}
|
||||
checked={props.checked}
|
||||
ref={innerRef}
|
||||
/>
|
||||
<div className="ToolIcon__icon">
|
||||
{props.icon}
|
||||
{props.keyBindingLabel && (
|
||||
<span className="ToolIcon__keybinding">{props.keyBindingLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
});
|
||||
|
||||
ToolButton.defaultProps = {
|
||||
visible: true,
|
||||
className: "",
|
||||
size: "medium",
|
||||
};
|
||||
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}
|
||||
ref={innerRef}
|
||||
/>
|
||||
<div className="ToolIcon__icon">
|
||||
{props.icon}
|
||||
{props.keyBindingLabel && (
|
||||
<span className="ToolIcon__keybinding">
|
||||
{props.keyBindingLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ToolButton.displayName = "ToolButton";
|
||||
|
||||
@@ -4,6 +4,7 @@ import fallbackLangData from "../locales/en.json";
|
||||
|
||||
import Trans from "./Trans";
|
||||
import type { TranslationKeys } from "../i18n";
|
||||
import { EditorJotaiProvider } from "../editor-jotai";
|
||||
|
||||
describe("Test <Trans/>", () => {
|
||||
it("should translate the the strings correctly", () => {
|
||||
@@ -17,7 +18,7 @@ describe("Test <Trans/>", () => {
|
||||
};
|
||||
|
||||
const { getByTestId } = render(
|
||||
<>
|
||||
<EditorJotaiProvider>
|
||||
<div data-testid="test1">
|
||||
<Trans
|
||||
i18nKey={"transTest.key1" as unknown as TranslationKeys}
|
||||
@@ -51,7 +52,7 @@ describe("Test <Trans/>", () => {
|
||||
connect-link={(el) => <a href="https://example.com">{el}</a>}
|
||||
/>
|
||||
</div>
|
||||
</>,
|
||||
</EditorJotaiProvider>,
|
||||
);
|
||||
|
||||
expect(getByTestId("test1").innerHTML).toEqual("Hello world");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { atom, useAtom } from "jotai";
|
||||
import React, { useLayoutEffect, useRef } from "react";
|
||||
import { useTunnels } from "../../context/tunnels";
|
||||
import { atom } from "../../editor-jotai";
|
||||
|
||||
export const withInternalFallback = <P,>(
|
||||
componentName: string,
|
||||
@@ -13,9 +13,11 @@ export const withInternalFallback = <P,>(
|
||||
__fallback?: boolean;
|
||||
}
|
||||
> = (props) => {
|
||||
const { jotaiScope } = useTunnels();
|
||||
const {
|
||||
tunnelsJotai: { useAtom },
|
||||
} = useTunnels();
|
||||
// for rerenders
|
||||
const [, setCounter] = useAtom(renderAtom, jotaiScope);
|
||||
const [, setCounter] = useAtom(renderAtom);
|
||||
// for initial & subsequent renders. Tracked as component state
|
||||
// due to excalidraw multi-instance scanerios.
|
||||
const metaRef = useRef({
|
||||
|
||||
@@ -171,15 +171,17 @@ export const Hyperlink = ({
|
||||
}, [handleSubmit]);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: number | null = null;
|
||||
|
||||
if (
|
||||
inputRef &&
|
||||
inputRef.current &&
|
||||
isEditing &&
|
||||
inputRef?.current &&
|
||||
!(device.viewport.isMobile || device.isTouchScreen)
|
||||
) {
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isEditing, device.viewport.isMobile, device.isTouchScreen]);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: number | null = null;
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
if (isEditing) {
|
||||
@@ -207,15 +209,7 @@ export const Hyperlink = ({
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
appState,
|
||||
element,
|
||||
isEditing,
|
||||
setAppState,
|
||||
elementsMap,
|
||||
device.viewport.isMobile,
|
||||
device.isTouchScreen,
|
||||
]);
|
||||
}, [appState, element, isEditing, setAppState, elementsMap]);
|
||||
|
||||
const handleRemove = useCallback(() => {
|
||||
trackEvent("hyperlink", "delete");
|
||||
|
||||
@@ -32,9 +32,8 @@ import {
|
||||
actionToggleTheme,
|
||||
} from "../../actions";
|
||||
import clsx from "clsx";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
||||
import { jotaiScope } from "../../jotai";
|
||||
import { useSetAtom } from "../../editor-jotai";
|
||||
import { useUIAppState } from "../../context/ui-appState";
|
||||
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
|
||||
import Trans from "../Trans";
|
||||
@@ -189,10 +188,7 @@ Help.displayName = "Help";
|
||||
export const ClearCanvas = () => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const setActiveConfirmDialog = useSetAtom(
|
||||
activeConfirmDialogAtom,
|
||||
jotaiScope,
|
||||
);
|
||||
const setActiveConfirmDialog = useSetAtom(activeConfirmDialogAtom);
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
if (!actionManager.isActionEnabled(actionClearCanvas)) {
|
||||
|
||||
@@ -255,14 +255,6 @@ export const EXPORT_SOURCE =
|
||||
// time in milliseconds
|
||||
export const IMAGE_RENDER_TIMEOUT = 500;
|
||||
export const TAP_TWICE_TIMEOUT = 300;
|
||||
/**
|
||||
* The time the user has from 2nd pointerdown to following pointerup
|
||||
* before it's not considered a double click.
|
||||
*
|
||||
* Helps prevent cases where you double-click by mistake but then drag/keep
|
||||
* the pointer down for to cancel the double click or do another action.
|
||||
*/
|
||||
export const DOUBLE_CLICK_POINTERUP_TIMEOUT = 300;
|
||||
export const TOUCH_CTX_MENU_TIMEOUT = 500;
|
||||
export const TITLE_TIMEOUT = 10000;
|
||||
export const VERSION_TIMEOUT = 30000;
|
||||
@@ -425,6 +417,7 @@ export const LIBRARY_DISABLED_TYPES = new Set([
|
||||
// use these constants to easily identify reference sites
|
||||
export const TOOL_TYPE = {
|
||||
selection: "selection",
|
||||
lassoSelection: "lassoSelection",
|
||||
rectangle: "rectangle",
|
||||
diamond: "diamond",
|
||||
ellipse: "ellipse",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import tunnel from "tunnel-rat";
|
||||
import { createIsolation } from "jotai-scope";
|
||||
|
||||
export type Tunnel = ReturnType<typeof tunnel>;
|
||||
|
||||
@@ -14,13 +15,17 @@ type TunnelsContextValue = {
|
||||
DefaultSidebarTabTriggersTunnel: Tunnel;
|
||||
OverwriteConfirmDialogTunnel: 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 useTunnels = () => React.useContext(TunnelsContext);
|
||||
|
||||
const tunnelsJotai = createIsolation();
|
||||
|
||||
export const useInitializeTunnels = () => {
|
||||
return React.useMemo((): TunnelsContextValue => {
|
||||
return {
|
||||
@@ -34,7 +39,7 @@ export const useInitializeTunnels = () => {
|
||||
DefaultSidebarTabTriggersTunnel: tunnel(),
|
||||
OverwriteConfirmDialogTunnel: tunnel(),
|
||||
TTDDialogTriggerTunnel: tunnel(),
|
||||
jotaiScope: Symbol(),
|
||||
tunnelsJotai,
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
|
||||
@@ -95,7 +95,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 33.519031369643244,
|
||||
"height": 35,
|
||||
"id": Any<String>,
|
||||
"index": "a2",
|
||||
"isDeleted": false,
|
||||
@@ -109,8 +109,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
0.5,
|
||||
],
|
||||
[
|
||||
382.47606040672997,
|
||||
34.019031369643244,
|
||||
394.5,
|
||||
34.5,
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
@@ -128,9 +128,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 7,
|
||||
"version": 4,
|
||||
"versionNonce": Any<Number>,
|
||||
"width": 381.97606040672997,
|
||||
"width": 395,
|
||||
"x": 247,
|
||||
"y": 420,
|
||||
}
|
||||
@@ -167,7 +167,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
0,
|
||||
],
|
||||
[
|
||||
389.5,
|
||||
399.5,
|
||||
0,
|
||||
],
|
||||
],
|
||||
@@ -186,10 +186,10 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 6,
|
||||
"version": 4,
|
||||
"versionNonce": Any<Number>,
|
||||
"width": 390,
|
||||
"x": 237,
|
||||
"width": 400,
|
||||
"x": 227,
|
||||
"y": 450,
|
||||
}
|
||||
`;
|
||||
@@ -319,7 +319,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"verticalAlign": "top",
|
||||
"width": 100,
|
||||
"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": {
|
||||
"elementId": "text-2",
|
||||
"fixedPoint": null,
|
||||
"focus": 1.625925925925924,
|
||||
"focus": 0,
|
||||
"gap": 14,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 18.278619528619487,
|
||||
"height": 0,
|
||||
"id": Any<String>,
|
||||
"index": "a2",
|
||||
"isDeleted": false,
|
||||
@@ -356,11 +356,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"points": [
|
||||
[
|
||||
0.5,
|
||||
-0.5,
|
||||
0,
|
||||
],
|
||||
[
|
||||
357.2037037037038,
|
||||
-17.778619528619487,
|
||||
99.5,
|
||||
0,
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
@@ -378,11 +378,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 6,
|
||||
"version": 4,
|
||||
"versionNonce": Any<Number>,
|
||||
"width": 357.7037037037038,
|
||||
"x": 171,
|
||||
"y": 249.45454545454544,
|
||||
"width": 100,
|
||||
"x": 255,
|
||||
"y": 239,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -482,7 +482,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 6,
|
||||
"version": 4,
|
||||
"versionNonce": Any<Number>,
|
||||
"width": 100,
|
||||
"x": 255,
|
||||
@@ -660,7 +660,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 6,
|
||||
"version": 4,
|
||||
"versionNonce": Any<Number>,
|
||||
"width": 100,
|
||||
"x": 255,
|
||||
@@ -1505,7 +1505,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
0,
|
||||
],
|
||||
[
|
||||
270.98528125,
|
||||
272.485,
|
||||
0,
|
||||
],
|
||||
],
|
||||
@@ -1526,10 +1526,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 7,
|
||||
"version": 4,
|
||||
"versionNonce": Any<Number>,
|
||||
"width": 270.48528125,
|
||||
"x": 112.76171875,
|
||||
"width": 272.985,
|
||||
"x": 111.262,
|
||||
"y": 57,
|
||||
}
|
||||
`;
|
||||
@@ -1587,11 +1587,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 6,
|
||||
"version": 4,
|
||||
"versionNonce": Any<Number>,
|
||||
"width": 0,
|
||||
"x": 83.015625,
|
||||
"y": 81.5,
|
||||
"x": 77.017,
|
||||
"y": 79,
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -8,8 +8,7 @@ import type {
|
||||
} from "../types";
|
||||
import { restoreLibraryItems } from "./restore";
|
||||
import type App from "../components/App";
|
||||
import { atom } from "jotai";
|
||||
import { jotaiStore } from "../jotai";
|
||||
import { atom, editorJotaiStore } from "../editor-jotai";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { getCommonBoundingBox } from "../element/bounds";
|
||||
import { AbortError } from "../errors";
|
||||
@@ -37,7 +36,18 @@ import { Queue } from "../queue";
|
||||
import { hashElementsVersion, hashString } from "../element";
|
||||
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 = {
|
||||
/** deleted library items since last onLibraryChange event */
|
||||
@@ -191,13 +201,13 @@ class Library {
|
||||
|
||||
private notifyListeners = () => {
|
||||
if (this.updateQueue.length > 0) {
|
||||
jotaiStore.set(libraryItemsAtom, (s) => ({
|
||||
editorJotaiStore.set(libraryItemsAtom, (s) => ({
|
||||
status: "loading",
|
||||
libraryItems: this.currLibraryItems,
|
||||
isInitialized: s.isInitialized,
|
||||
}));
|
||||
} else {
|
||||
jotaiStore.set(libraryItemsAtom, {
|
||||
editorJotaiStore.set(libraryItemsAtom, {
|
||||
status: "loaded",
|
||||
libraryItems: this.currLibraryItems,
|
||||
isInitialized: true,
|
||||
@@ -225,7 +235,7 @@ class Library {
|
||||
destroy = () => {
|
||||
this.updateQueue = [];
|
||||
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
|
||||
// jotaiStore.set(libraryItemsAtom, {
|
||||
// status: "loading",
|
||||
@@ -470,26 +480,37 @@ export const distributeLibraryItemsOnSquareGrid = (
|
||||
return resElements;
|
||||
};
|
||||
|
||||
const validateLibraryUrl = (
|
||||
export const validateLibraryUrl = (
|
||||
libraryUrl: string,
|
||||
/**
|
||||
* If supplied, takes precedence over the default whitelist.
|
||||
* Return `true` if the URL is valid.
|
||||
* @returns `true` if the URL is valid, throws otherwise.
|
||||
*/
|
||||
validator?: (libraryUrl: string) => boolean,
|
||||
): boolean => {
|
||||
validator:
|
||||
| ((libraryUrl: string) => boolean)
|
||||
| string[] = ALLOWED_LIBRARY_URLS,
|
||||
): true => {
|
||||
if (
|
||||
validator
|
||||
typeof validator === "function"
|
||||
? validator(libraryUrl)
|
||||
: ALLOWED_LIBRARY_HOSTNAMES.includes(
|
||||
new URL(libraryUrl).hostname.split(".").slice(-2).join("."),
|
||||
)
|
||||
: validator.some((allowedUrlDef) => {
|
||||
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;
|
||||
}
|
||||
|
||||
console.error(`Invalid or disallowed library URL: "${libraryUrl}"`);
|
||||
throw new Error("Invalid or disallowed library URL");
|
||||
throw new Error(`Invalid or disallowed library URL: "${libraryUrl}"`);
|
||||
};
|
||||
|
||||
export const parseLibraryTokensFromUrl = () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawElementType,
|
||||
ExcalidrawLinearElement,
|
||||
@@ -69,6 +70,7 @@ export const AllowedExcalidrawActiveTools: Record<
|
||||
boolean
|
||||
> = {
|
||||
selection: true,
|
||||
lassoSelection: true,
|
||||
text: true,
|
||||
rectangle: true,
|
||||
diamond: true,
|
||||
@@ -101,23 +103,38 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
|
||||
return DEFAULT_FONT_FAMILY;
|
||||
};
|
||||
|
||||
const repairBinding = (
|
||||
element: ExcalidrawLinearElement,
|
||||
const repairBinding = <T extends ExcalidrawLinearElement>(
|
||||
element: T,
|
||||
binding: PointBinding | FixedPointBinding | null,
|
||||
): PointBinding | FixedPointBinding | null => {
|
||||
): T extends ExcalidrawElbowArrowElement
|
||||
? FixedPointBinding | null
|
||||
: PointBinding | FixedPointBinding | null => {
|
||||
if (!binding) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...binding,
|
||||
focus: binding.focus || 0,
|
||||
...(isElbowArrow(element) && isFixedPointBinding(binding)
|
||||
const focus = binding.focus || 0;
|
||||
|
||||
if (isElbowArrow(element)) {
|
||||
const fixedPointBinding:
|
||||
| ExcalidrawElbowArrowElement["startBinding"]
|
||||
| ExcalidrawElbowArrowElement["endBinding"] = isFixedPointBinding(binding)
|
||||
? {
|
||||
...binding,
|
||||
focus,
|
||||
fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
: null;
|
||||
|
||||
return fixedPointBinding;
|
||||
}
|
||||
|
||||
return {
|
||||
...binding,
|
||||
focus,
|
||||
} as T extends ExcalidrawElbowArrowElement
|
||||
? FixedPointBinding | null
|
||||
: PointBinding | FixedPointBinding | null;
|
||||
};
|
||||
|
||||
const restoreElementWithProperties = <
|
||||
@@ -308,8 +325,7 @@ const restoreElement = (
|
||||
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
|
||||
}
|
||||
|
||||
// TODO: Separate arrow from linear element
|
||||
return restoreElementWithProperties(element as ExcalidrawArrowElement, {
|
||||
const base = {
|
||||
type: element.type,
|
||||
startBinding: repairBinding(element, element.startBinding),
|
||||
endBinding: repairBinding(element, element.endBinding),
|
||||
@@ -321,7 +337,20 @@ const restoreElement = (
|
||||
y,
|
||||
elbowed: (element as ExcalidrawArrowElement).elbowed,
|
||||
...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
|
||||
|
||||
@@ -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();
|
||||
@@ -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
|
||||
@@ -629,11 +623,9 @@ export const updateBoundElements = (
|
||||
simultaneouslyUpdated?: readonly ExcalidrawElement[];
|
||||
newSize?: { width: number; height: number };
|
||||
changedElements?: Map<string, OrderedExcalidrawElement>;
|
||||
zoom?: AppState["zoom"];
|
||||
},
|
||||
) => {
|
||||
const { newSize, simultaneouslyUpdated, changedElements, zoom } =
|
||||
options ?? {};
|
||||
const { newSize, simultaneouslyUpdated } = options ?? {};
|
||||
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
|
||||
simultaneouslyUpdated,
|
||||
);
|
||||
@@ -667,7 +659,7 @@ export const updateBoundElements = (
|
||||
|
||||
// `linearElement` is being moved/scaled already, just update the binding
|
||||
if (simultaneouslyUpdatedElementIds.has(element.id)) {
|
||||
mutateElement(element, bindings);
|
||||
mutateElement(element, bindings, true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -709,23 +701,14 @@ export const updateBoundElements = (
|
||||
}> => update !== null,
|
||||
);
|
||||
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
updates,
|
||||
elementsMap,
|
||||
{
|
||||
...(changedElement.id === element.startBinding?.elementId
|
||||
? { startBinding: bindings.startBinding }
|
||||
: {}),
|
||||
...(changedElement.id === element.endBinding?.elementId
|
||||
? { endBinding: bindings.endBinding }
|
||||
: {}),
|
||||
},
|
||||
{
|
||||
changedElements,
|
||||
zoom,
|
||||
},
|
||||
);
|
||||
LinearElementEditor.movePoints(element, updates, {
|
||||
...(changedElement.id === element.startBinding?.elementId
|
||||
? { startBinding: bindings.startBinding }
|
||||
: {}),
|
||||
...(changedElement.id === element.endBinding?.elementId
|
||||
? { endBinding: bindings.endBinding }
|
||||
: {}),
|
||||
});
|
||||
|
||||
const boundText = getBoundTextElement(element, elementsMap);
|
||||
if (boundText && !boundText.isDeleted) {
|
||||
@@ -784,9 +767,7 @@ export const getHeadingForElbowArrowSnap = (
|
||||
);
|
||||
}
|
||||
|
||||
const pointHeading = headingForPointFromElement(bindableElement, aabb, p);
|
||||
|
||||
return pointHeading;
|
||||
return headingForPointFromElement(bindableElement, aabb, p);
|
||||
};
|
||||
|
||||
const getDistanceForBinding = (
|
||||
@@ -2289,7 +2270,7 @@ export const getGlobalFixedPointForBindableElement = (
|
||||
);
|
||||
};
|
||||
|
||||
const getGlobalFixedPoints = (
|
||||
export const getGlobalFixedPoints = (
|
||||
arrow: ExcalidrawElbowArrowElement,
|
||||
elementsMap: ElementsMap,
|
||||
): [GlobalPoint, GlobalPoint] => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { generateRoughOptions } from "../scene/Shape";
|
||||
import {
|
||||
isArrowElement,
|
||||
isBoundToContainer,
|
||||
isFrameLikeElement,
|
||||
isFreeDrawElement,
|
||||
isLinearElement,
|
||||
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 [
|
||||
lineSegment(nw, ne),
|
||||
lineSegment(sw, se),
|
||||
|
||||
@@ -42,9 +42,20 @@ export const dragSelectedElements = (
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedElements = _selectedElements.filter(
|
||||
(el) => !(isElbowArrow(el) && el.startBinding && el.endBinding),
|
||||
);
|
||||
const selectedElements = _selectedElements.filter((element) => {
|
||||
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
|
||||
// but when it happens (due to some bug), we want to avoid updating element
|
||||
@@ -78,10 +89,8 @@ export const dragSelectedElements = (
|
||||
|
||||
elementsToUpdate.forEach((element) => {
|
||||
updateElementCoords(pointerDownState, element, adjustedOffset);
|
||||
if (
|
||||
if (!isArrowElement(element)) {
|
||||
// skip arrow labels since we calculate its position during render
|
||||
!isArrowElement(element)
|
||||
) {
|
||||
const textElement = getBoundTextElement(
|
||||
element,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
@@ -89,10 +98,10 @@ export const dragSelectedElements = (
|
||||
if (textElement) {
|
||||
updateElementCoords(pointerDownState, textElement, adjustedOffset);
|
||||
}
|
||||
updateBoundElements(element, scene.getElementsMapIncludingDeleted(), {
|
||||
simultaneouslyUpdated: Array.from(elementsToUpdate),
|
||||
});
|
||||
}
|
||||
updateBoundElements(element, scene.getElementsMapIncludingDeleted(), {
|
||||
simultaneouslyUpdated: Array.from(elementsToUpdate),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+112
-9
@@ -9,20 +9,121 @@ import {
|
||||
render,
|
||||
} from "../tests/test-utils";
|
||||
import { bindLinearElement } from "./binding";
|
||||
import { Excalidraw } from "../index";
|
||||
import { mutateElbowArrow } from "./routing";
|
||||
import { Excalidraw, mutateElement } from "../index";
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
} from "./types";
|
||||
import { ARROW_TYPE } from "../constants";
|
||||
import type { LocalPoint } from "../../math";
|
||||
import { pointFrom } from "../../math";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
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", () => {
|
||||
it("can properly generate orthogonal arrow points", () => {
|
||||
const scene = new Scene();
|
||||
@@ -31,10 +132,12 @@ describe("elbow arrow routing", () => {
|
||||
elbowed: true,
|
||||
}) as ExcalidrawElbowArrowElement;
|
||||
scene.insertElement(arrow);
|
||||
mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [
|
||||
pointFrom(-45 - arrow.x, -100.1 - arrow.y),
|
||||
pointFrom(45 - arrow.x, 99.9 - arrow.y),
|
||||
]);
|
||||
mutateElement(arrow, {
|
||||
points: [
|
||||
pointFrom<LocalPoint>(-45 - arrow.x, -100.1 - arrow.y),
|
||||
pointFrom<LocalPoint>(45 - arrow.x, 99.9 - arrow.y),
|
||||
],
|
||||
});
|
||||
expect(arrow.points).toEqual([
|
||||
[0, 0],
|
||||
[0, 100],
|
||||
@@ -81,7 +184,9 @@ describe("elbow arrow routing", () => {
|
||||
expect(arrow.startBinding).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([
|
||||
[0, 0],
|
||||
@@ -182,8 +287,6 @@ describe("elbow arrow ui", () => {
|
||||
expect(arrow.points.map((point) => point.map(Math.round))).toEqual([
|
||||
[0, 0],
|
||||
[35, 0],
|
||||
[35, 90],
|
||||
[35, 90], // Note that coordinates are rounded above!
|
||||
[35, 165],
|
||||
[103, 165],
|
||||
]);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -254,6 +254,9 @@ const addNewNode = (
|
||||
backgroundColor: element.backgroundColor,
|
||||
strokeColor: element.strokeColor,
|
||||
strokeWidth: element.strokeWidth,
|
||||
opacity: element.opacity,
|
||||
fillStyle: element.fillStyle,
|
||||
strokeStyle: element.strokeStyle,
|
||||
});
|
||||
|
||||
invariant(
|
||||
@@ -329,6 +332,9 @@ export const addNewNodes = (
|
||||
backgroundColor: startNode.backgroundColor,
|
||||
strokeColor: startNode.strokeColor,
|
||||
strokeWidth: startNode.strokeWidth,
|
||||
opacity: startNode.opacity,
|
||||
fillStyle: startNode.fillStyle,
|
||||
strokeStyle: startNode.strokeStyle,
|
||||
});
|
||||
|
||||
invariant(
|
||||
@@ -416,11 +422,13 @@ const createBindingArrow = (
|
||||
type: "arrow",
|
||||
x: startX,
|
||||
y: startY,
|
||||
startArrowhead: appState.currentItemStartArrowhead,
|
||||
startArrowhead: null,
|
||||
endArrowhead: appState.currentItemEndArrowhead,
|
||||
strokeColor: appState.currentItemStrokeColor,
|
||||
strokeStyle: appState.currentItemStrokeStyle,
|
||||
strokeWidth: appState.currentItemStrokeWidth,
|
||||
strokeColor: startBindingElement.strokeColor,
|
||||
strokeStyle: startBindingElement.strokeStyle,
|
||||
strokeWidth: startBindingElement.strokeWidth,
|
||||
opacity: startBindingElement.opacity,
|
||||
roughness: startBindingElement.roughness,
|
||||
points: [pointFrom(0, 0), pointFrom(endX, endY)],
|
||||
elbowed: true,
|
||||
});
|
||||
@@ -452,20 +460,12 @@ const createBindingArrow = (
|
||||
bindingArrow as OrderedExcalidrawElement,
|
||||
);
|
||||
|
||||
LinearElementEditor.movePoints(
|
||||
bindingArrow,
|
||||
[
|
||||
{
|
||||
index: 1,
|
||||
point: bindingArrow.points[1],
|
||||
},
|
||||
],
|
||||
elementsMap as NonDeletedSceneElementsMap,
|
||||
undefined,
|
||||
LinearElementEditor.movePoints(bindingArrow, [
|
||||
{
|
||||
changedElements,
|
||||
index: 1,
|
||||
point: bindingArrow.points[1],
|
||||
},
|
||||
);
|
||||
]);
|
||||
|
||||
return bindingArrow;
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
pointScaleFromOrigin,
|
||||
radiansToDegrees,
|
||||
triangleIncludesPoint,
|
||||
vectorFromPoint,
|
||||
} from "../../math";
|
||||
import { getCenterForBounds, type Bounds } from "./bounds";
|
||||
import type { ExcalidrawBindableElement } from "./types";
|
||||
@@ -52,9 +53,24 @@ export const vectorToHeading = (vec: Vector): Heading => {
|
||||
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) =>
|
||||
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
|
||||
// close fitting bounding box, then creating 4 search cones around the center of
|
||||
// the external bbox.
|
||||
@@ -63,7 +79,7 @@ export const headingForPointFromElement = <
|
||||
>(
|
||||
element: Readonly<ExcalidrawBindableElement>,
|
||||
aabb: Readonly<Bounds>,
|
||||
p: Readonly<LocalPoint | GlobalPoint>,
|
||||
p: Readonly<Point>,
|
||||
): Heading => {
|
||||
const SEARCH_CONE_MULTIPLIER = 2;
|
||||
|
||||
@@ -117,14 +133,22 @@ export const headingForPointFromElement = <
|
||||
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);
|
||||
} else if (
|
||||
triangleIncludesPoint([right, bottom, midPoint] as Triangle<Point>, p)
|
||||
triangleIncludesPoint<Point>(
|
||||
[right, bottom, midPoint] as Triangle<Point>,
|
||||
p,
|
||||
)
|
||||
) {
|
||||
return headingForDiamond(right, bottom);
|
||||
} else if (
|
||||
triangleIncludesPoint([bottom, left, midPoint] as Triangle<Point>, p)
|
||||
triangleIncludesPoint<Point>(
|
||||
[bottom, left, midPoint] as Triangle<Point>,
|
||||
p,
|
||||
)
|
||||
) {
|
||||
return headingForDiamond(bottom, left);
|
||||
}
|
||||
@@ -153,17 +177,17 @@ export const headingForPointFromElement = <
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
) as Point;
|
||||
|
||||
return triangleIncludesPoint(
|
||||
return triangleIncludesPoint<Point>(
|
||||
[topLeft, topRight, midPoint] as Triangle<Point>,
|
||||
p,
|
||||
)
|
||||
? HEADING_UP
|
||||
: triangleIncludesPoint(
|
||||
: triangleIncludesPoint<Point>(
|
||||
[topRight, bottomRight, midPoint] as Triangle<Point>,
|
||||
p,
|
||||
)
|
||||
? HEADING_RIGHT
|
||||
: triangleIncludesPoint(
|
||||
: triangleIncludesPoint<Point>(
|
||||
[bottomRight, bottomLeft, midPoint] as Triangle<Point>,
|
||||
p,
|
||||
)
|
||||
|
||||
@@ -7,9 +7,10 @@ import type {
|
||||
ExcalidrawTextElementWithContainer,
|
||||
ElementsMap,
|
||||
NonDeletedSceneElementsMap,
|
||||
OrderedExcalidrawElement,
|
||||
FixedPointBinding,
|
||||
SceneElementsMap,
|
||||
FixedSegment,
|
||||
ExcalidrawElbowArrowElement,
|
||||
} from "./types";
|
||||
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
|
||||
import type { Bounds } from "./bounds";
|
||||
@@ -24,6 +25,7 @@ import type {
|
||||
InteractiveCanvasAppState,
|
||||
AppClassProperties,
|
||||
NullableGridSize,
|
||||
Zoom,
|
||||
} from "../types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
|
||||
@@ -32,7 +34,7 @@ import {
|
||||
getHoveredElementForBinding,
|
||||
isBindingEnabled,
|
||||
} from "./binding";
|
||||
import { invariant, toBrandedType, tupleToCoors } from "../utils";
|
||||
import { invariant, tupleToCoors } from "../utils";
|
||||
import {
|
||||
isBindingElement,
|
||||
isElbowArrow,
|
||||
@@ -44,7 +46,6 @@ import { DRAGGING_THRESHOLD } from "../constants";
|
||||
import type { Mutable } from "../utility-types";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import type { Store } from "../store";
|
||||
import { mutateElbowArrow } from "./routing";
|
||||
import type Scene from "../scene/Scene";
|
||||
import type { Radians } from "../../math";
|
||||
import {
|
||||
@@ -56,6 +57,8 @@ import {
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
pointDistance,
|
||||
pointTranslate,
|
||||
vectorFromPoint,
|
||||
} from "../../math";
|
||||
import {
|
||||
getBezierCurveLength,
|
||||
@@ -65,6 +68,7 @@ import {
|
||||
mapIntervalToBezierT,
|
||||
} from "../shapes";
|
||||
import { getGridPoint } from "../snapping";
|
||||
import { headingIsHorizontal, vectorToHeading } from "./heading";
|
||||
|
||||
const editorMidPointsCache: {
|
||||
version: number | null;
|
||||
@@ -144,13 +148,13 @@ export class LinearElementEditor {
|
||||
* @param id the `elementId` from the instance of this class (so that we can
|
||||
* statically guarantee this method returns an ExcalidrawLinearElement)
|
||||
*/
|
||||
static getElement(
|
||||
static getElement<T extends ExcalidrawLinearElement>(
|
||||
id: InstanceType<typeof LinearElementEditor>["elementId"],
|
||||
elementsMap: ElementsMap,
|
||||
) {
|
||||
): T | null {
|
||||
const element = elementsMap.get(id);
|
||||
if (element) {
|
||||
return element as NonDeleted<ExcalidrawLinearElement>;
|
||||
return element as NonDeleted<T>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -291,20 +295,16 @@ export class LinearElementEditor {
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
);
|
||||
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
[
|
||||
{
|
||||
index: selectedIndex,
|
||||
point: pointFrom(
|
||||
width + referencePoint[0],
|
||||
height + referencePoint[1],
|
||||
),
|
||||
isDragging: selectedIndex === lastClickedPoint,
|
||||
},
|
||||
],
|
||||
elementsMap,
|
||||
);
|
||||
LinearElementEditor.movePoints(element, [
|
||||
{
|
||||
index: selectedIndex,
|
||||
point: pointFrom(
|
||||
width + referencePoint[0],
|
||||
height + referencePoint[1],
|
||||
),
|
||||
isDragging: selectedIndex === lastClickedPoint,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
const newDraggingPointPosition = LinearElementEditor.createPointAt(
|
||||
element,
|
||||
@@ -339,7 +339,6 @@ export class LinearElementEditor {
|
||||
isDragging: pointIndex === lastClickedPoint,
|
||||
};
|
||||
}),
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -422,19 +421,15 @@ export class LinearElementEditor {
|
||||
selectedPoint === element.points.length - 1
|
||||
) {
|
||||
if (isPathALoop(element.points, appState.zoom.value)) {
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
[
|
||||
{
|
||||
index: selectedPoint,
|
||||
point:
|
||||
selectedPoint === 0
|
||||
? element.points[element.points.length - 1]
|
||||
: element.points[0],
|
||||
},
|
||||
],
|
||||
elementsMap,
|
||||
);
|
||||
LinearElementEditor.movePoints(element, [
|
||||
{
|
||||
index: selectedPoint,
|
||||
point:
|
||||
selectedPoint === 0
|
||||
? element.points[element.points.length - 1]
|
||||
: element.points[0],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
const bindingElement = isBindingEnabled(appState)
|
||||
@@ -495,6 +490,7 @@ export class LinearElementEditor {
|
||||
|
||||
// Since its not needed outside editor unless 2 pointer lines or bound text
|
||||
if (
|
||||
!isElbowArrow(element) &&
|
||||
!appState.editingLinearElement &&
|
||||
element.points.length > 2 &&
|
||||
!boundText
|
||||
@@ -533,6 +529,7 @@ export class LinearElementEditor {
|
||||
element,
|
||||
element.points[index],
|
||||
element.points[index + 1],
|
||||
index,
|
||||
appState.zoom,
|
||||
)
|
||||
) {
|
||||
@@ -573,19 +570,23 @@ export class LinearElementEditor {
|
||||
scenePointer.x,
|
||||
scenePointer.y,
|
||||
);
|
||||
if (clickedPointIndex >= 0) {
|
||||
if (!isElbowArrow(element) && clickedPointIndex >= 0) {
|
||||
return null;
|
||||
}
|
||||
const points = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
if (points.length >= 3 && !appState.editingLinearElement) {
|
||||
if (
|
||||
points.length >= 3 &&
|
||||
!appState.editingLinearElement &&
|
||||
!isElbowArrow(element)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const threshold =
|
||||
LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value;
|
||||
(LinearElementEditor.POINT_HANDLE_SIZE + 1) / appState.zoom.value;
|
||||
|
||||
const existingSegmentMidpointHitCoords =
|
||||
linearElementEditor.segmentMidPointHoveredCoords;
|
||||
@@ -604,10 +605,11 @@ export class LinearElementEditor {
|
||||
let index = 0;
|
||||
const midPoints: typeof editorMidPointsCache["points"] =
|
||||
LinearElementEditor.getEditorMidPoints(element, elementsMap, appState);
|
||||
|
||||
while (index < midPoints.length) {
|
||||
if (midPoints[index] !== null) {
|
||||
const distance = pointDistance(
|
||||
pointFrom(midPoints[index]![0], midPoints[index]![1]),
|
||||
midPoints[index]!,
|
||||
pointFrom(scenePointer.x, scenePointer.y),
|
||||
);
|
||||
if (distance <= threshold) {
|
||||
@@ -620,16 +622,25 @@ export class LinearElementEditor {
|
||||
return null;
|
||||
};
|
||||
|
||||
static isSegmentTooShort(
|
||||
static isSegmentTooShort<P extends GlobalPoint | LocalPoint>(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
startPoint: GlobalPoint | LocalPoint,
|
||||
endPoint: GlobalPoint | LocalPoint,
|
||||
zoom: AppState["zoom"],
|
||||
startPoint: P,
|
||||
endPoint: P,
|
||||
index: number,
|
||||
zoom: Zoom,
|
||||
) {
|
||||
let distance = pointDistance(
|
||||
pointFrom(startPoint[0], startPoint[1]),
|
||||
pointFrom(endPoint[0], endPoint[1]),
|
||||
);
|
||||
if (isElbowArrow(element)) {
|
||||
if (index >= 0 && index < element.points.length) {
|
||||
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) {
|
||||
distance = getBezierCurveLength(element, endPoint);
|
||||
}
|
||||
@@ -748,12 +759,8 @@ export class LinearElementEditor {
|
||||
segmentMidpoint,
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
if (event.altKey && appState.editingLinearElement) {
|
||||
if (
|
||||
linearElementEditor.lastUncommittedPoint == null &&
|
||||
!isElbowArrow(element)
|
||||
) {
|
||||
} else if (event.altKey && appState.editingLinearElement) {
|
||||
if (linearElementEditor.lastUncommittedPoint == null) {
|
||||
mutateElement(element, {
|
||||
points: [
|
||||
...element.points,
|
||||
@@ -909,12 +916,7 @@ export class LinearElementEditor {
|
||||
|
||||
if (!event.altKey) {
|
||||
if (lastPoint === lastUncommittedPoint) {
|
||||
LinearElementEditor.deletePoints(
|
||||
element,
|
||||
[points.length - 1],
|
||||
elementsMap,
|
||||
app.state.zoom,
|
||||
);
|
||||
LinearElementEditor.deletePoints(element, [points.length - 1]);
|
||||
}
|
||||
return {
|
||||
...appState.editingLinearElement,
|
||||
@@ -952,23 +954,14 @@ export class LinearElementEditor {
|
||||
}
|
||||
|
||||
if (lastPoint === lastUncommittedPoint) {
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
[
|
||||
{
|
||||
index: element.points.length - 1,
|
||||
point: newPoint,
|
||||
},
|
||||
],
|
||||
elementsMap,
|
||||
);
|
||||
LinearElementEditor.movePoints(element, [
|
||||
{
|
||||
index: element.points.length - 1,
|
||||
point: newPoint,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
LinearElementEditor.addPoints(
|
||||
element,
|
||||
[{ point: newPoint }],
|
||||
elementsMap,
|
||||
app.state.zoom,
|
||||
);
|
||||
LinearElementEditor.addPoints(element, [{ point: newPoint }]);
|
||||
}
|
||||
return {
|
||||
...appState.editingLinearElement,
|
||||
@@ -1197,16 +1190,12 @@ export class LinearElementEditor {
|
||||
// potentially expanding the bounding box
|
||||
if (pointAddedToEnd) {
|
||||
const lastPoint = element.points[element.points.length - 1];
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
[
|
||||
{
|
||||
index: element.points.length - 1,
|
||||
point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
|
||||
},
|
||||
],
|
||||
elementsMap,
|
||||
);
|
||||
LinearElementEditor.movePoints(element, [
|
||||
{
|
||||
index: element.points.length - 1,
|
||||
point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1221,8 +1210,6 @@ export class LinearElementEditor {
|
||||
static deletePoints(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
pointIndices: readonly number[],
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
zoom: AppState["zoom"],
|
||||
) {
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
@@ -1252,47 +1239,27 @@ export class LinearElementEditor {
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
LinearElementEditor._updatePoints(
|
||||
element,
|
||||
nextPoints,
|
||||
offsetX,
|
||||
offsetY,
|
||||
elementsMap,
|
||||
);
|
||||
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
|
||||
}
|
||||
|
||||
static addPoints(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
targetPoints: { point: LocalPoint }[],
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
zoom: AppState["zoom"],
|
||||
) {
|
||||
const offsetX = 0;
|
||||
const offsetY = 0;
|
||||
|
||||
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
|
||||
LinearElementEditor._updatePoints(
|
||||
element,
|
||||
nextPoints,
|
||||
offsetX,
|
||||
offsetY,
|
||||
elementsMap,
|
||||
);
|
||||
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
|
||||
}
|
||||
|
||||
static movePoints(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
otherUpdates?: {
|
||||
startBinding?: PointBinding | null;
|
||||
endBinding?: PointBinding | null;
|
||||
},
|
||||
options?: {
|
||||
changedElements?: Map<string, OrderedExcalidrawElement>;
|
||||
isDragging?: boolean;
|
||||
zoom?: AppState["zoom"];
|
||||
},
|
||||
) {
|
||||
const { points } = element;
|
||||
|
||||
@@ -1335,7 +1302,6 @@ export class LinearElementEditor {
|
||||
nextPoints,
|
||||
offsetX,
|
||||
offsetY,
|
||||
elementsMap,
|
||||
otherUpdates,
|
||||
{
|
||||
isDragging: targetPoints.reduce(
|
||||
@@ -1343,8 +1309,6 @@ export class LinearElementEditor {
|
||||
dragging || targetPoint.isDragging === true,
|
||||
false,
|
||||
),
|
||||
changedElements: options?.changedElements,
|
||||
zoom: options?.zoom,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1451,54 +1415,49 @@ export class LinearElementEditor {
|
||||
nextPoints: readonly LocalPoint[],
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
otherUpdates?: {
|
||||
startBinding?: PointBinding | null;
|
||||
endBinding?: PointBinding | null;
|
||||
},
|
||||
options?: {
|
||||
changedElements?: Map<string, OrderedExcalidrawElement>;
|
||||
isDragging?: boolean;
|
||||
zoom?: AppState["zoom"];
|
||||
},
|
||||
) {
|
||||
if (isElbowArrow(element)) {
|
||||
const bindings: {
|
||||
const updates: {
|
||||
startBinding?: FixedPointBinding | null;
|
||||
endBinding?: FixedPointBinding | null;
|
||||
points?: LocalPoint[];
|
||||
} = {};
|
||||
if (otherUpdates?.startBinding !== undefined) {
|
||||
bindings.startBinding =
|
||||
updates.startBinding =
|
||||
otherUpdates.startBinding !== null &&
|
||||
isFixedPointBinding(otherUpdates.startBinding)
|
||||
? otherUpdates.startBinding
|
||||
: null;
|
||||
}
|
||||
if (otherUpdates?.endBinding !== undefined) {
|
||||
bindings.endBinding =
|
||||
updates.endBinding =
|
||||
otherUpdates.endBinding !== null &&
|
||||
isFixedPointBinding(otherUpdates.endBinding)
|
||||
? otherUpdates.endBinding
|
||||
: null;
|
||||
}
|
||||
|
||||
const mergedElementsMap = options?.changedElements
|
||||
? toBrandedType<SceneElementsMap>(
|
||||
new Map([...elementsMap, ...options.changedElements]),
|
||||
)
|
||||
: elementsMap;
|
||||
|
||||
mutateElbowArrow(
|
||||
element,
|
||||
mergedElementsMap,
|
||||
nextPoints,
|
||||
updates.points = Array.from(nextPoints);
|
||||
updates.points[0] = pointTranslate(
|
||||
updates.points[0],
|
||||
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 {
|
||||
const nextCoords = getElementPointsCoords(element, nextPoints);
|
||||
const prevCoords = getElementPointsCoords(element, element.points);
|
||||
@@ -1773,6 +1732,99 @@ export class LinearElementEditor {
|
||||
|
||||
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 = (
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { ExcalidrawElement } from "./types";
|
||||
import type { ExcalidrawElement, SceneElementsMap } from "./types";
|
||||
import Scene from "../scene/Scene";
|
||||
import { getSizeFromPoints } from "../points";
|
||||
import { randomInteger } from "../random";
|
||||
import { getUpdatedTimestamp } from "../utils";
|
||||
import { getUpdatedTimestamp, toBrandedType } from "../utils";
|
||||
import type { Mutable } from "../utility-types";
|
||||
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<
|
||||
Partial<TElement>,
|
||||
@@ -19,14 +22,49 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
element: TElement,
|
||||
updates: ElementUpdate<TElement>,
|
||||
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 => {
|
||||
let didChange = false;
|
||||
|
||||
// casting to any because can't use `in` operator
|
||||
// (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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ import type {
|
||||
ExcalidrawIframeElement,
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
FixedSegment,
|
||||
ExcalidrawElbowArrowElement,
|
||||
} from "./types";
|
||||
import {
|
||||
arrayToMap,
|
||||
@@ -450,15 +452,34 @@ export const newLinearElement = (
|
||||
};
|
||||
};
|
||||
|
||||
export const newArrowElement = (
|
||||
export const newArrowElement = <T extends boolean>(
|
||||
opts: {
|
||||
type: ExcalidrawArrowElement["type"];
|
||||
startArrowhead?: Arrowhead | null;
|
||||
endArrowhead?: Arrowhead | null;
|
||||
points?: ExcalidrawArrowElement["points"];
|
||||
elbowed?: boolean;
|
||||
elbowed?: T;
|
||||
fixedSegments?: FixedSegment[] | null;
|
||||
} & 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 {
|
||||
..._newElementBase<ExcalidrawArrowElement>(opts.type, opts),
|
||||
points: opts.points || [],
|
||||
@@ -467,8 +488,10 @@ export const newArrowElement = (
|
||||
endBinding: null,
|
||||
startArrowhead: opts.startArrowhead || null,
|
||||
endArrowhead: opts.endArrowhead || null,
|
||||
elbowed: opts.elbowed || false,
|
||||
};
|
||||
elbowed: false,
|
||||
} as T extends true
|
||||
? NonDeleted<ExcalidrawElbowArrowElement>
|
||||
: NonDeleted<ExcalidrawArrowElement>;
|
||||
};
|
||||
|
||||
export const newImageElement = (
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
ExcalidrawImageElement,
|
||||
ElementsMap,
|
||||
SceneElementsMap,
|
||||
ExcalidrawElbowArrowElement,
|
||||
} from "./types";
|
||||
import type { Mutable } from "../utility-types";
|
||||
import {
|
||||
@@ -53,7 +54,6 @@ import {
|
||||
import { wrapText } from "./textWrapping";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { isInGroup } from "../groups";
|
||||
import { mutateElbowArrow } from "./routing";
|
||||
import type { GlobalPoint } from "../../math";
|
||||
import {
|
||||
pointCenter,
|
||||
@@ -177,10 +177,10 @@ export const transformElements = (
|
||||
elementsMap,
|
||||
transformHandleType,
|
||||
scene,
|
||||
originalElements,
|
||||
{
|
||||
shouldResizeFromCenter,
|
||||
shouldMaintainAspectRatio,
|
||||
originalElementsMap: originalElements,
|
||||
flipByX,
|
||||
flipByY,
|
||||
nextWidth,
|
||||
@@ -531,8 +531,10 @@ const rotateMultipleElements = (
|
||||
);
|
||||
|
||||
if (isElbowArrow(element)) {
|
||||
const points = getArrowLocalFixedPoints(element, elementsMap);
|
||||
mutateElbowArrow(element, elementsMap, points);
|
||||
// Needed to re-route the arrow
|
||||
mutateElement(element, {
|
||||
points: getArrowLocalFixedPoints(element, elementsMap),
|
||||
});
|
||||
} else {
|
||||
mutateElement(
|
||||
element,
|
||||
@@ -1201,6 +1203,7 @@ export const resizeMultipleElements = (
|
||||
elementsMap: ElementsMap,
|
||||
handleDirection: TransformHandleDirection,
|
||||
scene: Scene,
|
||||
originalElementsMap: ElementsMap,
|
||||
{
|
||||
shouldMaintainAspectRatio = false,
|
||||
shouldResizeFromCenter = false,
|
||||
@@ -1208,7 +1211,6 @@ export const resizeMultipleElements = (
|
||||
flipByY = false,
|
||||
nextHeight,
|
||||
nextWidth,
|
||||
originalElementsMap,
|
||||
originalBoundingBox,
|
||||
}: {
|
||||
nextWidth?: number;
|
||||
@@ -1217,7 +1219,6 @@ export const resizeMultipleElements = (
|
||||
shouldResizeFromCenter?: boolean;
|
||||
flipByX?: boolean;
|
||||
flipByY?: boolean;
|
||||
originalElementsMap?: ElementsMap;
|
||||
// added to improve performance
|
||||
originalBoundingBox?: BoundingBox;
|
||||
} = {},
|
||||
@@ -1387,6 +1388,9 @@ export const resizeMultipleElements = (
|
||||
fontSize?: ExcalidrawTextElement["fontSize"];
|
||||
scale?: ExcalidrawImageElement["scale"];
|
||||
boundTextFontSize?: ExcalidrawTextElement["fontSize"];
|
||||
startBinding?: ExcalidrawElbowArrowElement["startBinding"];
|
||||
endBinding?: ExcalidrawElbowArrowElement["endBinding"];
|
||||
fixedSegments?: ExcalidrawElbowArrowElement["fixedSegments"];
|
||||
};
|
||||
}[] = [];
|
||||
|
||||
@@ -1427,6 +1431,44 @@ export const resizeMultipleElements = (
|
||||
...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)) {
|
||||
update.scale = [
|
||||
orig.scale[0] * flipFactorX,
|
||||
@@ -1472,7 +1514,10 @@ export const resizeMultipleElements = (
|
||||
} of elementsAndUpdates) {
|
||||
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, {
|
||||
simultaneouslyUpdated: elementsToUpdate,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -319,6 +319,12 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
||||
endArrowhead: Arrowhead | null;
|
||||
}>;
|
||||
|
||||
export type FixedSegment = {
|
||||
start: LocalPoint;
|
||||
end: LocalPoint;
|
||||
index: number;
|
||||
};
|
||||
|
||||
export type ExcalidrawArrowElement = ExcalidrawLinearElement &
|
||||
Readonly<{
|
||||
type: "arrow";
|
||||
@@ -331,6 +337,23 @@ export type ExcalidrawElbowArrowElement = Merge<
|
||||
elbowed: true;
|
||||
startBinding: 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;
|
||||
}
|
||||
>;
|
||||
|
||||
|
||||
@@ -190,7 +190,6 @@ export const syncInvalidIndices = (
|
||||
): OrderedExcalidrawElement[] => {
|
||||
const indicesGroups = getInvalidIndicesGroups(elements);
|
||||
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||
|
||||
for (const [element, update] of elementsUpdates) {
|
||||
mutateElement(element, update, false);
|
||||
}
|
||||
|
||||
+214
-44
@@ -95,12 +95,11 @@ export const getElementsCompletelyInFrame = (
|
||||
);
|
||||
|
||||
export const isElementContainingFrame = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
element: ExcalidrawElement,
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
return getElementsWithinSelection(elements, element, elementsMap).some(
|
||||
return getElementsWithinSelection([frame], element, elementsMap).some(
|
||||
(e) => e.id === frame.id,
|
||||
);
|
||||
};
|
||||
@@ -144,7 +143,7 @@ export const elementOverlapsWithFrame = (
|
||||
return (
|
||||
elementsAreInFrameBounds([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([
|
||||
...getElementsCompletelyInFrame(allElements, frame, elementsMap),
|
||||
...prevElementsInFrame.filter((element) =>
|
||||
isElementContainingFrame(allElements, element, frame, elementsMap),
|
||||
isElementContainingFrame(element, frame, elementsMap),
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -370,12 +369,57 @@ export const getElementsInNewFrame = (
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
return omitGroupsContainingFrameLikes(
|
||||
elements,
|
||||
getElementsCompletelyInFrame(elements, frame, elementsMap),
|
||||
return omitPartialGroups(
|
||||
omitGroupsContainingFrameLikes(
|
||||
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 = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
@@ -454,6 +498,7 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
|
||||
allElements: T,
|
||||
elementsToAdd: NonDeletedExcalidrawElement[],
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
appState: AppState,
|
||||
): T => {
|
||||
const elementsMap = arrayToMap(allElements);
|
||||
const currTargetFrameChildrenMap = new Map<ExcalidrawElement["id"], true>();
|
||||
@@ -489,6 +534,17 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
|
||||
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)) {
|
||||
finalElementsToAdd.push(element);
|
||||
}
|
||||
@@ -577,6 +633,7 @@ export const replaceAllElementsInFrame = <T extends ExcalidrawElement>(
|
||||
removeAllElementsFromFrame(allElements, frame),
|
||||
nextElementsInFrame,
|
||||
frame,
|
||||
app.state,
|
||||
).slice();
|
||||
};
|
||||
|
||||
@@ -683,6 +740,16 @@ export const getTargetFrame = (
|
||||
? getContainerElement(element, elementsMap) || 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] &&
|
||||
appState.selectedElementsAreBeingDragged
|
||||
? appState.frameToHighlight
|
||||
@@ -695,61 +762,151 @@ export const isElementInFrame = (
|
||||
element: ExcalidrawElement,
|
||||
allElementsMap: ElementsMap,
|
||||
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)
|
||||
? getContainerElement(element, allElementsMap) || element
|
||||
: element;
|
||||
|
||||
if (frame) {
|
||||
// Perf improvement:
|
||||
// For an element that's already in a frame, if it's not being dragged
|
||||
// then there is no need to refer to geometry (which, yes, is slow) to check if it's in a frame.
|
||||
// It has to be in its containing frame.
|
||||
if (
|
||||
!appState.selectedElementIds[element.id] ||
|
||||
!appState.selectedElementsAreBeingDragged
|
||||
) {
|
||||
const setGroupsInFrame = (isInFrame: boolean) => {
|
||||
if (opts?.checkedGroups) {
|
||||
_element.groupIds.forEach((groupId) => {
|
||||
opts.checkedGroups?.set(groupId, isInFrame);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (_element.groupIds.length === 0) {
|
||||
return elementOverlapsWithFrame(_element, frame, allElementsMap);
|
||||
selectedElements.forEach((selectedElement) => {
|
||||
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(
|
||||
_element.groupIds.flatMap((gid) =>
|
||||
getElementsInGroup(allElementsMap, gid),
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (appState.editingGroupId && appState.selectedElementsAreBeingDragged) {
|
||||
const selectedElements = new Set(
|
||||
getSelectedElements(allElementsMap, appState),
|
||||
);
|
||||
// if an element is outside the frame, but is part of a group that has some elements
|
||||
// "in" the frame, we should clip the element
|
||||
if (
|
||||
!shouldClipElementItself &&
|
||||
element.groupIds.length > 0 &&
|
||||
!elementsAreInFrameBounds([element], frame, elementsMap)
|
||||
) {
|
||||
let shouldClip = false;
|
||||
|
||||
const editingGroupOverlapsFrame = appState.frameToHighlight !== null;
|
||||
|
||||
if (editingGroupOverlapsFrame) {
|
||||
return true;
|
||||
// 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 (!appState.selectedElementsAreBeingDragged) {
|
||||
shouldClip = element.frameId === frame.id;
|
||||
for (const groupId of element.groupIds) {
|
||||
checkedGroups?.set(groupId, shouldClip);
|
||||
}
|
||||
|
||||
selectedElements.forEach((selectedElement) => {
|
||||
allElementsInGroup.delete(selectedElement);
|
||||
} else {
|
||||
shouldClip = isElementInFrame(element, elementsMap, appState, {
|
||||
targetFrame: frame,
|
||||
checkedGroups,
|
||||
});
|
||||
}
|
||||
|
||||
for (const elementInGroup of allElementsInGroup) {
|
||||
if (isFrameLikeElement(elementInGroup)) {
|
||||
return false;
|
||||
}
|
||||
for (const groupId of element.groupIds) {
|
||||
checkedGroups?.set(groupId, shouldClip);
|
||||
}
|
||||
|
||||
for (const elementInGroup of allElementsInGroup) {
|
||||
if (elementOverlapsWithFrame(elementInGroup, frame, allElementsMap)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return shouldClip;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -779,3 +936,16 @@ export const getElementsOverlappingFrame = (
|
||||
.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),
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -105,6 +105,10 @@ export const selectGroupsForSelectedElements = (function () {
|
||||
const groupElementsIndex: Record<GroupId, string[]> = {};
|
||||
const selectedElementIdsInGroups = elements.reduce(
|
||||
(acc: Record<string, true>, element) => {
|
||||
if (element.isDeleted) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const groupId = element.groupIds.find((id) => selectedGroupIds[id]);
|
||||
|
||||
if (groupId) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import { COLOR_PALETTE } from "../colors";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import { atom, useAtom } from "../editor-jotai";
|
||||
import { exportToSvg } from "../../utils/export";
|
||||
import type { LibraryItem } from "../types";
|
||||
|
||||
@@ -64,7 +63,7 @@ export const useLibraryItemSvg = (
|
||||
};
|
||||
|
||||
export const useLibraryCache = () => {
|
||||
const [svgCache] = useAtom(libraryItemSvgsCache, jotaiScope);
|
||||
const [svgCache] = useAtom(libraryItemSvgsCache);
|
||||
|
||||
const clearLibraryCache = () => svgCache.clear();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { atom, useAtom } from "../editor-jotai";
|
||||
import throttle from "lodash.throttle";
|
||||
|
||||
const scrollPositionAtom = atom<number>(0);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import fallbackLangData from "./locales/en.json";
|
||||
import percentages from "./locales/percentages.json";
|
||||
import { jotaiScope, jotaiStore } from "./jotai";
|
||||
import { atom, useAtomValue } from "jotai";
|
||||
import { useAtomValue, editorJotaiStore, atom } from "./editor-jotai";
|
||||
import type { NestedKeyOf } from "./utility-types";
|
||||
|
||||
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;
|
||||
@@ -165,6 +164,6 @@ const editorLangCodeAtom = atom(defaultLang.code);
|
||||
// - component is rendered internally by <Excalidraw>, but the component
|
||||
// is memoized w/o being updated on `langCode`, `AppState`, or `UIAppState`
|
||||
export const useI18n = () => {
|
||||
const langCode = useAtomValue(editorLangCodeAtom, jotaiScope);
|
||||
const langCode = useAtomValue(editorLangCodeAtom);
|
||||
return { t, langCode };
|
||||
};
|
||||
|
||||
@@ -11,8 +11,7 @@ import "./fonts/fonts.css";
|
||||
import type { AppProps, ExcalidrawProps } from "./types";
|
||||
import { defaultLang } from "./i18n";
|
||||
import { DEFAULT_UI_OPTIONS } from "./constants";
|
||||
import { Provider } from "jotai";
|
||||
import { jotaiScope, jotaiStore } from "./jotai";
|
||||
import { EditorJotaiProvider, editorJotaiStore } from "./editor-jotai";
|
||||
import Footer from "./components/footer/FooterCenter";
|
||||
import MainMenu from "./components/main-menu/MainMenu";
|
||||
import WelcomeScreen from "./components/welcome-screen/WelcomeScreen";
|
||||
@@ -108,7 +107,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Provider unstable_createStore={() => jotaiStore} scope={jotaiScope}>
|
||||
<EditorJotaiProvider store={editorJotaiStore}>
|
||||
<InitializeApp langCode={langCode} theme={theme}>
|
||||
<App
|
||||
onChange={onChange}
|
||||
@@ -145,7 +144,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||
{children}
|
||||
</App>
|
||||
</InitializeApp>
|
||||
</Provider>
|
||||
</EditorJotaiProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -164,7 +164,8 @@
|
||||
"imageCropping": "Image cropping",
|
||||
"unCroppedDimension": "Uncropped dimension",
|
||||
"copyElementLink": "Copy link to object",
|
||||
"linkToElement": "Link to object"
|
||||
"linkToElement": "Link to object",
|
||||
"wrapSelectionInFrame": "Wrap selection in frame"
|
||||
},
|
||||
"elementLink": {
|
||||
"title": "Link to object",
|
||||
|
||||
@@ -70,7 +70,8 @@
|
||||
"fractional-indexing": "3.2.0",
|
||||
"fuzzy": "0.1.3",
|
||||
"image-blob-reduce": "3.0.1",
|
||||
"jotai": "1.13.1",
|
||||
"jotai": "2.11.0",
|
||||
"jotai-scope": "0.7.2",
|
||||
"lodash.throttle": "4.1.1",
|
||||
"nanoid": "3.3.3",
|
||||
"open-color": "1.9.1",
|
||||
@@ -116,6 +117,7 @@
|
||||
"fonteditor-core": "2.4.1",
|
||||
"harfbuzzjs": "0.3.6",
|
||||
"import-meta-loader": "1.1.0",
|
||||
"jest-diff": "29.7.0",
|
||||
"mini-css-extract-plugin": "2.6.1",
|
||||
"postcss-loader": "7.0.1",
|
||||
"sass-loader": "13.0.2",
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
getOmitSidesForDevice,
|
||||
shouldShowBoundingBox,
|
||||
} from "../element/transformHandles";
|
||||
import { arrayToMap, throttleRAF } from "../utils";
|
||||
import { arrayToMap, invariant, throttleRAF } from "../utils";
|
||||
import {
|
||||
DEFAULT_TRANSFORM_HANDLE_SPACING,
|
||||
FRAME_STYLE,
|
||||
@@ -78,8 +78,32 @@ import type {
|
||||
InteractiveSceneRenderConfig,
|
||||
RenderableElementsMap,
|
||||
} 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 { 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 = (
|
||||
context: CanvasRenderingContext2D,
|
||||
@@ -490,7 +514,7 @@ const renderLinearPointHandles = (
|
||||
context.save();
|
||||
context.translate(appState.scrollX, appState.scrollY);
|
||||
context.lineWidth = 1 / appState.zoom.value;
|
||||
const points = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
const points: GlobalPoint[] = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
@@ -510,55 +534,57 @@ const renderLinearPointHandles = (
|
||||
renderSingleLinearPoint(context, appState, point, radius, isSelected);
|
||||
});
|
||||
|
||||
//Rendering segment mid points
|
||||
const midPoints = LinearElementEditor.getEditorMidPoints(
|
||||
element,
|
||||
elementsMap,
|
||||
appState,
|
||||
).filter((midPoint): midPoint is GlobalPoint => midPoint !== null);
|
||||
|
||||
midPoints.forEach((segmentMidPoint) => {
|
||||
if (
|
||||
appState?.selectedLinearElement?.segmentMidPointHoveredCoords &&
|
||||
LinearElementEditor.arePointsEqual(
|
||||
segmentMidPoint,
|
||||
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) {
|
||||
// Rendering segment mid points
|
||||
if (isElbowArrow(element)) {
|
||||
const fixedSegments =
|
||||
element.fixedSegments?.map((segment) => segment.index) || [];
|
||||
points.slice(0, -1).forEach((p, idx) => {
|
||||
if (
|
||||
!LinearElementEditor.isSegmentTooShort(
|
||||
element,
|
||||
points[idx + 1],
|
||||
points[idx],
|
||||
idx,
|
||||
appState.zoom,
|
||||
)
|
||||
) {
|
||||
renderSingleLinearPoint(
|
||||
context,
|
||||
appState,
|
||||
segmentMidPoint,
|
||||
radius,
|
||||
false,
|
||||
);
|
||||
highlightPoint(segmentMidPoint, context, appState);
|
||||
} else {
|
||||
highlightPoint(segmentMidPoint, context, appState);
|
||||
renderSingleLinearPoint(
|
||||
context,
|
||||
appState,
|
||||
segmentMidPoint,
|
||||
radius,
|
||||
pointFrom<GlobalPoint>(
|
||||
(p[0] + points[idx + 1][0]) / 2,
|
||||
(p[1] + points[idx + 1][1]) / 2,
|
||||
),
|
||||
POINT_HANDLE_SIZE / 2,
|
||||
false,
|
||||
!fixedSegments.includes(idx + 1),
|
||||
);
|
||||
}
|
||||
} else if (appState.editingLinearElement || points.length === 2) {
|
||||
renderSingleLinearPoint(
|
||||
context,
|
||||
appState,
|
||||
segmentMidPoint,
|
||||
POINT_HANDLE_SIZE / 2,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
const midPoints = LinearElementEditor.getEditorMidPoints(
|
||||
element,
|
||||
elementsMap,
|
||||
appState,
|
||||
).filter(
|
||||
(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();
|
||||
};
|
||||
@@ -864,6 +890,12 @@ const _renderInteractiveScene = ({
|
||||
}
|
||||
|
||||
if (
|
||||
isElbowArrow(selectedElements[0]) &&
|
||||
appState.selectedLinearElement &&
|
||||
appState.selectedLinearElement.segmentMidPointHoveredCoords
|
||||
) {
|
||||
renderElbowArrowMidPointHighlight(context, appState);
|
||||
} else if (
|
||||
appState.selectedLinearElement &&
|
||||
appState.selectedLinearElement.hoverPointIndex >= 0 &&
|
||||
!(
|
||||
@@ -875,6 +907,7 @@ const _renderInteractiveScene = ({
|
||||
) {
|
||||
renderLinearElementPointHighlight(context, appState, elementsMap);
|
||||
}
|
||||
|
||||
// Paint selected elements
|
||||
if (!appState.multiElement && !appState.editingLinearElement) {
|
||||
const showBoundingBox = shouldShowBoundingBox(selectedElements, appState);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getElementAbsoluteCoords } from "../element";
|
||||
import {
|
||||
elementOverlapsWithFrame,
|
||||
getTargetFrame,
|
||||
isElementInFrame,
|
||||
shouldApplyFrameClip,
|
||||
} from "../frame";
|
||||
import {
|
||||
isEmbeddableElement,
|
||||
@@ -273,6 +273,8 @@ const _renderStaticScene = ({
|
||||
}
|
||||
});
|
||||
|
||||
const inFrameGroupsMap = new Map<string, boolean>();
|
||||
|
||||
// Paint visible elements
|
||||
visibleElements
|
||||
.filter((el) => !isIframeLikeElement(el))
|
||||
@@ -297,9 +299,16 @@ const _renderStaticScene = ({
|
||||
appState.frameRendering.clip
|
||||
) {
|
||||
const frame = getTargetFrame(element, elementsMap, appState);
|
||||
|
||||
// TODO do we need to check isElementInFrame here?
|
||||
if (frame && isElementInFrame(element, elementsMap, appState)) {
|
||||
if (
|
||||
frame &&
|
||||
shouldApplyFrameClip(
|
||||
element,
|
||||
frame,
|
||||
appState,
|
||||
elementsMap,
|
||||
inFrameGroupsMap,
|
||||
)
|
||||
) {
|
||||
frameClip(frame, context, renderConfig, appState);
|
||||
}
|
||||
renderElement(
|
||||
@@ -400,7 +409,16 @@ const _renderStaticScene = ({
|
||||
|
||||
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);
|
||||
}
|
||||
render();
|
||||
|
||||
@@ -23,13 +23,9 @@ import {
|
||||
} from "../element/typeChecks";
|
||||
import { canChangeRoundness } from "./comparisons";
|
||||
import type { EmbedsValidationStatus } from "../types";
|
||||
import {
|
||||
pointFrom,
|
||||
pointDistance,
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
} from "../../math";
|
||||
import { pointFrom, pointDistance, type LocalPoint } from "../../math";
|
||||
import { getCornerRadius, isPathALoop } from "../shapes";
|
||||
import { headingForPointIsHorizontal } from "../element/heading";
|
||||
|
||||
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
|
||||
|
||||
@@ -527,45 +523,53 @@ export const _generateElementShape = (
|
||||
}
|
||||
};
|
||||
|
||||
const generateElbowArrowShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
points: readonly Point[],
|
||||
const generateElbowArrowShape = (
|
||||
points: readonly LocalPoint[],
|
||||
radius: number,
|
||||
) => {
|
||||
const subpoints = [] as [number, number][];
|
||||
for (let i = 1; i < points.length - 1; i += 1) {
|
||||
const prev = points[i - 1];
|
||||
const next = points[i + 1];
|
||||
const point = points[i];
|
||||
const prevIsHorizontal = headingForPointIsHorizontal(point, prev);
|
||||
const nextIsHorizontal = headingForPointIsHorizontal(next, point);
|
||||
const corner = Math.min(
|
||||
radius,
|
||||
pointDistance(points[i], next) / 2,
|
||||
pointDistance(points[i], prev) / 2,
|
||||
);
|
||||
|
||||
if (prev[0] < points[i][0] && prev[1] === points[i][1]) {
|
||||
// LEFT
|
||||
subpoints.push([points[i][0] - corner, points[i][1]]);
|
||||
} else if (prev[0] === points[i][0] && prev[1] < points[i][1]) {
|
||||
if (prevIsHorizontal) {
|
||||
if (prev[0] < point[0]) {
|
||||
// LEFT
|
||||
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
|
||||
subpoints.push([points[i][0], points[i][1] - corner]);
|
||||
} else if (prev[0] > points[i][0] && prev[1] === points[i][1]) {
|
||||
// RIGHT
|
||||
subpoints.push([points[i][0] + corner, points[i][1]]);
|
||||
} else {
|
||||
subpoints.push([points[i][0], points[i][1] + corner]);
|
||||
}
|
||||
|
||||
subpoints.push(points[i] as [number, number]);
|
||||
|
||||
if (next[0] < points[i][0] && next[1] === points[i][1]) {
|
||||
// LEFT
|
||||
subpoints.push([points[i][0] - corner, points[i][1]]);
|
||||
} else if (next[0] === points[i][0] && next[1] < points[i][1]) {
|
||||
if (nextIsHorizontal) {
|
||||
if (next[0] < point[0]) {
|
||||
// LEFT
|
||||
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
|
||||
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 {
|
||||
// DOWN
|
||||
subpoints.push([points[i][0], points[i][1] + corner]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,10 +183,12 @@ export const getSelectedElements = (
|
||||
includeElementsInFrames?: boolean;
|
||||
},
|
||||
) => {
|
||||
const addedElements = new Set<ExcalidrawElement["id"]>();
|
||||
const selectedElements: ExcalidrawElement[] = [];
|
||||
for (const element of elements.values()) {
|
||||
if (appState.selectedElementIds[element.id]) {
|
||||
selectedElements.push(element);
|
||||
addedElements.add(element.id);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
@@ -195,6 +197,7 @@ export const getSelectedElements = (
|
||||
appState.selectedElementIds[element?.containerId]
|
||||
) {
|
||||
selectedElements.push(element);
|
||||
addedElements.add(element.id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -203,8 +206,8 @@ export const getSelectedElements = (
|
||||
const elementsToInclude: ExcalidrawElement[] = [];
|
||||
selectedElements.forEach((element) => {
|
||||
if (isFrameLikeElement(element)) {
|
||||
getFrameChildren(elements, element.id).forEach((e) =>
|
||||
elementsToInclude.push(e),
|
||||
getFrameChildren(elements, element.id).forEach(
|
||||
(e) => !addedElements.has(e.id) && elementsToInclude.push(e),
|
||||
);
|
||||
}
|
||||
elementsToInclude.push(element);
|
||||
|
||||
@@ -97,6 +97,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
"category": "element",
|
||||
},
|
||||
},
|
||||
"separator",
|
||||
{
|
||||
"label": "labels.selectAllElementsInFrame",
|
||||
"name": "selectAllElementsInFrame",
|
||||
@@ -115,6 +116,15 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
"category": "history",
|
||||
},
|
||||
},
|
||||
{
|
||||
"label": "labels.wrapSelectionInFrame",
|
||||
"name": "wrapSelectionInFrame",
|
||||
"perform": [Function],
|
||||
"predicate": [Function],
|
||||
"trackEvent": {
|
||||
"category": "element",
|
||||
},
|
||||
},
|
||||
"separator",
|
||||
{
|
||||
"PanelComponent": [Function],
|
||||
@@ -4731,6 +4741,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
"category": "element",
|
||||
},
|
||||
},
|
||||
"separator",
|
||||
{
|
||||
"label": "labels.selectAllElementsInFrame",
|
||||
"name": "selectAllElementsInFrame",
|
||||
@@ -4749,6 +4760,15 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
"category": "history",
|
||||
},
|
||||
},
|
||||
{
|
||||
"label": "labels.wrapSelectionInFrame",
|
||||
"name": "wrapSelectionInFrame",
|
||||
"perform": [Function],
|
||||
"predicate": [Function],
|
||||
"trackEvent": {
|
||||
"category": "element",
|
||||
},
|
||||
},
|
||||
"separator",
|
||||
{
|
||||
"PanelComponent": [Function],
|
||||
@@ -5942,6 +5962,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"category": "element",
|
||||
},
|
||||
},
|
||||
"separator",
|
||||
{
|
||||
"label": "labels.selectAllElementsInFrame",
|
||||
"name": "selectAllElementsInFrame",
|
||||
@@ -5960,6 +5981,15 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"category": "history",
|
||||
},
|
||||
},
|
||||
{
|
||||
"label": "labels.wrapSelectionInFrame",
|
||||
"name": "wrapSelectionInFrame",
|
||||
"perform": [Function],
|
||||
"predicate": [Function],
|
||||
"trackEvent": {
|
||||
"category": "element",
|
||||
},
|
||||
},
|
||||
"separator",
|
||||
{
|
||||
"PanelComponent": [Function],
|
||||
@@ -7876,6 +7906,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"category": "element",
|
||||
},
|
||||
},
|
||||
"separator",
|
||||
{
|
||||
"label": "labels.selectAllElementsInFrame",
|
||||
"name": "selectAllElementsInFrame",
|
||||
@@ -7894,6 +7925,15 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"category": "history",
|
||||
},
|
||||
},
|
||||
{
|
||||
"label": "labels.wrapSelectionInFrame",
|
||||
"name": "wrapSelectionInFrame",
|
||||
"perform": [Function],
|
||||
"predicate": [Function],
|
||||
"trackEvent": {
|
||||
"category": "element",
|
||||
},
|
||||
},
|
||||
"separator",
|
||||
{
|
||||
"PanelComponent": [Function],
|
||||
@@ -8854,6 +8894,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"category": "element",
|
||||
},
|
||||
},
|
||||
"separator",
|
||||
{
|
||||
"label": "labels.selectAllElementsInFrame",
|
||||
"name": "selectAllElementsInFrame",
|
||||
@@ -8872,6 +8913,15 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"category": "history",
|
||||
},
|
||||
},
|
||||
{
|
||||
"label": "labels.wrapSelectionInFrame",
|
||||
"name": "wrapSelectionInFrame",
|
||||
"perform": [Function],
|
||||
"predicate": [Function],
|
||||
"trackEvent": {
|
||||
"category": "element",
|
||||
},
|
||||
},
|
||||
"separator",
|
||||
{
|
||||
"PanelComponent": [Function],
|
||||
|
||||
@@ -197,7 +197,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 125,
|
||||
"height": 99,
|
||||
"id": "id166",
|
||||
"index": "a2",
|
||||
"isDeleted": false,
|
||||
@@ -211,8 +211,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
0,
|
||||
],
|
||||
[
|
||||
125,
|
||||
125,
|
||||
"98.20800",
|
||||
99,
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
@@ -226,9 +226,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 47,
|
||||
"width": 125,
|
||||
"x": 0,
|
||||
"version": 40,
|
||||
"width": "98.20800",
|
||||
"x": 1,
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
@@ -298,7 +298,7 @@ History {
|
||||
"focus": "0.00990",
|
||||
"gap": 1,
|
||||
},
|
||||
"height": "0.98000",
|
||||
"height": "0.98017",
|
||||
"points": [
|
||||
[
|
||||
0,
|
||||
@@ -306,7 +306,7 @@ History {
|
||||
],
|
||||
[
|
||||
98,
|
||||
"-0.98000",
|
||||
"-0.98017",
|
||||
],
|
||||
],
|
||||
"startBinding": {
|
||||
@@ -320,10 +320,10 @@ History {
|
||||
"endBinding": {
|
||||
"elementId": "id165",
|
||||
"fixedPoint": null,
|
||||
"focus": "-0.02040",
|
||||
"focus": "-0.02000",
|
||||
"gap": 1,
|
||||
},
|
||||
"height": "0.02000",
|
||||
"height": "0.00169",
|
||||
"points": [
|
||||
[
|
||||
0,
|
||||
@@ -331,13 +331,13 @@ History {
|
||||
],
|
||||
[
|
||||
98,
|
||||
"0.02000",
|
||||
"0.00169",
|
||||
],
|
||||
],
|
||||
"startBinding": {
|
||||
"elementId": "id164",
|
||||
"fixedPoint": null,
|
||||
"focus": "0.01959",
|
||||
"focus": "0.02000",
|
||||
"gap": 1,
|
||||
},
|
||||
},
|
||||
@@ -393,20 +393,18 @@ History {
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
},
|
||||
"height": 125,
|
||||
"height": 99,
|
||||
"points": [
|
||||
[
|
||||
0,
|
||||
0,
|
||||
],
|
||||
[
|
||||
125,
|
||||
125,
|
||||
"98.20800",
|
||||
99,
|
||||
],
|
||||
],
|
||||
"startBinding": null,
|
||||
"width": 125,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"inserted": {
|
||||
@@ -416,7 +414,7 @@ History {
|
||||
"focus": "0.00990",
|
||||
"gap": 1,
|
||||
},
|
||||
"height": "0.98000",
|
||||
"height": "0.98161",
|
||||
"points": [
|
||||
[
|
||||
0,
|
||||
@@ -424,7 +422,7 @@ History {
|
||||
],
|
||||
[
|
||||
98,
|
||||
"-0.98000",
|
||||
"-0.98161",
|
||||
],
|
||||
],
|
||||
"startBinding": {
|
||||
@@ -433,9 +431,7 @@ History {
|
||||
"focus": "0.02970",
|
||||
"gap": 1,
|
||||
},
|
||||
"width": 98,
|
||||
"x": 1,
|
||||
"y": "0.99000",
|
||||
"y": "0.99245",
|
||||
},
|
||||
},
|
||||
"id169" => Delta {
|
||||
@@ -827,9 +823,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 37,
|
||||
"width": 100,
|
||||
"x": 150,
|
||||
"version": 30,
|
||||
"width": 0,
|
||||
"x": 200,
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
@@ -866,8 +862,6 @@ History {
|
||||
0,
|
||||
],
|
||||
],
|
||||
"width": 0,
|
||||
"x": 149,
|
||||
},
|
||||
"inserted": {
|
||||
"points": [
|
||||
@@ -876,12 +870,10 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
"98.00000",
|
||||
100,
|
||||
0,
|
||||
],
|
||||
],
|
||||
"width": "98.00000",
|
||||
"x": "1.00000",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -938,8 +930,6 @@ History {
|
||||
],
|
||||
],
|
||||
"startBinding": null,
|
||||
"width": 100,
|
||||
"x": 150,
|
||||
},
|
||||
"inserted": {
|
||||
"endBinding": {
|
||||
@@ -964,8 +954,6 @@ History {
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
},
|
||||
"width": 0,
|
||||
"x": 149,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2375,9 +2363,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 12,
|
||||
"version": 10,
|
||||
"width": 498,
|
||||
"x": "1.00000",
|
||||
"x": 1,
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
@@ -2516,7 +2504,7 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
"98.00000",
|
||||
100,
|
||||
0,
|
||||
],
|
||||
],
|
||||
@@ -2535,8 +2523,8 @@ History {
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"width": "98.00000",
|
||||
"x": 1,
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"inserted": {
|
||||
@@ -10908,12 +10896,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "6Rm4g567UQM4WjLwej2Vc",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"boundElements": [],
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -10921,7 +10904,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
|
||||
"height": 126,
|
||||
"id": "KPrBI4g_v9qUB1XxYLgSz",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"isDeleted": true,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
@@ -10934,7 +10917,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 6,
|
||||
"version": 4,
|
||||
"width": 157,
|
||||
"x": 600,
|
||||
"y": 0,
|
||||
@@ -10945,12 +10928,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
|
||||
{
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "6Rm4g567UQM4WjLwej2Vc",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"boundElements": [],
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -10958,7 +10936,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
|
||||
"height": 129,
|
||||
"id": "u2JGnnmoJ0VATV4vCNJE5",
|
||||
"index": "a1",
|
||||
"isDeleted": false,
|
||||
"isDeleted": true,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
@@ -10971,7 +10949,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
|
||||
"strokeWidth": 2,
|
||||
"type": "diamond",
|
||||
"updated": 1,
|
||||
"version": 6,
|
||||
"version": 4,
|
||||
"width": 124,
|
||||
"x": 1152,
|
||||
"y": 516,
|
||||
@@ -10995,13 +10973,15 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
|
||||
"focus": "-0.00161",
|
||||
"gap": "3.53708",
|
||||
},
|
||||
"endIsSpecial": false,
|
||||
"fillStyle": "solid",
|
||||
"fixedSegments": [],
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": "448.10100",
|
||||
"height": "236.10000",
|
||||
"id": "6Rm4g567UQM4WjLwej2Vc",
|
||||
"index": "a2",
|
||||
"isDeleted": false,
|
||||
"isDeleted": true,
|
||||
"lastCommittedPoint": null,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -11012,12 +10992,12 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
|
||||
0,
|
||||
],
|
||||
[
|
||||
"451.90000",
|
||||
"178.90000",
|
||||
0,
|
||||
],
|
||||
[
|
||||
"451.90000",
|
||||
"448.10100",
|
||||
"178.90000",
|
||||
"236.10000",
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
@@ -11034,15 +11014,16 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
|
||||
"focus": "-0.00159",
|
||||
"gap": 5,
|
||||
},
|
||||
"startIsSpecial": false,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 6,
|
||||
"width": "451.90000",
|
||||
"x": 762,
|
||||
"y": "62.90000",
|
||||
"version": 3,
|
||||
"width": "178.90000",
|
||||
"x": 1035,
|
||||
"y": "274.90000",
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -11054,8 +11035,7 @@ History {
|
||||
[Function],
|
||||
],
|
||||
},
|
||||
"redoStack": [],
|
||||
"undoStack": [
|
||||
"redoStack": [
|
||||
HistoryEntry {
|
||||
"appStateChange": AppStateChange {
|
||||
"delta": Delta {
|
||||
@@ -11064,86 +11044,12 @@ History {
|
||||
},
|
||||
},
|
||||
"elementsChange": ElementsChange {
|
||||
"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 {
|
||||
"added": Map {
|
||||
"6Rm4g567UQM4WjLwej2Vc" => Delta {
|
||||
"deleted": {
|
||||
"isDeleted": true,
|
||||
},
|
||||
"inserted": {
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
@@ -11159,7 +11065,9 @@ History {
|
||||
"focus": "-0.00161",
|
||||
"gap": "3.53708",
|
||||
},
|
||||
"endIsSpecial": false,
|
||||
"fillStyle": "solid",
|
||||
"fixedSegments": [],
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": "236.10000",
|
||||
@@ -11197,6 +11105,7 @@ History {
|
||||
"focus": "-0.00159",
|
||||
"gap": 5,
|
||||
},
|
||||
"startIsSpecial": false,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
@@ -11205,14 +11114,15 @@ History {
|
||||
"x": 1035,
|
||||
"y": "274.90000",
|
||||
},
|
||||
"inserted": {
|
||||
"isDeleted": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
"removed": Map {},
|
||||
"updated": Map {
|
||||
"KPrBI4g_v9qUB1XxYLgSz" => Delta {
|
||||
"deleted": {
|
||||
"boundElements": [],
|
||||
},
|
||||
"inserted": {
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "6Rm4g567UQM4WjLwej2Vc",
|
||||
@@ -11220,12 +11130,12 @@ History {
|
||||
},
|
||||
],
|
||||
},
|
||||
"inserted": {
|
||||
"boundElements": [],
|
||||
},
|
||||
},
|
||||
"u2JGnnmoJ0VATV4vCNJE5" => Delta {
|
||||
"deleted": {
|
||||
"boundElements": [],
|
||||
},
|
||||
"inserted": {
|
||||
"boundElements": [
|
||||
{
|
||||
"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,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 12,
|
||||
"version": 10,
|
||||
"width": "98.00000",
|
||||
"x": "1.00000",
|
||||
"x": 1,
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
@@ -15220,7 +15204,7 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
"98.00000",
|
||||
100,
|
||||
0,
|
||||
],
|
||||
],
|
||||
@@ -15233,7 +15217,7 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
"98.00000",
|
||||
100,
|
||||
0,
|
||||
],
|
||||
],
|
||||
@@ -15529,7 +15513,7 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
"98.00000",
|
||||
100,
|
||||
0,
|
||||
],
|
||||
],
|
||||
@@ -15548,8 +15532,8 @@ History {
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"width": "98.00000",
|
||||
"x": 1,
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"inserted": {
|
||||
@@ -15878,9 +15862,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 12,
|
||||
"version": 10,
|
||||
"width": "98.00000",
|
||||
"x": "1.00000",
|
||||
"x": 1,
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
@@ -16152,7 +16136,7 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
"98.00000",
|
||||
100,
|
||||
0,
|
||||
],
|
||||
],
|
||||
@@ -16171,8 +16155,8 @@ History {
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"width": "98.00000",
|
||||
"x": 1,
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"inserted": {
|
||||
@@ -16501,9 +16485,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 12,
|
||||
"version": 10,
|
||||
"width": "98.00000",
|
||||
"x": "1.00000",
|
||||
"x": 1,
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
@@ -16775,7 +16759,7 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
"98.00000",
|
||||
100,
|
||||
0,
|
||||
],
|
||||
],
|
||||
@@ -16794,8 +16778,8 @@ History {
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"width": "98.00000",
|
||||
"x": 1,
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"inserted": {
|
||||
@@ -17122,9 +17106,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 12,
|
||||
"version": 10,
|
||||
"width": "98.00000",
|
||||
"x": "1.00000",
|
||||
"x": 1,
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
@@ -17180,7 +17164,7 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
"98.00000",
|
||||
100,
|
||||
0,
|
||||
],
|
||||
],
|
||||
@@ -17198,7 +17182,7 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
"98.00000",
|
||||
100,
|
||||
0,
|
||||
],
|
||||
],
|
||||
@@ -17467,7 +17451,7 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
"98.00000",
|
||||
100,
|
||||
0,
|
||||
],
|
||||
],
|
||||
@@ -17486,8 +17470,8 @@ History {
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"width": "98.00000",
|
||||
"x": 1,
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"inserted": {
|
||||
@@ -17840,9 +17824,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 13,
|
||||
"version": 11,
|
||||
"width": "98.00000",
|
||||
"x": "1.00000",
|
||||
"x": 1,
|
||||
"y": 0,
|
||||
}
|
||||
`;
|
||||
@@ -17913,7 +17897,7 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
"98.00000",
|
||||
100,
|
||||
0,
|
||||
],
|
||||
],
|
||||
@@ -17932,7 +17916,7 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
"98.00000",
|
||||
100,
|
||||
0,
|
||||
],
|
||||
],
|
||||
@@ -18201,7 +18185,7 @@ History {
|
||||
0,
|
||||
],
|
||||
[
|
||||
"98.00000",
|
||||
100,
|
||||
0,
|
||||
],
|
||||
],
|
||||
@@ -18220,8 +18204,8 @@ History {
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"width": "98.00000",
|
||||
"x": 1,
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"inserted": {
|
||||
|
||||
@@ -173,7 +173,7 @@ exports[`move element > rectangles with binding arrow 6`] = `
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 7,
|
||||
"versionNonce": 2066753033,
|
||||
"versionNonce": 745419401,
|
||||
"width": 300,
|
||||
"x": 201,
|
||||
"y": 2,
|
||||
@@ -232,8 +232,8 @@ exports[`move element > rectangles with binding arrow 7`] = `
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 15,
|
||||
"versionNonce": 271613161,
|
||||
"version": 11,
|
||||
"versionNonce": 1051383431,
|
||||
"width": 81,
|
||||
"x": 110,
|
||||
"y": 50,
|
||||
|
||||
@@ -4307,14 +4307,20 @@ History {
|
||||
"appStateChange": AppStateChange {
|
||||
"delta": Delta {
|
||||
"deleted": {
|
||||
"editingGroupId": null,
|
||||
"selectedElementIds": {
|
||||
"id1": true,
|
||||
},
|
||||
"selectedGroupIds": {
|
||||
"id4": false,
|
||||
},
|
||||
},
|
||||
"inserted": {
|
||||
"editingGroupId": "id4",
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
"selectedGroupIds": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -4337,14 +4343,16 @@ History {
|
||||
"appStateChange": AppStateChange {
|
||||
"delta": Delta {
|
||||
"deleted": {
|
||||
"editingGroupId": null,
|
||||
"selectedElementIds": {},
|
||||
"selectedGroupIds": {},
|
||||
},
|
||||
"inserted": {
|
||||
"editingGroupId": "id4",
|
||||
"selectedElementIds": {
|
||||
"id1": true,
|
||||
},
|
||||
"selectedGroupIds": {
|
||||
"id4": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -120,6 +120,7 @@ describe("contextMenu element", () => {
|
||||
"cut",
|
||||
"copy",
|
||||
"paste",
|
||||
"wrapSelectionInFrame",
|
||||
"copyStyles",
|
||||
"pasteStyles",
|
||||
"deleteSelectedElements",
|
||||
@@ -213,6 +214,7 @@ describe("contextMenu element", () => {
|
||||
"cut",
|
||||
"copy",
|
||||
"paste",
|
||||
"wrapSelectionInFrame",
|
||||
"copyStyles",
|
||||
"pasteStyles",
|
||||
"deleteSelectedElements",
|
||||
@@ -269,6 +271,7 @@ describe("contextMenu element", () => {
|
||||
"cut",
|
||||
"copy",
|
||||
"paste",
|
||||
"wrapSelectionInFrame",
|
||||
"copyStyles",
|
||||
"pasteStyles",
|
||||
"deleteSelectedElements",
|
||||
|
||||
@@ -171,8 +171,8 @@ describe("Crop an image", () => {
|
||||
// test corner handle aspect ratio preserving
|
||||
UI.crop(image, "se", naturalWidth, naturalHeight, [initialWidth, 0], true);
|
||||
expect(image.width / image.height).toBe(resizedWidth / resizedHeight);
|
||||
expect(image.width).toBeLessThanOrEqual(initialWidth);
|
||||
expect(image.height).toBeLessThanOrEqual(initialHeight);
|
||||
expect(image.width).toBeLessThanOrEqual(initialWidth + 0.0001);
|
||||
expect(image.height).toBeLessThanOrEqual(initialHeight + 0.0001);
|
||||
|
||||
// reset
|
||||
image = API.createElement({ type: "image", width: 200, height: 100 });
|
||||
@@ -194,7 +194,7 @@ describe("Crop an image", () => {
|
||||
expect(image.width).toBeCloseTo(image.height);
|
||||
// max height should be reached
|
||||
expect(image.height).toBeCloseTo(initialHeight);
|
||||
expect(image.width).toBe(initialHeight);
|
||||
expect(image.width).toBeCloseTo(initialHeight);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
ExcalidrawMagicFrameElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawArrowElement,
|
||||
FixedSegment,
|
||||
} from "../../element/types";
|
||||
import { newElement, newTextElement, newLinearElement } from "../../element";
|
||||
import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS } from "../../constants";
|
||||
@@ -197,6 +198,7 @@ export class API {
|
||||
? ExcalidrawArrowElement["endArrowhead"] | ExcalidrawElbowArrowElement["endArrowhead"]
|
||||
: never;
|
||||
elbowed?: boolean;
|
||||
fixedSegments?: FixedSegment[] | null;
|
||||
}): T extends "arrow" | "line"
|
||||
? ExcalidrawLinearElement
|
||||
: T extends "freedraw"
|
||||
|
||||
@@ -2077,15 +2077,15 @@ describe("history", () => {
|
||||
storeAction: StoreAction.UPDATE,
|
||||
});
|
||||
|
||||
Keyboard.redo();
|
||||
Keyboard.undo();
|
||||
|
||||
const modifiedArrow = h.elements.filter(
|
||||
(el) => el.type === "arrow",
|
||||
)[0] as ExcalidrawElbowArrowElement;
|
||||
expect(modifiedArrow.points).toEqual([
|
||||
expect(modifiedArrow.points).toCloselyEqualPoints([
|
||||
[0, 0],
|
||||
[451.9000000000001, 0],
|
||||
[451.9000000000001, 448.10100010002003],
|
||||
[178.9, 0],
|
||||
[178.9, 236.1],
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
FontString,
|
||||
SceneElementsMap,
|
||||
} from "../element/types";
|
||||
import { Excalidraw, mutateElement } from "../index";
|
||||
import { reseed } from "../random";
|
||||
@@ -1353,23 +1352,19 @@ describe("Test Linear Elements", () => {
|
||||
const [origStartX, origStartY] = [line.x, line.y];
|
||||
|
||||
act(() => {
|
||||
LinearElementEditor.movePoints(
|
||||
line,
|
||||
[
|
||||
{
|
||||
index: 0,
|
||||
point: pointFrom(line.points[0][0] + 10, line.points[0][1] + 10),
|
||||
},
|
||||
{
|
||||
index: line.points.length - 1,
|
||||
point: pointFrom(
|
||||
line.points[line.points.length - 1][0] - 10,
|
||||
line.points[line.points.length - 1][1] - 10,
|
||||
),
|
||||
},
|
||||
],
|
||||
new Map() as SceneElementsMap,
|
||||
);
|
||||
LinearElementEditor.movePoints(line, [
|
||||
{
|
||||
index: 0,
|
||||
point: pointFrom(line.points[0][0] + 10, line.points[0][1] + 10),
|
||||
},
|
||||
{
|
||||
index: line.points.length - 1,
|
||||
point: pointFrom(
|
||||
line.points[line.points.length - 1][0] - 10,
|
||||
line.points[line.points.length - 1][1] - 10,
|
||||
),
|
||||
},
|
||||
]);
|
||||
});
|
||||
expect(line.x).toBe(origStartX + 10);
|
||||
expect(line.y).toBe(origStartY + 10);
|
||||
|
||||
@@ -535,7 +535,7 @@ describe("arrow element", () => {
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { STORAGE_KEYS } from "../../../excalidraw-app/app_constants";
|
||||
import { getSelectedElements } from "../scene/selection";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import { UI } from "./helpers/ui";
|
||||
import { diffStringsUnified } from "jest-diff";
|
||||
|
||||
const customQueries = {
|
||||
...queries,
|
||||
@@ -246,6 +247,36 @@ expect.extend({
|
||||
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
Reference in New Issue
Block a user