Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64e43bf7c7 | ||
|
|
9d24003517 | ||
|
|
91ebf8b0ea | ||
|
|
8551823da9 | ||
|
|
ae6bee3403 | ||
|
|
46f42ef8d7 | ||
|
|
00b5b0a0ca | ||
|
|
c92f3bebf5 | ||
|
|
2ac55067cd | ||
|
|
78ab12c7e6 | ||
|
|
f2f8219917 | ||
|
|
12c39d1034 | ||
|
|
d33e42e3a1 | ||
|
|
3b9ffd9586 | ||
|
|
b63689c230 | ||
|
|
c84babf574 | ||
|
|
36274f1f3e |
+15
-1
@@ -3,6 +3,20 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"import/no-anonymous-default-export": "off",
|
"import/no-anonymous-default-export": "off",
|
||||||
"no-restricted-globals": "off",
|
"no-restricted-globals": "off",
|
||||||
"@typescript-eslint/consistent-type-imports": ["error", { "prefer": "type-imports", "disallowTypeAnnotations": false, "fixStyle": "separate-type-imports" }]
|
"@typescript-eslint/consistent-type-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"prefer": "type-imports",
|
||||||
|
"disallowTypeAnnotations": false,
|
||||||
|
"fixStyle": "separate-type-imports"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"no-restricted-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"name": "jotai",
|
||||||
|
"message": "Do not import from \"jotai\" directly. Use our app-specific modules (\"editor-jotai\" or \"app-jotai\")."
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-7
@@ -90,9 +90,13 @@ import {
|
|||||||
import { AppMainMenu } from "./components/AppMainMenu";
|
import { AppMainMenu } from "./components/AppMainMenu";
|
||||||
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
|
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
|
||||||
import { AppFooter } from "./components/AppFooter";
|
import { AppFooter } from "./components/AppFooter";
|
||||||
import { Provider, useAtom, useAtomValue } from "jotai";
|
import {
|
||||||
import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
|
Provider,
|
||||||
import { appJotaiStore } from "./app-jotai";
|
useAtom,
|
||||||
|
useAtomValue,
|
||||||
|
useAtomWithInitialValue,
|
||||||
|
appJotaiStore,
|
||||||
|
} from "./app-jotai";
|
||||||
|
|
||||||
import "./index.scss";
|
import "./index.scss";
|
||||||
import type { ResolutionType } from "../packages/excalidraw/utility-types";
|
import type { ResolutionType } from "../packages/excalidraw/utility-types";
|
||||||
@@ -117,7 +121,7 @@ import {
|
|||||||
share,
|
share,
|
||||||
youtubeIcon,
|
youtubeIcon,
|
||||||
} from "../packages/excalidraw/components/icons";
|
} from "../packages/excalidraw/components/icons";
|
||||||
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
|
import { useHandleAppTheme } from "./useHandleAppTheme";
|
||||||
import { getPreferredLanguage } from "./app-language/language-detector";
|
import { getPreferredLanguage } from "./app-language/language-detector";
|
||||||
import { useAppLangCode } from "./app-language/language-state";
|
import { useAppLangCode } from "./app-language/language-state";
|
||||||
import DebugCanvas, {
|
import DebugCanvas, {
|
||||||
@@ -328,8 +332,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
const isCollabDisabled = isRunningInIframe();
|
const isCollabDisabled = isRunningInIframe();
|
||||||
|
|
||||||
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
|
const { editorTheme, appTheme, setAppTheme } = useHandleAppTheme();
|
||||||
const { editorTheme } = useHandleAppTheme();
|
|
||||||
|
|
||||||
const [langCode, setLangCode] = useAppLangCode();
|
const [langCode, setLangCode] = useAppLangCode();
|
||||||
|
|
||||||
@@ -1141,7 +1144,7 @@ const ExcalidrawApp = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TopErrorBoundary>
|
<TopErrorBoundary>
|
||||||
<Provider unstable_createStore={() => appJotaiStore}>
|
<Provider store={appJotaiStore}>
|
||||||
<ExcalidrawWrapper />
|
<ExcalidrawWrapper />
|
||||||
</Provider>
|
</Provider>
|
||||||
</TopErrorBoundary>
|
</TopErrorBoundary>
|
||||||
|
|||||||
@@ -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 React from "react";
|
||||||
import { useI18n, languages } from "../../packages/excalidraw/i18n";
|
import { useI18n, languages } from "../../packages/excalidraw/i18n";
|
||||||
|
import { useSetAtom } from "../app-jotai";
|
||||||
import { appLangCodeAtom } from "./language-state";
|
import { appLangCodeAtom } from "./language-state";
|
||||||
|
|
||||||
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
|
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { atom, useAtom } from "jotai";
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { atom, useAtom } from "../app-jotai";
|
||||||
import { getPreferredLanguage, languageDetector } from "./language-detector";
|
import { getPreferredLanguage, languageDetector } from "./language-detector";
|
||||||
|
|
||||||
export const appLangCodeAtom = atom(getPreferredLanguage());
|
export const appLangCodeAtom = atom(getPreferredLanguage());
|
||||||
|
|||||||
@@ -79,8 +79,7 @@ import { newElementWith } from "../../packages/excalidraw/element/mutateElement"
|
|||||||
import { decryptData } from "../../packages/excalidraw/data/encryption";
|
import { decryptData } from "../../packages/excalidraw/data/encryption";
|
||||||
import { resetBrowserStateVersions } from "../data/tabSync";
|
import { resetBrowserStateVersions } from "../data/tabSync";
|
||||||
import { LocalData } from "../data/LocalData";
|
import { LocalData } from "../data/LocalData";
|
||||||
import { atom } from "jotai";
|
import { appJotaiStore, atom } from "../app-jotai";
|
||||||
import { appJotaiStore } from "../app-jotai";
|
|
||||||
import type { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
|
import type { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
|
||||||
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
|
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
|
||||||
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
|
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { Tooltip } from "../../packages/excalidraw/components/Tooltip";
|
|||||||
import { warning } from "../../packages/excalidraw/components/icons";
|
import { warning } from "../../packages/excalidraw/components/icons";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { atom } from "../app-jotai";
|
||||||
|
|
||||||
import "./CollabError.scss";
|
import "./CollabError.scss";
|
||||||
import { atom } from "jotai";
|
|
||||||
|
|
||||||
type ErrorIndicator = {
|
type ErrorIndicator = {
|
||||||
message: string | null;
|
message: string | null;
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"firebase": "8.3.3",
|
"firebase": "8.3.3",
|
||||||
"i18next-browser-languagedetector": "6.1.4",
|
"i18next-browser-languagedetector": "6.1.4",
|
||||||
"idb-keyval": "6.0.3",
|
"idb-keyval": "6.0.3",
|
||||||
"jotai": "1.13.1",
|
"jotai": "2.11.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"socket.io-client": "4.7.2",
|
"socket.io-client": "4.7.2",
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ import { TextField } from "../../packages/excalidraw/components/TextField";
|
|||||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
||||||
import type { CollabAPI } from "../collab/Collab";
|
import type { CollabAPI } from "../collab/Collab";
|
||||||
import { activeRoomLinkAtom } from "../collab/Collab";
|
import { activeRoomLinkAtom } from "../collab/Collab";
|
||||||
import { atom, useAtom, useAtomValue } from "jotai";
|
|
||||||
|
|
||||||
import "./ShareDialog.scss";
|
|
||||||
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
|
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
|
||||||
import { useCopyStatus } from "../../packages/excalidraw/hooks/useCopiedIndicator";
|
import { useCopyStatus } from "../../packages/excalidraw/hooks/useCopiedIndicator";
|
||||||
|
import { atom, useAtom, useAtomValue } from "../app-jotai";
|
||||||
|
|
||||||
|
import "./ShareDialog.scss";
|
||||||
|
|
||||||
type OnExportToBackend = () => void;
|
type OnExportToBackend = () => void;
|
||||||
type ShareDialogType = "share" | "collaborationOnly";
|
type ShareDialogType = "share" | "collaborationOnly";
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { atom, useAtom } from "jotai";
|
|
||||||
import { useEffect, useLayoutEffect, useState } from "react";
|
import { useEffect, useLayoutEffect, useState } from "react";
|
||||||
import { THEME } from "../packages/excalidraw";
|
import { THEME } from "../packages/excalidraw";
|
||||||
import { EVENT } from "../packages/excalidraw/constants";
|
import { EVENT } from "../packages/excalidraw/constants";
|
||||||
@@ -6,18 +5,18 @@ import type { Theme } from "../packages/excalidraw/element/types";
|
|||||||
import { CODES, KEYS } from "../packages/excalidraw/keys";
|
import { CODES, KEYS } from "../packages/excalidraw/keys";
|
||||||
import { STORAGE_KEYS } from "./app_constants";
|
import { STORAGE_KEYS } from "./app_constants";
|
||||||
|
|
||||||
export const appThemeAtom = atom<Theme | "system">(
|
|
||||||
(localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_THEME) as
|
|
||||||
| Theme
|
|
||||||
| "system"
|
|
||||||
| null) || THEME.LIGHT,
|
|
||||||
);
|
|
||||||
|
|
||||||
const getDarkThemeMediaQuery = (): MediaQueryList | undefined =>
|
const getDarkThemeMediaQuery = (): MediaQueryList | undefined =>
|
||||||
window.matchMedia?.("(prefers-color-scheme: dark)");
|
window.matchMedia?.("(prefers-color-scheme: dark)");
|
||||||
|
|
||||||
export const useHandleAppTheme = () => {
|
export const useHandleAppTheme = () => {
|
||||||
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
|
const [appTheme, setAppTheme] = useState<Theme | "system">(() => {
|
||||||
|
return (
|
||||||
|
(localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_THEME) as
|
||||||
|
| Theme
|
||||||
|
| "system"
|
||||||
|
| null) || THEME.LIGHT
|
||||||
|
);
|
||||||
|
});
|
||||||
const [editorTheme, setEditorTheme] = useState<Theme>(THEME.LIGHT);
|
const [editorTheme, setEditorTheme] = useState<Theme>(THEME.LIGHT);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -66,5 +65,5 @@ export const useHandleAppTheme = () => {
|
|||||||
}
|
}
|
||||||
}, [appTheme]);
|
}, [appTheme]);
|
||||||
|
|
||||||
return { editorTheme };
|
return { editorTheme, appTheme, setAppTheme };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { getNonDeletedElements } from "../element";
|
|||||||
import type { ExcalidrawElement } from "../element/types";
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
import type { AppClassProperties, AppState } from "../types";
|
import type { AppClassProperties, AppState } from "../types";
|
||||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||||
import { getElementsInGroup } from "../groups";
|
import { getElementsInGroup, selectGroupsForSelectedElements } from "../groups";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||||
import {
|
import {
|
||||||
@@ -18,14 +18,12 @@ import {
|
|||||||
import { updateActiveTool } from "../utils";
|
import { updateActiveTool } from "../utils";
|
||||||
import { TrashIcon } from "../components/icons";
|
import { TrashIcon } from "../components/icons";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
import { mutateElbowArrow } from "../element/routing";
|
|
||||||
|
|
||||||
const deleteSelectedElements = (
|
const deleteSelectedElements = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
) => {
|
) => {
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
|
||||||
const framesToBeDeleted = new Set(
|
const framesToBeDeleted = new Set(
|
||||||
getSelectedElements(
|
getSelectedElements(
|
||||||
elements.filter((el) => isFrameLikeElement(el)),
|
elements.filter((el) => isFrameLikeElement(el)),
|
||||||
@@ -33,48 +31,99 @@ const deleteSelectedElements = (
|
|||||||
).map((el) => el.id),
|
).map((el) => el.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
const selectedElementIds: Record<ExcalidrawElement["id"], true> = {};
|
||||||
elements: elements.map((el) => {
|
|
||||||
if (appState.selectedElementIds[el.id]) {
|
let shouldSelectEditingGroup = true;
|
||||||
if (el.boundElements) {
|
|
||||||
el.boundElements.forEach((candidate) => {
|
const nextElements = elements.map((el) => {
|
||||||
const bound = app.scene
|
if (appState.selectedElementIds[el.id]) {
|
||||||
.getNonDeletedElementsMap()
|
if (el.boundElements) {
|
||||||
.get(candidate.id);
|
el.boundElements.forEach((candidate) => {
|
||||||
if (bound && isElbowArrow(bound)) {
|
const bound = app.scene.getNonDeletedElementsMap().get(candidate.id);
|
||||||
mutateElement(bound, {
|
if (bound && isElbowArrow(bound)) {
|
||||||
startBinding:
|
mutateElement(bound, {
|
||||||
el.id === bound.startBinding?.elementId
|
startBinding:
|
||||||
? null
|
el.id === bound.startBinding?.elementId
|
||||||
: bound.startBinding,
|
? null
|
||||||
endBinding:
|
: bound.startBinding,
|
||||||
el.id === bound.endBinding?.elementId
|
endBinding:
|
||||||
? null
|
el.id === bound.endBinding?.elementId ? null : bound.endBinding,
|
||||||
: bound.endBinding,
|
});
|
||||||
});
|
mutateElement(bound, { points: bound.points });
|
||||||
mutateElbowArrow(bound, elementsMap, bound.points);
|
}
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
|
return newElementWith(el, { isDeleted: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// if deleting a frame, remove the children from it and select them
|
||||||
|
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
|
||||||
|
shouldSelectEditingGroup = false;
|
||||||
|
selectedElementIds[el.id] = true;
|
||||||
|
return newElementWith(el, { frameId: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBoundToContainer(el) && appState.selectedElementIds[el.containerId]) {
|
||||||
|
return newElementWith(el, { isDeleted: true });
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
});
|
||||||
|
|
||||||
|
let nextEditingGroupId = appState.editingGroupId;
|
||||||
|
|
||||||
|
// select next eligible element in currently editing group or supergroup
|
||||||
|
if (shouldSelectEditingGroup && appState.editingGroupId) {
|
||||||
|
const elems = getElementsInGroup(
|
||||||
|
nextElements,
|
||||||
|
appState.editingGroupId,
|
||||||
|
).filter((el) => !el.isDeleted);
|
||||||
|
if (elems.length > 1) {
|
||||||
|
if (elems[0]) {
|
||||||
|
selectedElementIds[elems[0].id] = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nextEditingGroupId = null;
|
||||||
|
if (elems[0]) {
|
||||||
|
selectedElementIds[elems[0].id] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastElementInGroup = elems[0];
|
||||||
|
if (lastElementInGroup) {
|
||||||
|
const editingGroupIdx = lastElementInGroup.groupIds.findIndex(
|
||||||
|
(groupId) => {
|
||||||
|
return groupId === appState.editingGroupId;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const superGroupId = lastElementInGroup.groupIds[editingGroupIdx + 1];
|
||||||
|
if (superGroupId) {
|
||||||
|
const elems = getElementsInGroup(nextElements, superGroupId).filter(
|
||||||
|
(el) => !el.isDeleted,
|
||||||
|
);
|
||||||
|
if (elems.length > 1) {
|
||||||
|
nextEditingGroupId = superGroupId;
|
||||||
|
|
||||||
|
elems.forEach((el) => {
|
||||||
|
selectedElementIds[el.id] = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return newElementWith(el, { isDeleted: true });
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
|
return {
|
||||||
return newElementWith(el, { isDeleted: true });
|
elements: nextElements,
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
isBoundToContainer(el) &&
|
|
||||||
appState.selectedElementIds[el.containerId]
|
|
||||||
) {
|
|
||||||
return newElementWith(el, { isDeleted: true });
|
|
||||||
}
|
|
||||||
return el;
|
|
||||||
}),
|
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
selectedElementIds: {},
|
...selectGroupsForSelectedElements(
|
||||||
selectedGroupIds: {},
|
{
|
||||||
|
selectedElementIds,
|
||||||
|
editingGroupId: nextEditingGroupId,
|
||||||
|
},
|
||||||
|
nextElements,
|
||||||
|
appState,
|
||||||
|
null,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -157,12 +206,7 @@ export const actionDeleteSelected = register({
|
|||||||
: endBindingElement,
|
: endBindingElement,
|
||||||
};
|
};
|
||||||
|
|
||||||
LinearElementEditor.deletePoints(
|
LinearElementEditor.deletePoints(element, selectedPointsIndices);
|
||||||
element,
|
|
||||||
selectedPointsIndices,
|
|
||||||
elementsMap,
|
|
||||||
appState.zoom,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
|
|||||||
@@ -49,12 +49,13 @@ describe("flipping re-centers selection", () => {
|
|||||||
},
|
},
|
||||||
startArrowhead: null,
|
startArrowhead: null,
|
||||||
endArrowhead: "arrow",
|
endArrowhead: "arrow",
|
||||||
|
fixedSegments: null,
|
||||||
points: [
|
points: [
|
||||||
pointFrom(0, 0),
|
pointFrom(0, 0),
|
||||||
pointFrom(0, -35),
|
pointFrom(0, -35),
|
||||||
pointFrom(-90.9, -35),
|
pointFrom(-90, -35),
|
||||||
pointFrom(-90.9, 204.9),
|
pointFrom(-90, 204),
|
||||||
pointFrom(65.1, 204.9),
|
pointFrom(66, 204),
|
||||||
],
|
],
|
||||||
elbowed: true,
|
elbowed: true,
|
||||||
}),
|
}),
|
||||||
@@ -70,13 +71,13 @@ describe("flipping re-centers selection", () => {
|
|||||||
API.executeAction(actionFlipHorizontal);
|
API.executeAction(actionFlipHorizontal);
|
||||||
API.executeAction(actionFlipHorizontal);
|
API.executeAction(actionFlipHorizontal);
|
||||||
|
|
||||||
const rec1 = h.elements.find((el) => el.id === "rec1");
|
const rec1 = h.elements.find((el) => el.id === "rec1")!;
|
||||||
expect(rec1?.x).toBeCloseTo(100);
|
expect(rec1.x).toBeCloseTo(100, 0);
|
||||||
expect(rec1?.y).toBeCloseTo(100);
|
expect(rec1.y).toBeCloseTo(100, 0);
|
||||||
|
|
||||||
const rec2 = h.elements.find((el) => el.id === "rec2");
|
const rec2 = h.elements.find((el) => el.id === "rec2")!;
|
||||||
expect(rec2?.x).toBeCloseTo(220);
|
expect(rec2.x).toBeCloseTo(220, 0);
|
||||||
expect(rec2?.y).toBeCloseTo(250);
|
expect(rec2.y).toBeCloseTo(250, 0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ import {
|
|||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
} from "../element/typeChecks";
|
} from "../element/typeChecks";
|
||||||
import { mutateElbowArrow } from "../element/routing";
|
|
||||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||||
|
import { deepCopyElement } from "../element/newElement";
|
||||||
import { getCommonBoundingBox } from "../element/bounds";
|
import { getCommonBoundingBox } from "../element/bounds";
|
||||||
|
|
||||||
export const actionFlipHorizontal = register({
|
export const actionFlipHorizontal = register({
|
||||||
@@ -134,12 +134,24 @@ const flipElements = (
|
|||||||
|
|
||||||
const { midX, midY } = getCommonBoundingBox(selectedElements);
|
const { midX, midY } = getCommonBoundingBox(selectedElements);
|
||||||
|
|
||||||
resizeMultipleElements(selectedElements, elementsMap, "nw", app.scene, {
|
resizeMultipleElements(
|
||||||
flipByX: flipDirection === "horizontal",
|
selectedElements,
|
||||||
flipByY: flipDirection === "vertical",
|
elementsMap,
|
||||||
shouldResizeFromCenter: true,
|
"nw",
|
||||||
shouldMaintainAspectRatio: true,
|
app.scene,
|
||||||
});
|
new Map(
|
||||||
|
Array.from(elementsMap.values()).map((element) => [
|
||||||
|
element.id,
|
||||||
|
deepCopyElement(element),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
flipByX: flipDirection === "horizontal",
|
||||||
|
flipByY: flipDirection === "vertical",
|
||||||
|
shouldResizeFromCenter: true,
|
||||||
|
shouldMaintainAspectRatio: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
bindOrUnbindLinearElements(
|
bindOrUnbindLinearElements(
|
||||||
selectedElements.filter(isLinearElement),
|
selectedElements.filter(isLinearElement),
|
||||||
@@ -181,16 +193,10 @@ const flipElements = (
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
elbowArrows.forEach((element) =>
|
elbowArrows.forEach((element) =>
|
||||||
mutateElbowArrow(
|
mutateElement(element, {
|
||||||
element,
|
x: element.x + diffX,
|
||||||
elementsMap,
|
y: element.y + diffY,
|
||||||
element.points,
|
}),
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
informMutation: false,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getNonDeletedElements } from "../element";
|
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||||
import type { ExcalidrawElement } from "../element/types";
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
import { removeAllElementsFromFrame } from "../frame";
|
import { addElementsToFrame, removeAllElementsFromFrame } from "../frame";
|
||||||
import { getFrameChildren } from "../frame";
|
import { getFrameChildren } from "../frame";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import type { AppClassProperties, AppState, UIAppState } from "../types";
|
import type { AppClassProperties, AppState, UIAppState } from "../types";
|
||||||
@@ -10,6 +10,8 @@ import { register } from "./register";
|
|||||||
import { isFrameLikeElement } from "../element/typeChecks";
|
import { isFrameLikeElement } from "../element/typeChecks";
|
||||||
import { frameToolIcon } from "../components/icons";
|
import { frameToolIcon } from "../components/icons";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
|
import { getSelectedElements } from "../scene";
|
||||||
|
import { newFrameElement } from "../element/newElement";
|
||||||
|
|
||||||
const isSingleFrameSelected = (
|
const isSingleFrameSelected = (
|
||||||
appState: UIAppState,
|
appState: UIAppState,
|
||||||
@@ -144,3 +146,46 @@ export const actionSetFrameAsActiveTool = register({
|
|||||||
!event.altKey &&
|
!event.altKey &&
|
||||||
event.key.toLocaleLowerCase() === KEYS.F,
|
event.key.toLocaleLowerCase() === KEYS.F,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const actionWrapSelectionInFrame = register({
|
||||||
|
name: "wrapSelectionInFrame",
|
||||||
|
label: "labels.wrapSelectionInFrame",
|
||||||
|
trackEvent: { category: "element" },
|
||||||
|
predicate: (elements, appState, _, app) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
|
||||||
|
return (
|
||||||
|
selectedElements.length > 0 &&
|
||||||
|
!selectedElements.some((element) => isFrameLikeElement(element))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
perform: (elements, appState, _, app) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
|
||||||
|
const [x1, y1, x2, y2] = getCommonBounds(
|
||||||
|
selectedElements,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
const PADDING = 16;
|
||||||
|
const frame = newFrameElement({
|
||||||
|
x: x1 - PADDING,
|
||||||
|
y: y1 - PADDING,
|
||||||
|
width: x2 - x1 + PADDING * 2,
|
||||||
|
height: y2 - y1 + PADDING * 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextElements = addElementsToFrame(
|
||||||
|
[...app.scene.getElementsIncludingDeleted(), frame],
|
||||||
|
selectedElements,
|
||||||
|
frame,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
elements: nextElements,
|
||||||
|
appState: {
|
||||||
|
selectedElementIds: { [frame.id]: true },
|
||||||
|
},
|
||||||
|
storeAction: StoreAction.CAPTURE,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ import {
|
|||||||
sharpArrowIcon,
|
sharpArrowIcon,
|
||||||
roundArrowIcon,
|
roundArrowIcon,
|
||||||
elbowArrowIcon,
|
elbowArrowIcon,
|
||||||
|
ArrowheadCrowfootIcon,
|
||||||
|
ArrowheadCrowfootOneIcon,
|
||||||
|
ArrowheadCrowfootOneOrManyIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import {
|
import {
|
||||||
ARROW_TYPE,
|
ARROW_TYPE,
|
||||||
@@ -113,10 +116,9 @@ import {
|
|||||||
calculateFixedPointForElbowArrowBinding,
|
calculateFixedPointForElbowArrowBinding,
|
||||||
getHoveredElementForBinding,
|
getHoveredElementForBinding,
|
||||||
} from "../element/binding";
|
} from "../element/binding";
|
||||||
import { mutateElbowArrow } from "../element/routing";
|
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import type { LocalPoint } from "../../math";
|
import type { LocalPoint } from "../../math";
|
||||||
import { pointFrom, vector } from "../../math";
|
import { pointFrom } from "../../math";
|
||||||
|
|
||||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||||
|
|
||||||
@@ -1405,59 +1407,65 @@ const getArrowheadOptions = (flip: boolean) => {
|
|||||||
keyBinding: "w",
|
keyBinding: "w",
|
||||||
icon: <ArrowheadArrowIcon flip={flip} />,
|
icon: <ArrowheadArrowIcon flip={flip} />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
value: "bar",
|
|
||||||
text: t("labels.arrowhead_bar"),
|
|
||||||
keyBinding: "e",
|
|
||||||
icon: <ArrowheadBarIcon flip={flip} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "dot",
|
|
||||||
text: t("labels.arrowhead_circle"),
|
|
||||||
keyBinding: null,
|
|
||||||
icon: <ArrowheadCircleIcon flip={flip} />,
|
|
||||||
showInPicker: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "circle",
|
|
||||||
text: t("labels.arrowhead_circle"),
|
|
||||||
keyBinding: "r",
|
|
||||||
icon: <ArrowheadCircleIcon flip={flip} />,
|
|
||||||
showInPicker: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "circle_outline",
|
|
||||||
text: t("labels.arrowhead_circle_outline"),
|
|
||||||
keyBinding: null,
|
|
||||||
icon: <ArrowheadCircleOutlineIcon flip={flip} />,
|
|
||||||
showInPicker: false,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
value: "triangle",
|
value: "triangle",
|
||||||
text: t("labels.arrowhead_triangle"),
|
text: t("labels.arrowhead_triangle"),
|
||||||
icon: <ArrowheadTriangleIcon flip={flip} />,
|
icon: <ArrowheadTriangleIcon flip={flip} />,
|
||||||
keyBinding: "t",
|
keyBinding: "e",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "triangle_outline",
|
value: "triangle_outline",
|
||||||
text: t("labels.arrowhead_triangle_outline"),
|
text: t("labels.arrowhead_triangle_outline"),
|
||||||
icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
|
icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
|
||||||
keyBinding: null,
|
keyBinding: "r",
|
||||||
showInPicker: false,
|
},
|
||||||
|
{
|
||||||
|
value: "circle",
|
||||||
|
text: t("labels.arrowhead_circle"),
|
||||||
|
keyBinding: "a",
|
||||||
|
icon: <ArrowheadCircleIcon flip={flip} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "circle_outline",
|
||||||
|
text: t("labels.arrowhead_circle_outline"),
|
||||||
|
keyBinding: "s",
|
||||||
|
icon: <ArrowheadCircleOutlineIcon flip={flip} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "diamond",
|
value: "diamond",
|
||||||
text: t("labels.arrowhead_diamond"),
|
text: t("labels.arrowhead_diamond"),
|
||||||
icon: <ArrowheadDiamondIcon flip={flip} />,
|
icon: <ArrowheadDiamondIcon flip={flip} />,
|
||||||
keyBinding: null,
|
keyBinding: "d",
|
||||||
showInPicker: false,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "diamond_outline",
|
value: "diamond_outline",
|
||||||
text: t("labels.arrowhead_diamond_outline"),
|
text: t("labels.arrowhead_diamond_outline"),
|
||||||
icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
|
icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
|
||||||
keyBinding: null,
|
keyBinding: "f",
|
||||||
showInPicker: false,
|
},
|
||||||
|
{
|
||||||
|
value: "bar",
|
||||||
|
text: t("labels.arrowhead_bar"),
|
||||||
|
keyBinding: "z",
|
||||||
|
icon: <ArrowheadBarIcon flip={flip} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "crowfoot_one",
|
||||||
|
text: t("labels.arrowhead_crowfoot_one"),
|
||||||
|
icon: <ArrowheadCrowfootOneIcon flip={flip} />,
|
||||||
|
keyBinding: "c",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "crowfoot_many",
|
||||||
|
text: t("labels.arrowhead_crowfoot_many"),
|
||||||
|
icon: <ArrowheadCrowfootIcon flip={flip} />,
|
||||||
|
keyBinding: "x",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "crowfoot_one_or_many",
|
||||||
|
text: t("labels.arrowhead_crowfoot_one_or_many"),
|
||||||
|
icon: <ArrowheadCrowfootOneOrManyIcon flip={flip} />,
|
||||||
|
keyBinding: "v",
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
};
|
};
|
||||||
@@ -1521,6 +1529,7 @@ export const actionChangeArrowhead = register({
|
|||||||
appState.currentItemStartArrowhead,
|
appState.currentItemStartArrowhead,
|
||||||
)}
|
)}
|
||||||
onChange={(value) => updateData({ position: "start", type: value })}
|
onChange={(value) => updateData({ position: "start", type: value })}
|
||||||
|
numberOfOptionsToAlwaysShow={4}
|
||||||
/>
|
/>
|
||||||
<IconPicker
|
<IconPicker
|
||||||
label="arrowhead_end"
|
label="arrowhead_end"
|
||||||
@@ -1537,6 +1546,7 @@ export const actionChangeArrowhead = register({
|
|||||||
appState.currentItemEndArrowhead,
|
appState.currentItemEndArrowhead,
|
||||||
)}
|
)}
|
||||||
onChange={(value) => updateData({ position: "end", type: value })}
|
onChange={(value) => updateData({ position: "end", type: value })}
|
||||||
|
numberOfOptionsToAlwaysShow={4}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -1549,152 +1559,162 @@ export const actionChangeArrowType = register({
|
|||||||
label: "Change arrow types",
|
label: "Change arrow types",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value, app) => {
|
perform: (elements, appState, value, app) => {
|
||||||
return {
|
const newElements = changeProperty(elements, appState, (el) => {
|
||||||
elements: changeProperty(elements, appState, (el) => {
|
if (!isArrowElement(el)) {
|
||||||
if (!isArrowElement(el)) {
|
return el;
|
||||||
return el;
|
}
|
||||||
}
|
const newElement = newElementWith(el, {
|
||||||
const newElement = newElementWith(el, {
|
roundness:
|
||||||
roundness:
|
value === ARROW_TYPE.round
|
||||||
value === ARROW_TYPE.round
|
? {
|
||||||
? {
|
type: ROUNDNESS.PROPORTIONAL_RADIUS,
|
||||||
type: ROUNDNESS.PROPORTIONAL_RADIUS,
|
}
|
||||||
}
|
: null,
|
||||||
: null,
|
elbowed: value === ARROW_TYPE.elbow,
|
||||||
elbowed: value === ARROW_TYPE.elbow,
|
points:
|
||||||
points:
|
value === ARROW_TYPE.elbow || el.elbowed
|
||||||
value === ARROW_TYPE.elbow || el.elbowed
|
? [el.points[0], el.points[el.points.length - 1]]
|
||||||
? [el.points[0], el.points[el.points.length - 1]]
|
: el.points,
|
||||||
: el.points,
|
});
|
||||||
|
|
||||||
|
if (isElbowArrow(newElement)) {
|
||||||
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
|
app.dismissLinearEditor();
|
||||||
|
|
||||||
|
const startGlobalPoint =
|
||||||
|
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||||
|
newElement,
|
||||||
|
0,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
const endGlobalPoint =
|
||||||
|
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||||
|
newElement,
|
||||||
|
-1,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
const startHoveredElement =
|
||||||
|
!newElement.startBinding &&
|
||||||
|
getHoveredElementForBinding(
|
||||||
|
tupleToCoors(startGlobalPoint),
|
||||||
|
elements,
|
||||||
|
elementsMap,
|
||||||
|
appState.zoom,
|
||||||
|
);
|
||||||
|
const endHoveredElement =
|
||||||
|
!newElement.endBinding &&
|
||||||
|
getHoveredElementForBinding(
|
||||||
|
tupleToCoors(endGlobalPoint),
|
||||||
|
elements,
|
||||||
|
elementsMap,
|
||||||
|
appState.zoom,
|
||||||
|
);
|
||||||
|
const startElement = startHoveredElement
|
||||||
|
? startHoveredElement
|
||||||
|
: newElement.startBinding &&
|
||||||
|
(elementsMap.get(
|
||||||
|
newElement.startBinding.elementId,
|
||||||
|
) as ExcalidrawBindableElement);
|
||||||
|
const endElement = endHoveredElement
|
||||||
|
? endHoveredElement
|
||||||
|
: newElement.endBinding &&
|
||||||
|
(elementsMap.get(
|
||||||
|
newElement.endBinding.elementId,
|
||||||
|
) as ExcalidrawBindableElement);
|
||||||
|
|
||||||
|
const finalStartPoint = startHoveredElement
|
||||||
|
? bindPointToSnapToElementOutline(
|
||||||
|
startGlobalPoint,
|
||||||
|
endGlobalPoint,
|
||||||
|
startHoveredElement,
|
||||||
|
elementsMap,
|
||||||
|
)
|
||||||
|
: startGlobalPoint;
|
||||||
|
const finalEndPoint = endHoveredElement
|
||||||
|
? bindPointToSnapToElementOutline(
|
||||||
|
endGlobalPoint,
|
||||||
|
startGlobalPoint,
|
||||||
|
endHoveredElement,
|
||||||
|
elementsMap,
|
||||||
|
)
|
||||||
|
: endGlobalPoint;
|
||||||
|
|
||||||
|
startHoveredElement &&
|
||||||
|
bindLinearElement(
|
||||||
|
newElement,
|
||||||
|
startHoveredElement,
|
||||||
|
"start",
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
endHoveredElement &&
|
||||||
|
bindLinearElement(newElement, endHoveredElement, "end", elementsMap);
|
||||||
|
|
||||||
|
mutateElement(newElement, {
|
||||||
|
points: [finalStartPoint, finalEndPoint].map(
|
||||||
|
(p): LocalPoint =>
|
||||||
|
pointFrom(p[0] - newElement.x, p[1] - newElement.y),
|
||||||
|
),
|
||||||
|
...(startElement && newElement.startBinding
|
||||||
|
? {
|
||||||
|
startBinding: {
|
||||||
|
// @ts-ignore TS cannot discern check above
|
||||||
|
...newElement.startBinding!,
|
||||||
|
...calculateFixedPointForElbowArrowBinding(
|
||||||
|
newElement,
|
||||||
|
startElement,
|
||||||
|
"start",
|
||||||
|
elementsMap,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(endElement && newElement.endBinding
|
||||||
|
? {
|
||||||
|
endBinding: {
|
||||||
|
// @ts-ignore TS cannot discern check above
|
||||||
|
...newElement.endBinding,
|
||||||
|
...calculateFixedPointForElbowArrowBinding(
|
||||||
|
newElement,
|
||||||
|
endElement,
|
||||||
|
"end",
|
||||||
|
elementsMap,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isElbowArrow(newElement)) {
|
LinearElementEditor.updateEditorMidPointsCache(
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
newElement,
|
||||||
|
elementsMap,
|
||||||
|
app.state,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
app.dismissLinearEditor();
|
return newElement;
|
||||||
|
});
|
||||||
|
|
||||||
const startGlobalPoint =
|
const newState = {
|
||||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
...appState,
|
||||||
newElement,
|
currentItemArrowType: value,
|
||||||
0,
|
};
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
const endGlobalPoint =
|
|
||||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
|
||||||
newElement,
|
|
||||||
-1,
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
const startHoveredElement =
|
|
||||||
!newElement.startBinding &&
|
|
||||||
getHoveredElementForBinding(
|
|
||||||
tupleToCoors(startGlobalPoint),
|
|
||||||
elements,
|
|
||||||
elementsMap,
|
|
||||||
appState.zoom,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const endHoveredElement =
|
|
||||||
!newElement.endBinding &&
|
|
||||||
getHoveredElementForBinding(
|
|
||||||
tupleToCoors(endGlobalPoint),
|
|
||||||
elements,
|
|
||||||
elementsMap,
|
|
||||||
appState.zoom,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const startElement = startHoveredElement
|
|
||||||
? startHoveredElement
|
|
||||||
: newElement.startBinding &&
|
|
||||||
(elementsMap.get(
|
|
||||||
newElement.startBinding.elementId,
|
|
||||||
) as ExcalidrawBindableElement);
|
|
||||||
const endElement = endHoveredElement
|
|
||||||
? endHoveredElement
|
|
||||||
: newElement.endBinding &&
|
|
||||||
(elementsMap.get(
|
|
||||||
newElement.endBinding.elementId,
|
|
||||||
) as ExcalidrawBindableElement);
|
|
||||||
|
|
||||||
const finalStartPoint = startHoveredElement
|
// Change the arrow type and update any other state settings for
|
||||||
? bindPointToSnapToElementOutline(
|
// the arrow.
|
||||||
startGlobalPoint,
|
const selectedId = appState.selectedLinearElement?.elementId;
|
||||||
endGlobalPoint,
|
if (selectedId) {
|
||||||
startHoveredElement,
|
const selected = newElements.find((el) => el.id === selectedId);
|
||||||
elementsMap,
|
if (selected) {
|
||||||
)
|
newState.selectedLinearElement = new LinearElementEditor(
|
||||||
: startGlobalPoint;
|
selected as ExcalidrawLinearElement,
|
||||||
const finalEndPoint = endHoveredElement
|
);
|
||||||
? bindPointToSnapToElementOutline(
|
}
|
||||||
endGlobalPoint,
|
}
|
||||||
startGlobalPoint,
|
|
||||||
endHoveredElement,
|
|
||||||
elementsMap,
|
|
||||||
)
|
|
||||||
: endGlobalPoint;
|
|
||||||
|
|
||||||
startHoveredElement &&
|
return {
|
||||||
bindLinearElement(
|
elements: newElements,
|
||||||
newElement,
|
appState: newState,
|
||||||
startHoveredElement,
|
|
||||||
"start",
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
endHoveredElement &&
|
|
||||||
bindLinearElement(
|
|
||||||
newElement,
|
|
||||||
endHoveredElement,
|
|
||||||
"end",
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
mutateElbowArrow(
|
|
||||||
newElement,
|
|
||||||
elementsMap,
|
|
||||||
[finalStartPoint, finalEndPoint].map(
|
|
||||||
(p): LocalPoint =>
|
|
||||||
pointFrom(p[0] - newElement.x, p[1] - newElement.y),
|
|
||||||
),
|
|
||||||
vector(0, 0),
|
|
||||||
{
|
|
||||||
...(startElement && newElement.startBinding
|
|
||||||
? {
|
|
||||||
startBinding: {
|
|
||||||
// @ts-ignore TS cannot discern check above
|
|
||||||
...newElement.startBinding!,
|
|
||||||
...calculateFixedPointForElbowArrowBinding(
|
|
||||||
newElement,
|
|
||||||
startElement,
|
|
||||||
"start",
|
|
||||||
elementsMap,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
...(endElement && newElement.endBinding
|
|
||||||
? {
|
|
||||||
endBinding: {
|
|
||||||
// @ts-ignore TS cannot discern check above
|
|
||||||
...newElement.endBinding,
|
|
||||||
...calculateFixedPointForElbowArrowBinding(
|
|
||||||
newElement,
|
|
||||||
endElement,
|
|
||||||
"end",
|
|
||||||
elementsMap,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return newElement;
|
|
||||||
}),
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
currentItemArrowType: value,
|
|
||||||
},
|
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export type ShortcutName =
|
|||||||
| "saveFileToDisk"
|
| "saveFileToDisk"
|
||||||
| "saveToActiveFile"
|
| "saveToActiveFile"
|
||||||
| "toggleShortcuts"
|
| "toggleShortcuts"
|
||||||
|
| "wrapSelectionInFrame"
|
||||||
>
|
>
|
||||||
| "saveScene"
|
| "saveScene"
|
||||||
| "imageExport"
|
| "imageExport"
|
||||||
@@ -112,6 +113,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
|||||||
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
|
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
|
||||||
toggleShortcuts: [getShortcutKey("?")],
|
toggleShortcuts: [getShortcutKey("?")],
|
||||||
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
|
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
|
||||||
|
wrapSelectionInFrame: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
|
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
|
||||||
|
|||||||
@@ -137,7 +137,8 @@ export type ActionName =
|
|||||||
| "searchMenu"
|
| "searchMenu"
|
||||||
| "copyElementLink"
|
| "copyElementLink"
|
||||||
| "linkToElement"
|
| "linkToElement"
|
||||||
| "cropEditor";
|
| "cropEditor"
|
||||||
|
| "wrapSelectionInFrame";
|
||||||
|
|
||||||
export type PanelComponentProps = {
|
export type PanelComponentProps = {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { atom, useAtom } from "jotai";
|
|
||||||
import { actionClearCanvas } from "../actions";
|
import { actionClearCanvas } from "../actions";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { jotaiScope } from "../jotai";
|
import { atom, useAtom } from "../editor-jotai";
|
||||||
import { useExcalidrawActionManager } from "./App";
|
import { useExcalidrawActionManager } from "./App";
|
||||||
import ConfirmDialog from "./ConfirmDialog";
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
|
|
||||||
@@ -10,7 +9,6 @@ export const activeConfirmDialogAtom = atom<"clearCanvas" | null>(null);
|
|||||||
export const ActiveConfirmDialog = () => {
|
export const ActiveConfirmDialog = () => {
|
||||||
const [activeConfirmDialog, setActiveConfirmDialog] = useAtom(
|
const [activeConfirmDialog, setActiveConfirmDialog] = useAtom(
|
||||||
activeConfirmDialogAtom,
|
activeConfirmDialogAtom,
|
||||||
jotaiScope,
|
|
||||||
);
|
);
|
||||||
const actionManager = useExcalidrawActionManager();
|
const actionManager = useExcalidrawActionManager();
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ import {
|
|||||||
isTextBindableContainer,
|
isTextBindableContainer,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
isFlowchartNodeElement,
|
isFlowchartNodeElement,
|
||||||
|
isBindableElement,
|
||||||
} from "../element/typeChecks";
|
} from "../element/typeChecks";
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawBindableElement,
|
ExcalidrawBindableElement,
|
||||||
@@ -189,7 +190,6 @@ import type {
|
|||||||
MagicGenerationData,
|
MagicGenerationData,
|
||||||
ExcalidrawNonSelectionElement,
|
ExcalidrawNonSelectionElement,
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
NonDeletedSceneElementsMap,
|
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { getCenter, getDistance } from "../gesture";
|
import { getCenter, getDistance } from "../gesture";
|
||||||
import {
|
import {
|
||||||
@@ -292,7 +292,6 @@ import {
|
|||||||
getDateTime,
|
getDateTime,
|
||||||
isShallowEqual,
|
isShallowEqual,
|
||||||
arrayToMap,
|
arrayToMap,
|
||||||
toBrandedType,
|
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
import {
|
import {
|
||||||
createSrcDoc,
|
createSrcDoc,
|
||||||
@@ -378,9 +377,10 @@ import { actionPaste } from "../actions/actionClipboard";
|
|||||||
import {
|
import {
|
||||||
actionRemoveAllElementsFromFrame,
|
actionRemoveAllElementsFromFrame,
|
||||||
actionSelectAllElementsInFrame,
|
actionSelectAllElementsInFrame,
|
||||||
|
actionWrapSelectionInFrame,
|
||||||
} from "../actions/actionFrame";
|
} from "../actions/actionFrame";
|
||||||
import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas";
|
import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas";
|
||||||
import { jotaiStore } from "../jotai";
|
import { editorJotaiStore } from "../editor-jotai";
|
||||||
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
||||||
import { ImageSceneDataError } from "../errors";
|
import { ImageSceneDataError } from "../errors";
|
||||||
import {
|
import {
|
||||||
@@ -442,7 +442,6 @@ import { actionTextAutoResize } from "../actions/actionTextAutoResize";
|
|||||||
import { getVisibleSceneBounds } from "../element/bounds";
|
import { getVisibleSceneBounds } from "../element/bounds";
|
||||||
import { isMaybeMermaidDefinition } from "../mermaid";
|
import { isMaybeMermaidDefinition } from "../mermaid";
|
||||||
import NewElementCanvas from "./canvases/NewElementCanvas";
|
import NewElementCanvas from "./canvases/NewElementCanvas";
|
||||||
import { mutateElbowArrow, updateElbowArrow } from "../element/routing";
|
|
||||||
import {
|
import {
|
||||||
FlowChartCreator,
|
FlowChartCreator,
|
||||||
FlowChartNavigator,
|
FlowChartNavigator,
|
||||||
@@ -2076,7 +2075,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private openEyeDropper = ({ type }: { type: "stroke" | "background" }) => {
|
private openEyeDropper = ({ type }: { type: "stroke" | "background" }) => {
|
||||||
jotaiStore.set(activeEyeDropperAtom, {
|
editorJotaiStore.set(activeEyeDropperAtom, {
|
||||||
swapPreviewOnAlt: true,
|
swapPreviewOnAlt: true,
|
||||||
colorPickerType:
|
colorPickerType:
|
||||||
type === "stroke" ? "elementStroke" : "elementBackground",
|
type === "stroke" ? "elementStroke" : "elementBackground",
|
||||||
@@ -3183,49 +3182,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
retainSeed?: boolean;
|
retainSeed?: boolean;
|
||||||
fitToContent?: boolean;
|
fitToContent?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
let elements = opts.elements.map((el, _, elements) => {
|
const elements = restoreElements(opts.elements, null, undefined);
|
||||||
if (isElbowArrow(el)) {
|
|
||||||
const startEndElements = [
|
|
||||||
el.startBinding &&
|
|
||||||
elements.find((l) => l.id === el.startBinding?.elementId),
|
|
||||||
el.endBinding &&
|
|
||||||
elements.find((l) => l.id === el.endBinding?.elementId),
|
|
||||||
];
|
|
||||||
const startBinding = startEndElements[0] ? el.startBinding : null;
|
|
||||||
const endBinding = startEndElements[1] ? el.endBinding : null;
|
|
||||||
return {
|
|
||||||
...el,
|
|
||||||
...updateElbowArrow(
|
|
||||||
{
|
|
||||||
...el,
|
|
||||||
startBinding,
|
|
||||||
endBinding,
|
|
||||||
},
|
|
||||||
toBrandedType<NonDeletedSceneElementsMap>(
|
|
||||||
new Map(
|
|
||||||
startEndElements
|
|
||||||
.filter((x) => x != null)
|
|
||||||
.map(
|
|
||||||
(el) =>
|
|
||||||
[el!.id, el] as [
|
|
||||||
string,
|
|
||||||
Ordered<NonDeletedExcalidrawElement>,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
[el.points[0], el.points[el.points.length - 1]],
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
zoom: this.state.zoom,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return el;
|
|
||||||
});
|
|
||||||
elements = restoreElements(elements, null, undefined);
|
|
||||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||||
|
|
||||||
const elementsCenterX = distance(minX, maxX) / 2;
|
const elementsCenterX = distance(minX, maxX) / 2;
|
||||||
@@ -3324,7 +3281,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
openSidebar:
|
openSidebar:
|
||||||
this.state.openSidebar &&
|
this.state.openSidebar &&
|
||||||
this.device.editor.canFitSidebar &&
|
this.device.editor.canFitSidebar &&
|
||||||
jotaiStore.get(isSidebarDockedAtom)
|
editorJotaiStore.get(isSidebarDockedAtom)
|
||||||
? this.state.openSidebar
|
? this.state.openSidebar
|
||||||
: null,
|
: null,
|
||||||
...selectGroupsForSelectedElements(
|
...selectGroupsForSelectedElements(
|
||||||
@@ -4376,7 +4333,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
updateBoundElements(element, this.scene.getNonDeletedElementsMap(), {
|
updateBoundElements(element, this.scene.getNonDeletedElementsMap(), {
|
||||||
simultaneouslyUpdated: selectedElements,
|
simultaneouslyUpdated: selectedElements,
|
||||||
zoom: this.state.zoom,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -4552,7 +4508,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
event[KEYS.CTRL_OR_CMD] &&
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
|
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
|
||||||
) {
|
) {
|
||||||
jotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
|
editorJotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
|
||||||
}
|
}
|
||||||
|
|
||||||
// eye dropper
|
// eye dropper
|
||||||
@@ -4695,7 +4651,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
if (nextActiveTool.type === "hand") {
|
if (nextActiveTool.type === "hand") {
|
||||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
|
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
|
||||||
} else if (!isHoldingSpace) {
|
} else if (!isHoldingSpace) {
|
||||||
setCursorForShape(this.interactiveCanvas, this.state);
|
setCursorForShape(this.interactiveCanvas, {
|
||||||
|
...this.state,
|
||||||
|
activeTool: nextActiveTool,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (isToolIcon(document.activeElement)) {
|
if (isToolIcon(document.activeElement)) {
|
||||||
this.focusContainer();
|
this.focusContainer();
|
||||||
@@ -5361,6 +5320,11 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
const selectedElements = this.scene.getSelectedElements(this.state);
|
const selectedElements = this.scene.getSelectedElements(this.state);
|
||||||
|
|
||||||
|
let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
||||||
|
event,
|
||||||
|
this.state,
|
||||||
|
);
|
||||||
|
|
||||||
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
|
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
|
||||||
if (
|
if (
|
||||||
event[KEYS.CTRL_OR_CMD] &&
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
@@ -5374,6 +5338,64 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
editingLinearElement: new LinearElementEditor(selectedElements[0]),
|
editingLinearElement: new LinearElementEditor(selectedElements[0]),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
} else if (
|
||||||
|
this.state.selectedLinearElement &&
|
||||||
|
isElbowArrow(selectedElements[0])
|
||||||
|
) {
|
||||||
|
const hitCoords = LinearElementEditor.getSegmentMidpointHitCoords(
|
||||||
|
this.state.selectedLinearElement,
|
||||||
|
{ x: sceneX, y: sceneY },
|
||||||
|
this.state,
|
||||||
|
this.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
const midPoint = hitCoords
|
||||||
|
? LinearElementEditor.getSegmentMidPointIndex(
|
||||||
|
this.state.selectedLinearElement,
|
||||||
|
this.state,
|
||||||
|
hitCoords,
|
||||||
|
this.scene.getNonDeletedElementsMap(),
|
||||||
|
)
|
||||||
|
: -1;
|
||||||
|
|
||||||
|
if (midPoint && midPoint > -1) {
|
||||||
|
this.store.shouldCaptureIncrement();
|
||||||
|
LinearElementEditor.deleteFixedSegment(selectedElements[0], midPoint);
|
||||||
|
|
||||||
|
const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords(
|
||||||
|
{
|
||||||
|
...this.state.selectedLinearElement,
|
||||||
|
segmentMidPointHoveredCoords: null,
|
||||||
|
},
|
||||||
|
{ x: sceneX, y: sceneY },
|
||||||
|
this.state,
|
||||||
|
this.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
const nextIndex = nextCoords
|
||||||
|
? LinearElementEditor.getSegmentMidPointIndex(
|
||||||
|
this.state.selectedLinearElement,
|
||||||
|
this.state,
|
||||||
|
nextCoords,
|
||||||
|
this.scene.getNonDeletedElementsMap(),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
selectedLinearElement: {
|
||||||
|
...this.state.selectedLinearElement,
|
||||||
|
pointerDownState: {
|
||||||
|
...this.state.selectedLinearElement.pointerDownState,
|
||||||
|
segmentMidpoint: {
|
||||||
|
index: nextIndex,
|
||||||
|
value: hitCoords,
|
||||||
|
added: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
segmentMidPointHoveredCoords: nextCoords,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5384,11 +5406,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
resetCursor(this.interactiveCanvas);
|
resetCursor(this.interactiveCanvas);
|
||||||
|
|
||||||
let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
|
||||||
event,
|
|
||||||
this.state,
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedGroupIds = getSelectedGroupIds(this.state);
|
const selectedGroupIds = getSelectedGroupIds(this.state);
|
||||||
|
|
||||||
if (selectedGroupIds.length > 0) {
|
if (selectedGroupIds.length > 0) {
|
||||||
@@ -5845,41 +5862,23 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
if (isPathALoop(points, this.state.zoom.value)) {
|
if (isPathALoop(points, this.state.zoom.value)) {
|
||||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
|
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
|
||||||
}
|
}
|
||||||
if (isElbowArrow(multiElement)) {
|
// update last uncommitted point
|
||||||
mutateElbowArrow(
|
mutateElement(
|
||||||
multiElement,
|
multiElement,
|
||||||
this.scene.getNonDeletedElementsMap(),
|
{
|
||||||
[
|
points: [
|
||||||
...points.slice(0, -1),
|
...points.slice(0, -1),
|
||||||
pointFrom<LocalPoint>(
|
pointFrom<LocalPoint>(
|
||||||
lastCommittedX + dxFromLastCommitted,
|
lastCommittedX + dxFromLastCommitted,
|
||||||
lastCommittedY + dyFromLastCommitted,
|
lastCommittedY + dyFromLastCommitted,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
undefined,
|
},
|
||||||
undefined,
|
false,
|
||||||
{
|
{
|
||||||
isDragging: true,
|
isDragging: true,
|
||||||
informMutation: false,
|
},
|
||||||
zoom: this.state.zoom,
|
);
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// update last uncommitted point
|
|
||||||
mutateElement(
|
|
||||||
multiElement,
|
|
||||||
{
|
|
||||||
points: [
|
|
||||||
...points.slice(0, -1),
|
|
||||||
pointFrom<LocalPoint>(
|
|
||||||
lastCommittedX + dxFromLastCommitted,
|
|
||||||
lastCommittedY + dyFromLastCommitted,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// in this path, we're mutating multiElement to reflect
|
// in this path, we're mutating multiElement to reflect
|
||||||
// how it will be after adding pointer position as the next point
|
// how it will be after adding pointer position as the next point
|
||||||
@@ -6045,7 +6044,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.setState({
|
this.setState({
|
||||||
activeEmbeddable: { element: hitElement, state: "hover" },
|
activeEmbeddable: { element: hitElement, state: "hover" },
|
||||||
});
|
});
|
||||||
} else {
|
} else if (!hitElement || !isElbowArrow(hitElement)) {
|
||||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
||||||
if (this.state.activeEmbeddable?.state === "hover") {
|
if (this.state.activeEmbeddable?.state === "hover") {
|
||||||
this.setState({ activeEmbeddable: null });
|
this.setState({ activeEmbeddable: null });
|
||||||
@@ -6231,14 +6230,18 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.state,
|
this.state,
|
||||||
this.scene.getNonDeletedElementsMap(),
|
this.scene.getNonDeletedElementsMap(),
|
||||||
);
|
);
|
||||||
|
const isHoveringAPointHandle = isElbowArrow(element)
|
||||||
if (hoverPointIndex >= 0 || segmentMidPointHoveredCoords) {
|
? hoverPointIndex === 0 ||
|
||||||
|
hoverPointIndex === element.points.length - 1
|
||||||
|
: hoverPointIndex >= 0;
|
||||||
|
if (isHoveringAPointHandle || segmentMidPointHoveredCoords) {
|
||||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
|
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
|
||||||
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
|
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
|
||||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
||||||
}
|
}
|
||||||
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
|
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
|
||||||
if (
|
if (
|
||||||
|
// Ebow arrows can only be moved when unconnected
|
||||||
!isElbowArrow(element) ||
|
!isElbowArrow(element) ||
|
||||||
!(element.startBinding || element.endBinding)
|
!(element.startBinding || element.endBinding)
|
||||||
) {
|
) {
|
||||||
@@ -6288,7 +6291,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
focus: false,
|
focus: false,
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
jotaiStore.set(searchItemInFocusAtom, null);
|
editorJotaiStore.set(searchItemInFocusAtom, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// since contextMenu options are potentially evaluated on each render,
|
// since contextMenu options are potentially evaluated on each render,
|
||||||
@@ -6968,6 +6971,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
if (
|
if (
|
||||||
selectedElements.length === 1 &&
|
selectedElements.length === 1 &&
|
||||||
!this.state.editingLinearElement &&
|
!this.state.editingLinearElement &&
|
||||||
|
!isElbowArrow(selectedElements[0]) &&
|
||||||
!(
|
!(
|
||||||
this.state.selectedLinearElement &&
|
this.state.selectedLinearElement &&
|
||||||
this.state.selectedLinearElement.hoverPointIndex !== -1
|
this.state.selectedLinearElement.hoverPointIndex !== -1
|
||||||
@@ -7669,6 +7673,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
locked: false,
|
locked: false,
|
||||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||||
elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow,
|
elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow,
|
||||||
|
fixedSegments:
|
||||||
|
this.state.currentItemArrowType === ARROW_TYPE.elbow
|
||||||
|
? []
|
||||||
|
: null,
|
||||||
})
|
})
|
||||||
: newLinearElement({
|
: newLinearElement({
|
||||||
type: elementType,
|
type: elementType,
|
||||||
@@ -7909,6 +7917,63 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pointerCoords = viewportCoordsToSceneCoords(event, this.state);
|
const pointerCoords = viewportCoordsToSceneCoords(event, this.state);
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.state.selectedLinearElement &&
|
||||||
|
this.state.selectedLinearElement.elbowed &&
|
||||||
|
this.state.selectedLinearElement.pointerDownState.segmentMidpoint.index
|
||||||
|
) {
|
||||||
|
const [gridX, gridY] = getGridPoint(
|
||||||
|
pointerCoords.x,
|
||||||
|
pointerCoords.y,
|
||||||
|
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let index =
|
||||||
|
this.state.selectedLinearElement.pointerDownState.segmentMidpoint
|
||||||
|
.index;
|
||||||
|
if (index < 0) {
|
||||||
|
const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords(
|
||||||
|
{
|
||||||
|
...this.state.selectedLinearElement,
|
||||||
|
segmentMidPointHoveredCoords: null,
|
||||||
|
},
|
||||||
|
{ x: gridX, y: gridY },
|
||||||
|
this.state,
|
||||||
|
this.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
index = nextCoords
|
||||||
|
? LinearElementEditor.getSegmentMidPointIndex(
|
||||||
|
this.state.selectedLinearElement,
|
||||||
|
this.state,
|
||||||
|
nextCoords,
|
||||||
|
this.scene.getNonDeletedElementsMap(),
|
||||||
|
)
|
||||||
|
: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ret = LinearElementEditor.moveFixedSegment(
|
||||||
|
this.state.selectedLinearElement,
|
||||||
|
index,
|
||||||
|
gridX,
|
||||||
|
gridY,
|
||||||
|
this.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
flushSync(() => {
|
||||||
|
if (this.state.selectedLinearElement) {
|
||||||
|
this.setState({
|
||||||
|
selectedLinearElement: {
|
||||||
|
...this.state.selectedLinearElement,
|
||||||
|
segmentMidPointHoveredCoords: ret.segmentMidPointHoveredCoords,
|
||||||
|
pointerDownState: ret.pointerDownState,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const lastPointerCoords =
|
const lastPointerCoords =
|
||||||
this.lastPointerMoveCoords ?? pointerDownState.origin;
|
this.lastPointerMoveCoords ?? pointerDownState.origin;
|
||||||
this.lastPointerMoveCoords = pointerCoords;
|
this.lastPointerMoveCoords = pointerCoords;
|
||||||
@@ -8261,7 +8326,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
// when we're editing the name of a frame, we want the user to be
|
// when we're editing the name of a frame, we want the user to be
|
||||||
// able to select and interact with the text input
|
// able to select and interact with the text input
|
||||||
!this.state.editingFrame &&
|
if (!this.state.editingFrame) {
|
||||||
dragSelectedElements(
|
dragSelectedElements(
|
||||||
pointerDownState,
|
pointerDownState,
|
||||||
selectedElements,
|
selectedElements,
|
||||||
@@ -8270,6 +8335,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
snapOffset,
|
snapOffset,
|
||||||
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
|
event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
selectedElementsAreBeingDragged: true,
|
selectedElementsAreBeingDragged: true,
|
||||||
@@ -8445,26 +8511,17 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
} else if (points.length > 1 && isElbowArrow(newElement)) {
|
} else if (
|
||||||
mutateElbowArrow(
|
points.length === 2 ||
|
||||||
newElement,
|
(points.length > 1 && isElbowArrow(newElement))
|
||||||
elementsMap,
|
) {
|
||||||
[...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
|
|
||||||
vector(0, 0),
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
isDragging: true,
|
|
||||||
informMutation: false,
|
|
||||||
zoom: this.state.zoom,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else if (points.length === 2) {
|
|
||||||
mutateElement(
|
mutateElement(
|
||||||
newElement,
|
newElement,
|
||||||
{
|
{
|
||||||
points: [...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
|
points: [...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
|
{ isDragging: true },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8659,6 +8716,24 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
selectedElementsAreBeingDragged: false,
|
selectedElementsAreBeingDragged: false,
|
||||||
});
|
});
|
||||||
const elementsMap = this.scene.getNonDeletedElementsMap();
|
const elementsMap = this.scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
|
if (
|
||||||
|
pointerDownState.drag.hasOccurred &&
|
||||||
|
pointerDownState.hit?.element?.id
|
||||||
|
) {
|
||||||
|
const element = elementsMap.get(pointerDownState.hit.element.id);
|
||||||
|
if (isBindableElement(element)) {
|
||||||
|
// Renormalize elbow arrows when they are changed via indirect move
|
||||||
|
element.boundElements
|
||||||
|
?.filter((e) => e.type === "arrow")
|
||||||
|
.map((e) => elementsMap.get(e.id))
|
||||||
|
.filter((e) => isElbowArrow(e))
|
||||||
|
.forEach((e) => {
|
||||||
|
!!e && mutateElement(e, {}, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle end of dragging a point of a linear element, might close a loop
|
// Handle end of dragging a point of a linear element, might close a loop
|
||||||
// and sets binding element
|
// and sets binding element
|
||||||
if (this.state.editingLinearElement) {
|
if (this.state.editingLinearElement) {
|
||||||
@@ -8683,6 +8758,17 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (this.state.selectedLinearElement) {
|
} else if (this.state.selectedLinearElement) {
|
||||||
|
// Normalize elbow arrow points, remove close parallel segments
|
||||||
|
if (this.state.selectedLinearElement.elbowed) {
|
||||||
|
const element = LinearElementEditor.getElement(
|
||||||
|
this.state.selectedLinearElement.elementId,
|
||||||
|
this.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
if (element) {
|
||||||
|
mutateElement(element, {}, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
pointerDownState.hit?.element?.id !==
|
pointerDownState.hit?.element?.id !==
|
||||||
this.state.selectedLinearElement.elementId
|
this.state.selectedLinearElement.elementId
|
||||||
@@ -9122,10 +9208,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.state.selectedLinearElement?.elementId !== hitElement?.id &&
|
this.state.selectedLinearElement?.elementId !== hitElement?.id &&
|
||||||
isLinearElement(hitElement)
|
isLinearElement(hitElement)
|
||||||
) {
|
) {
|
||||||
const selectedELements = this.scene.getSelectedElements(this.state);
|
const selectedElements = this.scene.getSelectedElements(this.state);
|
||||||
// set selectedLinearElement when no other element selected except
|
// set selectedLinearElement when no other element selected except
|
||||||
// the one we've hit
|
// the one we've hit
|
||||||
if (selectedELements.length === 1) {
|
if (selectedElements.length === 1) {
|
||||||
this.setState({
|
this.setState({
|
||||||
selectedLinearElement: new LinearElementEditor(hitElement),
|
selectedLinearElement: new LinearElementEditor(hitElement),
|
||||||
});
|
});
|
||||||
@@ -9333,6 +9419,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
// not elbow midpoint dragged
|
||||||
|
!(hitElement && isElbowArrow(hitElement)) &&
|
||||||
// not dragged
|
// not dragged
|
||||||
!pointerDownState.drag.hasOccurred &&
|
!pointerDownState.drag.hasOccurred &&
|
||||||
// not resized
|
// not resized
|
||||||
@@ -10661,8 +10749,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
actionCut,
|
actionCut,
|
||||||
actionCopy,
|
actionCopy,
|
||||||
actionPaste,
|
actionPaste,
|
||||||
|
CONTEXT_MENU_SEPARATOR,
|
||||||
actionSelectAllElementsInFrame,
|
actionSelectAllElementsInFrame,
|
||||||
actionRemoveAllElementsFromFrame,
|
actionRemoveAllElementsFromFrame,
|
||||||
|
actionWrapSelectionInFrame,
|
||||||
CONTEXT_MENU_SEPARATOR,
|
CONTEXT_MENU_SEPARATOR,
|
||||||
actionToggleCropEditor,
|
actionToggleCropEditor,
|
||||||
CONTEXT_MENU_SEPARATOR,
|
CONTEXT_MENU_SEPARATOR,
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { getColor } from "./ColorPicker";
|
import { getColor } from "./ColorPicker";
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import type { ColorPickerType } from "./colorPickerUtils";
|
import type { ColorPickerType } from "./colorPickerUtils";
|
||||||
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
||||||
import { eyeDropperIcon } from "../icons";
|
import { eyeDropperIcon } from "../icons";
|
||||||
import { jotaiScope } from "../../jotai";
|
import { useAtom } from "../../editor-jotai";
|
||||||
import { KEYS } from "../../keys";
|
import { KEYS } from "../../keys";
|
||||||
import { activeEyeDropperAtom } from "../EyeDropper";
|
import { activeEyeDropperAtom } from "../EyeDropper";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
@@ -57,10 +56,7 @@ export const ColorInput = ({
|
|||||||
}
|
}
|
||||||
}, [activeSection]);
|
}, [activeSection]);
|
||||||
|
|
||||||
const [eyeDropperState, setEyeDropperState] = useAtom(
|
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
|
||||||
activeEyeDropperAtom,
|
|
||||||
jotaiScope,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { TopPicks } from "./TopPicks";
|
|||||||
import { ButtonSeparator } from "../ButtonSeparator";
|
import { ButtonSeparator } from "../ButtonSeparator";
|
||||||
import { Picker } from "./Picker";
|
import { Picker } from "./Picker";
|
||||||
import * as Popover from "@radix-ui/react-popover";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import type { ColorPickerType } from "./colorPickerUtils";
|
import type { ColorPickerType } from "./colorPickerUtils";
|
||||||
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
||||||
import { useExcalidrawContainer } from "../App";
|
import { useExcalidrawContainer } from "../App";
|
||||||
@@ -15,7 +14,7 @@ import PickerHeading from "./PickerHeading";
|
|||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { jotaiScope } from "../../jotai";
|
import { useAtom } from "../../editor-jotai";
|
||||||
import { ColorInput } from "./ColorInput";
|
import { ColorInput } from "./ColorInput";
|
||||||
import { activeEyeDropperAtom } from "../EyeDropper";
|
import { activeEyeDropperAtom } from "../EyeDropper";
|
||||||
import { PropertiesPopover } from "../PropertiesPopover";
|
import { PropertiesPopover } from "../PropertiesPopover";
|
||||||
@@ -76,10 +75,7 @@ const ColorPickerPopupContent = ({
|
|||||||
const { container } = useExcalidrawContainer();
|
const { container } = useExcalidrawContainer();
|
||||||
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
|
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
|
||||||
|
|
||||||
const [eyeDropperState, setEyeDropperState] = useAtom(
|
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
|
||||||
activeEyeDropperAtom,
|
|
||||||
jotaiScope,
|
|
||||||
);
|
|
||||||
|
|
||||||
const colorInputJSX = (
|
const colorInputJSX = (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "../../editor-jotai";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
||||||
import HotkeyLabel from "./HotkeyLabel";
|
import HotkeyLabel from "./HotkeyLabel";
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { ExcalidrawElement } from "../../element/types";
|
|||||||
import { ShadeList } from "./ShadeList";
|
import { ShadeList } from "./ShadeList";
|
||||||
|
|
||||||
import PickerColorList from "./PickerColorList";
|
import PickerColorList from "./PickerColorList";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "../../editor-jotai";
|
||||||
import { CustomColorList } from "./CustomColorList";
|
import { CustomColorList } from "./CustomColorList";
|
||||||
import { colorPickerKeyNavHandler } from "./keyboardNavHandlers";
|
import { colorPickerKeyNavHandler } from "./keyboardNavHandlers";
|
||||||
import PickerHeading from "./PickerHeading";
|
import PickerHeading from "./PickerHeading";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "../../editor-jotai";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
activeColorPickerSectionAtom,
|
activeColorPickerSectionAtom,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "../../editor-jotai";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
activeColorPickerSectionAtom,
|
activeColorPickerSectionAtom,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ExcalidrawElement } from "../../element/types";
|
import type { ExcalidrawElement } from "../../element/types";
|
||||||
import { atom } from "jotai";
|
|
||||||
import type { ColorPickerColor, ColorPaletteCustom } from "../../colors";
|
import type { ColorPickerColor, ColorPaletteCustom } from "../../colors";
|
||||||
import { MAX_CUSTOM_COLORS_USED_IN_CANVAS } from "../../colors";
|
import { MAX_CUSTOM_COLORS_USED_IN_CANVAS } from "../../colors";
|
||||||
|
import { atom } from "../../editor-jotai";
|
||||||
|
|
||||||
export const getColorNameAndShadeFromColor = ({
|
export const getColorNameAndShadeFromColor = ({
|
||||||
palette,
|
palette,
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import {
|
|||||||
getShortcutKey,
|
getShortcutKey,
|
||||||
isWritableElement,
|
isWritableElement,
|
||||||
} from "../../utils";
|
} from "../../utils";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom, editorJotaiStore } from "../../editor-jotai";
|
||||||
import { deburr } from "../../deburr";
|
import { deburr } from "../../deburr";
|
||||||
import type { MarkRequired } from "../../utility-types";
|
import type { MarkRequired } from "../../utility-types";
|
||||||
import { InlineIcon } from "../InlineIcon";
|
import { InlineIcon } from "../InlineIcon";
|
||||||
@@ -48,7 +48,6 @@ import {
|
|||||||
actionLink,
|
actionLink,
|
||||||
actionToggleSearchMenu,
|
actionToggleSearchMenu,
|
||||||
} from "../../actions";
|
} from "../../actions";
|
||||||
import { jotaiStore } from "../../jotai";
|
|
||||||
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
||||||
import type { CommandPaletteItem } from "./types";
|
import type { CommandPaletteItem } from "./types";
|
||||||
import * as defaultItems from "./defaultCommandPaletteItems";
|
import * as defaultItems from "./defaultCommandPaletteItems";
|
||||||
@@ -263,6 +262,7 @@ function CommandPaletteInner({
|
|||||||
actionManager.actions.cut,
|
actionManager.actions.cut,
|
||||||
actionManager.actions.copy,
|
actionManager.actions.copy,
|
||||||
actionManager.actions.deleteSelectedElements,
|
actionManager.actions.deleteSelectedElements,
|
||||||
|
actionManager.actions.wrapSelectionInFrame,
|
||||||
actionManager.actions.copyStyles,
|
actionManager.actions.copyStyles,
|
||||||
actionManager.actions.pasteStyles,
|
actionManager.actions.pasteStyles,
|
||||||
actionManager.actions.bringToFront,
|
actionManager.actions.bringToFront,
|
||||||
@@ -348,7 +348,7 @@ function CommandPaletteInner({
|
|||||||
keywords: ["delete", "destroy"],
|
keywords: ["delete", "destroy"],
|
||||||
viewMode: false,
|
viewMode: false,
|
||||||
perform: () => {
|
perform: () => {
|
||||||
jotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
|
editorJotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ import { Dialog } from "./Dialog";
|
|||||||
|
|
||||||
import "./ConfirmDialog.scss";
|
import "./ConfirmDialog.scss";
|
||||||
import DialogActionButton from "./DialogActionButton";
|
import DialogActionButton from "./DialogActionButton";
|
||||||
import { useSetAtom } from "jotai";
|
|
||||||
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
|
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
|
||||||
import { useExcalidrawContainer, useExcalidrawSetAppState } from "./App";
|
import { useExcalidrawContainer, useExcalidrawSetAppState } from "./App";
|
||||||
import { jotaiScope } from "../jotai";
|
import { useSetAtom } from "../editor-jotai";
|
||||||
|
|
||||||
interface Props extends Omit<DialogProps, "onCloseRequest"> {
|
interface Props extends Omit<DialogProps, "onCloseRequest"> {
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
@@ -27,7 +26,7 @@ const ConfirmDialog = (props: Props) => {
|
|||||||
...rest
|
...rest
|
||||||
} = props;
|
} = props;
|
||||||
const setAppState = useExcalidrawSetAppState();
|
const setAppState = useExcalidrawSetAppState();
|
||||||
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
|
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
|
||||||
const { container } = useExcalidrawContainer();
|
const { container } = useExcalidrawContainer();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -11,9 +11,8 @@ import "./Dialog.scss";
|
|||||||
import { Island } from "./Island";
|
import { Island } from "./Island";
|
||||||
import { Modal } from "./Modal";
|
import { Modal } from "./Modal";
|
||||||
import { queryFocusableElements } from "../utils";
|
import { queryFocusableElements } from "../utils";
|
||||||
import { useSetAtom } from "jotai";
|
|
||||||
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
|
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
|
||||||
import { jotaiScope } from "../jotai";
|
import { useSetAtom } from "../editor-jotai";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { CloseIcon } from "./icons";
|
import { CloseIcon } from "./icons";
|
||||||
|
|
||||||
@@ -92,7 +91,7 @@ export const Dialog = (props: DialogProps) => {
|
|||||||
}, [islandNode, props.autofocus]);
|
}, [islandNode, props.autofocus]);
|
||||||
|
|
||||||
const setAppState = useExcalidrawSetAppState();
|
const setAppState = useExcalidrawSetAppState();
|
||||||
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
|
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
|
||||||
|
|
||||||
const onClose = () => {
|
const onClose = () => {
|
||||||
setAppState({ openMenu: null });
|
setAppState({ openMenu: null });
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { atom } from "jotai";
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { rgbToHex } from "../colors";
|
import { rgbToHex } from "../colors";
|
||||||
@@ -14,6 +13,7 @@ import { useStable } from "../hooks/useStable";
|
|||||||
import "./EyeDropper.scss";
|
import "./EyeDropper.scss";
|
||||||
import type { ColorPickerType } from "./ColorPicker/colorPickerUtils";
|
import type { ColorPickerType } from "./ColorPicker/colorPickerUtils";
|
||||||
import type { ExcalidrawElement } from "../element/types";
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
|
import { atom } from "../editor-jotai";
|
||||||
|
|
||||||
export type EyeDropperProperties = {
|
export type EyeDropperProperties = {
|
||||||
keepOpenOnAlt: boolean;
|
keepOpenOnAlt: boolean;
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
top: var(--editor-container-padding);
|
top: var(--editor-container-padding);
|
||||||
right: var(--editor-container-padding);
|
right: var(--editor-container-padding);
|
||||||
bottom: var(--editor-container-padding);
|
bottom: var(--editor-container-padding);
|
||||||
z-index: 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.FixedSideContainer_side_top.zen-mode {
|
.FixedSideContainer_side_top.zen-mode {
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
@import "../css/variables.module.scss";
|
@import "../css/variables.module.scss";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.picker-container {
|
|
||||||
display: inline-block;
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker {
|
.picker {
|
||||||
|
padding: 0.5rem;
|
||||||
background: var(--popup-bg-color);
|
background: var(--popup-bg-color);
|
||||||
border: 0 solid transparentize($oc-white, 0.75);
|
border: 0 solid transparentize($oc-white, 0.75);
|
||||||
// ˇˇ yeah, i dunno, open to suggestions here :D
|
box-shadow: var(--shadow-island);
|
||||||
box-shadow: rgb(0 0 0 / 25%) 2px 2px 4px 2px;
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
padding: 0.4rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-container button,
|
.picker-container button,
|
||||||
@@ -55,47 +52,16 @@
|
|||||||
padding: 0.25rem 0.28rem 0.35rem 0.25rem;
|
padding: 0.25rem 0.28rem 0.35rem 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-triangle {
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
position: relative;
|
|
||||||
top: -10px;
|
|
||||||
:root[dir="ltr"] & {
|
|
||||||
left: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
right: 12px;
|
|
||||||
}
|
|
||||||
z-index: 10;
|
|
||||||
|
|
||||||
&:before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
border-style: solid;
|
|
||||||
border-width: 0 9px 10px;
|
|
||||||
border-color: transparent transparent transparentize($oc-black, 0.9);
|
|
||||||
top: -1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
border-style: solid;
|
|
||||||
border-width: 0 9px 10px;
|
|
||||||
border-color: transparent transparent var(--popup-bg-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-content {
|
.picker-content {
|
||||||
padding: 0.5rem;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, auto);
|
grid-template-columns: repeat(4, auto);
|
||||||
grid-gap: 0.5rem;
|
grid-gap: 0.5rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
:root[dir="rtl"] & {
|
}
|
||||||
padding: 0.4rem;
|
|
||||||
}
|
.picker-collapsible {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-keybinding {
|
.picker-keybinding {
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
import React from "react";
|
import React, { useEffect } from "react";
|
||||||
import { Popover } from "./Popover";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
|
import { isArrowKey, KEYS } from "../keys";
|
||||||
|
import { getLanguage, t } from "../i18n";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import Collapsible from "./Stats/Collapsible";
|
||||||
|
import { atom, useAtom } from "../editor-jotai";
|
||||||
|
import { useDevice } from "..";
|
||||||
|
|
||||||
import "./IconPicker.scss";
|
import "./IconPicker.scss";
|
||||||
import { isArrowKey, KEYS } from "../keys";
|
|
||||||
import { getLanguage } from "../i18n";
|
const moreOptionsAtom = atom(false);
|
||||||
import clsx from "clsx";
|
|
||||||
|
type Option<T> = {
|
||||||
|
value: T;
|
||||||
|
text: string;
|
||||||
|
icon: JSX.Element;
|
||||||
|
keyBinding: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
function Picker<T>({
|
function Picker<T>({
|
||||||
options,
|
options,
|
||||||
@@ -12,30 +24,16 @@ function Picker<T>({
|
|||||||
label,
|
label,
|
||||||
onChange,
|
onChange,
|
||||||
onClose,
|
onClose,
|
||||||
|
numberOfOptionsToAlwaysShow = options.length,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: T;
|
value: T;
|
||||||
options: {
|
options: readonly Option<T>[];
|
||||||
value: T;
|
|
||||||
text: string;
|
|
||||||
icon: JSX.Element;
|
|
||||||
keyBinding: string | null;
|
|
||||||
}[];
|
|
||||||
onChange: (value: T) => void;
|
onChange: (value: T) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
numberOfOptionsToAlwaysShow?: number;
|
||||||
}) {
|
}) {
|
||||||
const rFirstItem = React.useRef<HTMLButtonElement>();
|
const device = useDevice();
|
||||||
const rActiveItem = React.useRef<HTMLButtonElement>();
|
|
||||||
const rGallery = React.useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
// After the component is first mounted focus on first input
|
|
||||||
if (rActiveItem.current) {
|
|
||||||
rActiveItem.current.focus();
|
|
||||||
} else if (rGallery.current) {
|
|
||||||
rGallery.current.focus();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||||
const pressedOption = options.find(
|
const pressedOption = options.find(
|
||||||
@@ -44,28 +42,19 @@ function Picker<T>({
|
|||||||
|
|
||||||
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
|
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
|
||||||
// Keybinding navigation
|
// Keybinding navigation
|
||||||
const index = options.indexOf(pressedOption);
|
onChange(pressedOption.value);
|
||||||
(rGallery!.current!.children![index] as any).focus();
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
} else if (event.key === KEYS.TAB) {
|
} else if (event.key === KEYS.TAB) {
|
||||||
// Tab navigation cycle through options. If the user tabs
|
const index = options.findIndex((option) => option.value === value);
|
||||||
// away from the picker, close the picker. We need to use
|
const nextIndex = event.shiftKey
|
||||||
// a timeout here to let the stack clear before checking.
|
? (options.length + index - 1) % options.length
|
||||||
setTimeout(() => {
|
: (index + 1) % options.length;
|
||||||
const active = rActiveItem.current;
|
onChange(options[nextIndex].value);
|
||||||
const docActive = document.activeElement;
|
|
||||||
if (active !== docActive) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
} else if (isArrowKey(event.key)) {
|
} else if (isArrowKey(event.key)) {
|
||||||
// Arrow navigation
|
// Arrow navigation
|
||||||
const { activeElement } = document;
|
|
||||||
const isRTL = getLanguage().rtl;
|
const isRTL = getLanguage().rtl;
|
||||||
const index = Array.prototype.indexOf.call(
|
const index = options.findIndex((option) => option.value === value);
|
||||||
rGallery!.current!.children,
|
|
||||||
activeElement,
|
|
||||||
);
|
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
const length = options.length;
|
const length = options.length;
|
||||||
let nextIndex = index;
|
let nextIndex = index;
|
||||||
@@ -73,19 +62,26 @@ function Picker<T>({
|
|||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
// Select the next option
|
// Select the next option
|
||||||
case isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT:
|
case isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT:
|
||||||
case KEYS.ARROW_DOWN: {
|
|
||||||
nextIndex = (index + 1) % length;
|
nextIndex = (index + 1) % length;
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
// Select the previous option
|
// Select the previous option
|
||||||
case isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT:
|
case isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT:
|
||||||
case KEYS.ARROW_UP: {
|
|
||||||
nextIndex = (length + index - 1) % length;
|
nextIndex = (length + index - 1) % length;
|
||||||
break;
|
break;
|
||||||
|
// Go the next row
|
||||||
|
case KEYS.ARROW_DOWN: {
|
||||||
|
nextIndex = (index + (numberOfOptionsToAlwaysShow ?? 1)) % length;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Go the previous row
|
||||||
|
case KEYS.ARROW_UP: {
|
||||||
|
nextIndex =
|
||||||
|
(length + index - (numberOfOptionsToAlwaysShow ?? 1)) % length;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(rGallery.current!.children![nextIndex] as any).focus();
|
onChange(options[nextIndex].value);
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
|
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
|
||||||
@@ -97,15 +93,26 @@ function Picker<T>({
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const [showMoreOptions, setShowMoreOptions] = useAtom(moreOptionsAtom);
|
||||||
<div
|
|
||||||
className={`picker`}
|
const alwaysVisibleOptions = React.useMemo(
|
||||||
role="dialog"
|
() => options.slice(0, numberOfOptionsToAlwaysShow),
|
||||||
aria-modal="true"
|
[options, numberOfOptionsToAlwaysShow],
|
||||||
aria-label={label}
|
);
|
||||||
onKeyDown={handleKeyDown}
|
const moreOptions = React.useMemo(
|
||||||
>
|
() => options.slice(numberOfOptionsToAlwaysShow),
|
||||||
<div className="picker-content" ref={rGallery}>
|
[options, numberOfOptionsToAlwaysShow],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!alwaysVisibleOptions.some((option) => option.value === value)) {
|
||||||
|
setShowMoreOptions(true);
|
||||||
|
}
|
||||||
|
}, [value, alwaysVisibleOptions, setShowMoreOptions]);
|
||||||
|
|
||||||
|
const renderOptions = (options: Option<T>[]) => {
|
||||||
|
return (
|
||||||
|
<div className="picker-content">
|
||||||
{options.map((option, i) => (
|
{options.map((option, i) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -113,7 +120,6 @@ function Picker<T>({
|
|||||||
active: value === option.value,
|
active: value === option.value,
|
||||||
})}
|
})}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
(event.currentTarget as HTMLButtonElement).focus();
|
|
||||||
onChange(option.value);
|
onChange(option.value);
|
||||||
}}
|
}}
|
||||||
title={`${option.text} ${
|
title={`${option.text} ${
|
||||||
@@ -122,16 +128,13 @@ function Picker<T>({
|
|||||||
aria-label={option.text || "none"}
|
aria-label={option.text || "none"}
|
||||||
aria-keyshortcuts={option.keyBinding || undefined}
|
aria-keyshortcuts={option.keyBinding || undefined}
|
||||||
key={option.text}
|
key={option.text}
|
||||||
ref={(el) => {
|
ref={(ref) => {
|
||||||
if (el && i === 0) {
|
if (value === option.value) {
|
||||||
rFirstItem.current = el;
|
// Use a timeout here to render focus properly
|
||||||
|
setTimeout(() => {
|
||||||
|
ref?.focus();
|
||||||
|
}, 0);
|
||||||
}
|
}
|
||||||
if (el && option.value === value) {
|
|
||||||
rActiveItem.current = el;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onFocus={() => {
|
|
||||||
onChange(option.value);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{option.icon}
|
{option.icon}
|
||||||
@@ -141,7 +144,43 @@ function Picker<T>({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover.Content
|
||||||
|
side={
|
||||||
|
device.editor.isMobile && !device.viewport.isLandscape
|
||||||
|
? "top"
|
||||||
|
: "bottom"
|
||||||
|
}
|
||||||
|
align="start"
|
||||||
|
sideOffset={12}
|
||||||
|
style={{ zIndex: "var(--zIndex-popup)" }}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`picker`}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
{renderOptions(alwaysVisibleOptions)}
|
||||||
|
|
||||||
|
{moreOptions.length > 0 && (
|
||||||
|
<Collapsible
|
||||||
|
label={t("labels.more_options")}
|
||||||
|
open={showMoreOptions}
|
||||||
|
openTrigger={() => {
|
||||||
|
setShowMoreOptions((value) => !value);
|
||||||
|
}}
|
||||||
|
className="picker-collapsible"
|
||||||
|
>
|
||||||
|
{renderOptions(moreOptions)}
|
||||||
|
</Collapsible>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Popover.Content>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +190,7 @@ export function IconPicker<T>({
|
|||||||
options,
|
options,
|
||||||
onChange,
|
onChange,
|
||||||
group = "",
|
group = "",
|
||||||
|
numberOfOptionsToAlwaysShow,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: T;
|
value: T;
|
||||||
@@ -159,51 +199,40 @@ export function IconPicker<T>({
|
|||||||
text: string;
|
text: string;
|
||||||
icon: JSX.Element;
|
icon: JSX.Element;
|
||||||
keyBinding: string | null;
|
keyBinding: string | null;
|
||||||
showInPicker?: boolean;
|
|
||||||
}[];
|
}[];
|
||||||
onChange: (value: T) => void;
|
onChange: (value: T) => void;
|
||||||
|
numberOfOptionsToAlwaysShow?: number;
|
||||||
group?: string;
|
group?: string;
|
||||||
}) {
|
}) {
|
||||||
const [isActive, setActive] = React.useState(false);
|
const [isActive, setActive] = React.useState(false);
|
||||||
const rPickerButton = React.useRef<any>(null);
|
const rPickerButton = React.useRef<any>(null);
|
||||||
const isRTL = getLanguage().rtl;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}>
|
||||||
name={group}
|
<Popover.Trigger
|
||||||
type="button"
|
name={group}
|
||||||
className={isActive ? "active" : ""}
|
type="button"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
onClick={() => setActive(!isActive)}
|
onClick={() => setActive(!isActive)}
|
||||||
ref={rPickerButton}
|
ref={rPickerButton}
|
||||||
>
|
className={isActive ? "active" : ""}
|
||||||
{options.find((option) => option.value === value)?.icon}
|
>
|
||||||
</button>
|
{options.find((option) => option.value === value)?.icon}
|
||||||
<React.Suspense fallback="">
|
</Popover.Trigger>
|
||||||
{isActive ? (
|
{isActive && (
|
||||||
<>
|
<Picker
|
||||||
<Popover
|
options={options}
|
||||||
onCloseRequest={(event) =>
|
value={value}
|
||||||
event.target !== rPickerButton.current && setActive(false)
|
label={label}
|
||||||
}
|
onChange={onChange}
|
||||||
{...(isRTL ? { right: 5.5 } : { left: -5.5 })}
|
onClose={() => {
|
||||||
>
|
setActive(false);
|
||||||
<Picker
|
}}
|
||||||
options={options.filter((opt) => opt.showInPicker !== false)}
|
numberOfOptionsToAlwaysShow={numberOfOptionsToAlwaysShow}
|
||||||
value={value}
|
/>
|
||||||
label={label}
|
)}
|
||||||
onChange={onChange}
|
</Popover.Root>
|
||||||
onClose={() => {
|
|
||||||
setActive(false);
|
|
||||||
rPickerButton.current?.focus();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
<div className="picker-triangle" />
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</React.Suspense>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,8 +41,7 @@ import { trackEvent } from "../analytics";
|
|||||||
import { useDevice } from "./App";
|
import { useDevice } from "./App";
|
||||||
import Footer from "./footer/Footer";
|
import Footer from "./footer/Footer";
|
||||||
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
|
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
|
||||||
import { jotaiScope } from "../jotai";
|
import { useAtom, useAtomValue } from "../editor-jotai";
|
||||||
import { Provider, useAtom, useAtomValue } from "jotai";
|
|
||||||
import MainMenu from "./main-menu/MainMenu";
|
import MainMenu from "./main-menu/MainMenu";
|
||||||
import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
|
import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
|
||||||
import { OverwriteConfirmDialog } from "./OverwriteConfirm/OverwriteConfirm";
|
import { OverwriteConfirmDialog } from "./OverwriteConfirm/OverwriteConfirm";
|
||||||
@@ -148,10 +147,9 @@ const LayerUI = ({
|
|||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
const tunnels = useInitializeTunnels();
|
const tunnels = useInitializeTunnels();
|
||||||
|
|
||||||
const [eyeDropperState, setEyeDropperState] = useAtom(
|
const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider;
|
||||||
activeEyeDropperAtom,
|
|
||||||
jotaiScope,
|
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
|
||||||
);
|
|
||||||
|
|
||||||
const renderJSONExportDialog = () => {
|
const renderJSONExportDialog = () => {
|
||||||
if (!UIOptions.canvasActions.export) {
|
if (!UIOptions.canvasActions.export) {
|
||||||
@@ -382,7 +380,7 @@ const LayerUI = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSidebarDocked = useAtomValue(isSidebarDockedAtom, jotaiScope);
|
const isSidebarDocked = useAtomValue(isSidebarDockedAtom);
|
||||||
|
|
||||||
const layerUIJSX = (
|
const layerUIJSX = (
|
||||||
<>
|
<>
|
||||||
@@ -566,11 +564,11 @@ const LayerUI = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<UIAppStateContext.Provider value={appState}>
|
<UIAppStateContext.Provider value={appState}>
|
||||||
<Provider scope={tunnels.jotaiScope}>
|
<TunnelsJotaiProvider>
|
||||||
<TunnelsContext.Provider value={tunnels}>
|
<TunnelsContext.Provider value={tunnels}>
|
||||||
{layerUIJSX}
|
{layerUIJSX}
|
||||||
</TunnelsContext.Provider>
|
</TunnelsContext.Provider>
|
||||||
</Provider>
|
</TunnelsJotaiProvider>
|
||||||
</UIAppStateContext.Provider>
|
</UIAppStateContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ import type {
|
|||||||
} from "../types";
|
} from "../types";
|
||||||
import LibraryMenuItems from "./LibraryMenuItems";
|
import LibraryMenuItems from "./LibraryMenuItems";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "../editor-jotai";
|
||||||
import { jotaiScope } from "../jotai";
|
|
||||||
import Spinner from "./Spinner";
|
import Spinner from "./Spinner";
|
||||||
import {
|
import {
|
||||||
useApp,
|
useApp,
|
||||||
@@ -61,7 +60,7 @@ export const LibraryMenuContent = ({
|
|||||||
selectedItems: LibraryItem["id"][];
|
selectedItems: LibraryItem["id"][];
|
||||||
onSelectItems: (id: LibraryItem["id"][]) => void;
|
onSelectItems: (id: LibraryItem["id"][]) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
const [libraryItemsData] = useAtom(libraryItemsAtom);
|
||||||
|
|
||||||
const _onAddToLibrary = useCallback(
|
const _onAddToLibrary = useCallback(
|
||||||
(elements: LibraryItem["elements"]) => {
|
(elements: LibraryItem["elements"]) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import Trans from "./Trans";
|
import Trans from "./Trans";
|
||||||
import { jotaiScope } from "../jotai";
|
import { useAtom } from "../editor-jotai";
|
||||||
import type { LibraryItem, LibraryItems, UIAppState } from "../types";
|
import type { LibraryItem, LibraryItems, UIAppState } from "../types";
|
||||||
import { useApp, useExcalidrawSetAppState } from "./App";
|
import { useApp, useExcalidrawSetAppState } from "./App";
|
||||||
import { saveLibraryAsJSON } from "../data/json";
|
import { saveLibraryAsJSON } from "../data/json";
|
||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import { fileOpen } from "../data/filesystem";
|
import { fileOpen } from "../data/filesystem";
|
||||||
import { muteFSAbortError } from "../utils";
|
import { muteFSAbortError } from "../utils";
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import ConfirmDialog from "./ConfirmDialog";
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
import PublishLibrary from "./PublishLibrary";
|
import PublishLibrary from "./PublishLibrary";
|
||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
@@ -51,10 +50,9 @@ export const LibraryDropdownMenuButton: React.FC<{
|
|||||||
appState,
|
appState,
|
||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
const [libraryItemsData] = useAtom(libraryItemsAtom);
|
||||||
const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom(
|
const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom(
|
||||||
isLibraryMenuOpenAtom,
|
isLibraryMenuOpenAtom,
|
||||||
jotaiScope,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderRemoveLibAlert = () => {
|
const renderRemoveLibAlert = () => {
|
||||||
@@ -286,7 +284,7 @@ export const LibraryDropdownMenu = ({
|
|||||||
const appState = useUIAppState();
|
const appState = useUIAppState();
|
||||||
const setAppState = useExcalidrawSetAppState();
|
const setAppState = useExcalidrawSetAppState();
|
||||||
|
|
||||||
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
const [libraryItemsData] = useAtom(libraryItemsAtom);
|
||||||
|
|
||||||
const removeFromLibrary = async (libraryItems: LibraryItems) => {
|
const removeFromLibrary = async (libraryItems: LibraryItems) => {
|
||||||
const nextItems = libraryItems.filter(
|
const nextItems = libraryItems.filter(
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useAtom } from "jotai";
|
|
||||||
|
|
||||||
import { useTunnels } from "../../context/tunnels";
|
import { useTunnels } from "../../context/tunnels";
|
||||||
import { jotaiScope } from "../../jotai";
|
import { useAtom } from "../../editor-jotai";
|
||||||
import { Dialog } from "../Dialog";
|
import { Dialog } from "../Dialog";
|
||||||
import { withInternalFallback } from "../hoc/withInternalFallback";
|
import { withInternalFallback } from "../hoc/withInternalFallback";
|
||||||
import { overwriteConfirmStateAtom } from "./OverwriteConfirmState";
|
import { overwriteConfirmStateAtom } from "./OverwriteConfirmState";
|
||||||
@@ -23,7 +22,6 @@ const OverwriteConfirmDialog = Object.assign(
|
|||||||
const { OverwriteConfirmDialogTunnel } = useTunnels();
|
const { OverwriteConfirmDialogTunnel } = useTunnels();
|
||||||
const [overwriteConfirmState, setState] = useAtom(
|
const [overwriteConfirmState, setState] = useAtom(
|
||||||
overwriteConfirmStateAtom,
|
overwriteConfirmStateAtom,
|
||||||
jotaiScope,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!overwriteConfirmState.active) {
|
if (!overwriteConfirmState.active) {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { atom } from "jotai";
|
import { atom, editorJotaiStore } from "../../editor-jotai";
|
||||||
import { jotaiStore } from "../../jotai";
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
|
||||||
export type OverwriteConfirmState =
|
export type OverwriteConfirmState =
|
||||||
@@ -32,7 +31,7 @@ export async function openConfirmModal({
|
|||||||
color: "danger" | "warning";
|
color: "danger" | "warning";
|
||||||
}) {
|
}) {
|
||||||
return new Promise<boolean>((resolve) => {
|
return new Promise<boolean>((resolve) => {
|
||||||
jotaiStore.set(overwriteConfirmStateAtom, {
|
editorJotaiStore.set(overwriteConfirmStateAtom, {
|
||||||
active: true,
|
active: true,
|
||||||
onConfirm: () => resolve(true),
|
onConfirm: () => resolve(true),
|
||||||
onClose: () => resolve(false),
|
onClose: () => resolve(false),
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ import { measureText } from "../element/textElement";
|
|||||||
import { addEventListener, getFontString } from "../utils";
|
import { addEventListener, getFontString } from "../utils";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "../editor-jotai";
|
||||||
import { jotaiScope } from "../jotai";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { isElementCompletelyInViewport } from "../element/sizeHelpers";
|
import { isElementCompletelyInViewport } from "../element/sizeHelpers";
|
||||||
import { randomInteger } from "../random";
|
import { randomInteger } from "../random";
|
||||||
@@ -58,7 +57,7 @@ export const SearchMenu = () => {
|
|||||||
|
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [inputValue, setInputValue] = useAtom(searchQueryAtom, jotaiScope);
|
const [inputValue, setInputValue] = useAtom(searchQueryAtom);
|
||||||
const searchQuery = inputValue.trim() as SearchQuery;
|
const searchQuery = inputValue.trim() as SearchQuery;
|
||||||
|
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
@@ -70,10 +69,7 @@ export const SearchMenu = () => {
|
|||||||
const searchedQueryRef = useRef<SearchQuery | null>(null);
|
const searchedQueryRef = useRef<SearchQuery | null>(null);
|
||||||
const lastSceneNonceRef = useRef<number | undefined>(undefined);
|
const lastSceneNonceRef = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
const [focusIndex, setFocusIndex] = useAtom(
|
const [focusIndex, setFocusIndex] = useAtom(searchItemInFocusAtom);
|
||||||
searchItemInFocusAtom,
|
|
||||||
jotaiScope,
|
|
||||||
);
|
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import React, {
|
|||||||
useCallback,
|
useCallback,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Island } from "../Island";
|
import { Island } from "../Island";
|
||||||
import { atom, useSetAtom } from "jotai";
|
import { atom, useSetAtom } from "../../editor-jotai";
|
||||||
import { jotaiScope } from "../../jotai";
|
|
||||||
import type { SidebarProps, SidebarPropsContextValue } from "./common";
|
import type { SidebarProps, SidebarPropsContextValue } from "./common";
|
||||||
import { SidebarPropsContext } from "./common";
|
import { SidebarPropsContext } from "./common";
|
||||||
import { SidebarHeader } from "./SidebarHeader";
|
import { SidebarHeader } from "./SidebarHeader";
|
||||||
@@ -58,7 +57,7 @@ export const SidebarInner = forwardRef(
|
|||||||
|
|
||||||
const setAppState = useExcalidrawSetAppState();
|
const setAppState = useExcalidrawSetAppState();
|
||||||
|
|
||||||
const setIsSidebarDockedAtom = useSetAtom(isSidebarDockedAtom, jotaiScope);
|
const setIsSidebarDockedAtom = useSetAtom(isSidebarDockedAtom);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
setIsSidebarDockedAtom(!!docked);
|
setIsSidebarDockedAtom(!!docked);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface CollapsibleProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
openTrigger: () => void;
|
openTrigger: () => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Collapsible = ({
|
const Collapsible = ({
|
||||||
@@ -16,6 +17,7 @@ const Collapsible = ({
|
|||||||
open,
|
open,
|
||||||
openTrigger,
|
openTrigger,
|
||||||
children,
|
children,
|
||||||
|
className,
|
||||||
}: CollapsibleProps) => {
|
}: CollapsibleProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -26,6 +28,7 @@ const Collapsible = ({
|
|||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
|
className={className}
|
||||||
onClick={openTrigger}
|
onClick={openTrigger}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ const MultiPosition = ({
|
|||||||
const [x1, y1] = getCommonBounds(elementsInUnit);
|
const [x1, y1] = getCommonBounds(elementsInUnit);
|
||||||
return Math.round((property === "x" ? x1 : y1) * 100) / 100;
|
return Math.round((property === "x" ? x1 : y1) * 100) / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [el] = elementsInUnit;
|
const [el] = elementsInUnit;
|
||||||
const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
|
const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import type { BinaryFiles } from "../../types";
|
|||||||
import { ArrowRightIcon } from "../icons";
|
import { ArrowRightIcon } from "../icons";
|
||||||
|
|
||||||
import "./TTDDialog.scss";
|
import "./TTDDialog.scss";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "../../editor-jotai";
|
||||||
import { trackEvent } from "../../analytics";
|
import { trackEvent } from "../../analytics";
|
||||||
import { InlineIcon } from "../InlineIcon";
|
import { InlineIcon } from "../InlineIcon";
|
||||||
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
|
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import fallbackLangData from "../locales/en.json";
|
|||||||
|
|
||||||
import Trans from "./Trans";
|
import Trans from "./Trans";
|
||||||
import type { TranslationKeys } from "../i18n";
|
import type { TranslationKeys } from "../i18n";
|
||||||
|
import { EditorJotaiProvider } from "../editor-jotai";
|
||||||
|
|
||||||
describe("Test <Trans/>", () => {
|
describe("Test <Trans/>", () => {
|
||||||
it("should translate the the strings correctly", () => {
|
it("should translate the the strings correctly", () => {
|
||||||
@@ -17,7 +18,7 @@ describe("Test <Trans/>", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { getByTestId } = render(
|
const { getByTestId } = render(
|
||||||
<>
|
<EditorJotaiProvider>
|
||||||
<div data-testid="test1">
|
<div data-testid="test1">
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey={"transTest.key1" as unknown as TranslationKeys}
|
i18nKey={"transTest.key1" as unknown as TranslationKeys}
|
||||||
@@ -51,7 +52,7 @@ describe("Test <Trans/>", () => {
|
|||||||
connect-link={(el) => <a href="https://example.com">{el}</a>}
|
connect-link={(el) => <a href="https://example.com">{el}</a>}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>,
|
</EditorJotaiProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(getByTestId("test1").innerHTML).toEqual("Hello world");
|
expect(getByTestId("test1").innerHTML).toEqual("Hello world");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { atom, useAtom } from "jotai";
|
|
||||||
import React, { useLayoutEffect, useRef } from "react";
|
import React, { useLayoutEffect, useRef } from "react";
|
||||||
import { useTunnels } from "../../context/tunnels";
|
import { useTunnels } from "../../context/tunnels";
|
||||||
|
import { atom } from "../../editor-jotai";
|
||||||
|
|
||||||
export const withInternalFallback = <P,>(
|
export const withInternalFallback = <P,>(
|
||||||
componentName: string,
|
componentName: string,
|
||||||
@@ -13,9 +13,11 @@ export const withInternalFallback = <P,>(
|
|||||||
__fallback?: boolean;
|
__fallback?: boolean;
|
||||||
}
|
}
|
||||||
> = (props) => {
|
> = (props) => {
|
||||||
const { jotaiScope } = useTunnels();
|
const {
|
||||||
|
tunnelsJotai: { useAtom },
|
||||||
|
} = useTunnels();
|
||||||
// for rerenders
|
// for rerenders
|
||||||
const [, setCounter] = useAtom(renderAtom, jotaiScope);
|
const [, setCounter] = useAtom(renderAtom);
|
||||||
// for initial & subsequent renders. Tracked as component state
|
// for initial & subsequent renders. Tracked as component state
|
||||||
// due to excalidraw multi-instance scanerios.
|
// due to excalidraw multi-instance scanerios.
|
||||||
const metaRef = useRef({
|
const metaRef = useRef({
|
||||||
|
|||||||
@@ -1352,6 +1352,54 @@ export const ArrowheadDiamondOutlineIcon = React.memo(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const ArrowheadCrowfootIcon = React.memo(
|
||||||
|
({ flip = false }: { flip?: boolean }) =>
|
||||||
|
createIcon(
|
||||||
|
<g
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path d="M34,10 H6 M15,10 L7,5 M15,10 L7,15" />
|
||||||
|
</g>,
|
||||||
|
{ width: 40, height: 20 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ArrowheadCrowfootOneIcon = React.memo(
|
||||||
|
({ flip = false }: { flip?: boolean }) =>
|
||||||
|
createIcon(
|
||||||
|
<g
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path d="M34,10 H6 M15,10 L15,15 L15,5" />
|
||||||
|
</g>,
|
||||||
|
{ width: 40, height: 20 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ArrowheadCrowfootOneOrManyIcon = React.memo(
|
||||||
|
({ flip = false }: { flip?: boolean }) =>
|
||||||
|
createIcon(
|
||||||
|
<g
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path d="M34,10 H6 M15,10 L15,16 L15,4 M15,10 L7,5 M15,10 L7,15" />
|
||||||
|
</g>,
|
||||||
|
{ width: 40, height: 20 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
export const FontSizeSmallIcon = createIcon(
|
export const FontSizeSmallIcon = createIcon(
|
||||||
<>
|
<>
|
||||||
<g clipPath="url(#a)">
|
<g clipPath="url(#a)">
|
||||||
|
|||||||
@@ -32,9 +32,8 @@ import {
|
|||||||
actionToggleTheme,
|
actionToggleTheme,
|
||||||
} from "../../actions";
|
} from "../../actions";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useSetAtom } from "jotai";
|
|
||||||
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
||||||
import { jotaiScope } from "../../jotai";
|
import { useSetAtom } from "../../editor-jotai";
|
||||||
import { useUIAppState } from "../../context/ui-appState";
|
import { useUIAppState } from "../../context/ui-appState";
|
||||||
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
|
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
|
||||||
import Trans from "../Trans";
|
import Trans from "../Trans";
|
||||||
@@ -189,10 +188,7 @@ Help.displayName = "Help";
|
|||||||
export const ClearCanvas = () => {
|
export const ClearCanvas = () => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const setActiveConfirmDialog = useSetAtom(
|
const setActiveConfirmDialog = useSetAtom(activeConfirmDialogAtom);
|
||||||
activeConfirmDialogAtom,
|
|
||||||
jotaiScope,
|
|
||||||
);
|
|
||||||
const actionManager = useExcalidrawActionManager();
|
const actionManager = useExcalidrawActionManager();
|
||||||
|
|
||||||
if (!actionManager.isActionEnabled(actionClearCanvas)) {
|
if (!actionManager.isActionEnabled(actionClearCanvas)) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import tunnel from "tunnel-rat";
|
import tunnel from "tunnel-rat";
|
||||||
|
import { createIsolation } from "jotai-scope";
|
||||||
|
|
||||||
export type Tunnel = ReturnType<typeof tunnel>;
|
export type Tunnel = ReturnType<typeof tunnel>;
|
||||||
|
|
||||||
@@ -14,13 +15,17 @@ type TunnelsContextValue = {
|
|||||||
DefaultSidebarTabTriggersTunnel: Tunnel;
|
DefaultSidebarTabTriggersTunnel: Tunnel;
|
||||||
OverwriteConfirmDialogTunnel: Tunnel;
|
OverwriteConfirmDialogTunnel: Tunnel;
|
||||||
TTDDialogTriggerTunnel: Tunnel;
|
TTDDialogTriggerTunnel: Tunnel;
|
||||||
jotaiScope: symbol;
|
// this can be removed once we create jotai stores per each editor
|
||||||
|
// instance
|
||||||
|
tunnelsJotai: ReturnType<typeof createIsolation>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TunnelsContext = React.createContext<TunnelsContextValue>(null!);
|
export const TunnelsContext = React.createContext<TunnelsContextValue>(null!);
|
||||||
|
|
||||||
export const useTunnels = () => React.useContext(TunnelsContext);
|
export const useTunnels = () => React.useContext(TunnelsContext);
|
||||||
|
|
||||||
|
const tunnelsJotai = createIsolation();
|
||||||
|
|
||||||
export const useInitializeTunnels = () => {
|
export const useInitializeTunnels = () => {
|
||||||
return React.useMemo((): TunnelsContextValue => {
|
return React.useMemo((): TunnelsContextValue => {
|
||||||
return {
|
return {
|
||||||
@@ -34,7 +39,7 @@ export const useInitializeTunnels = () => {
|
|||||||
DefaultSidebarTabTriggersTunnel: tunnel(),
|
DefaultSidebarTabTriggersTunnel: tunnel(),
|
||||||
OverwriteConfirmDialogTunnel: tunnel(),
|
OverwriteConfirmDialogTunnel: tunnel(),
|
||||||
TTDDialogTriggerTunnel: tunnel(),
|
TTDDialogTriggerTunnel: tunnel(),
|
||||||
jotaiScope: Symbol(),
|
tunnelsJotai,
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
|||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 33.519031369643244,
|
"height": 35,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
"index": "a2",
|
"index": "a2",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
@@ -109,8 +109,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
|||||||
0.5,
|
0.5,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
382.47606040672997,
|
394.5,
|
||||||
34.019031369643244,
|
34.5,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
"roughness": 1,
|
"roughness": 1,
|
||||||
@@ -128,9 +128,9 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
|||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 7,
|
"version": 4,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 381.97606040672997,
|
"width": 395,
|
||||||
"x": 247,
|
"x": 247,
|
||||||
"y": 420,
|
"y": 420,
|
||||||
}
|
}
|
||||||
@@ -167,7 +167,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
389.5,
|
399.5,
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -186,10 +186,10 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
|||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 6,
|
"version": 4,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 390,
|
"width": 400,
|
||||||
"x": 237,
|
"x": 227,
|
||||||
"y": 450,
|
"y": 450,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -319,7 +319,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
|||||||
"verticalAlign": "top",
|
"verticalAlign": "top",
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 560,
|
"x": 560,
|
||||||
"y": 236.95454545454544,
|
"y": 226.5,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -339,13 +339,13 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
|||||||
"endBinding": {
|
"endBinding": {
|
||||||
"elementId": "text-2",
|
"elementId": "text-2",
|
||||||
"fixedPoint": null,
|
"fixedPoint": null,
|
||||||
"focus": 1.625925925925924,
|
"focus": 0,
|
||||||
"gap": 14,
|
"gap": 14,
|
||||||
},
|
},
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 18.278619528619487,
|
"height": 0,
|
||||||
"id": Any<String>,
|
"id": Any<String>,
|
||||||
"index": "a2",
|
"index": "a2",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
@@ -356,11 +356,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
|||||||
"points": [
|
"points": [
|
||||||
[
|
[
|
||||||
0.5,
|
0.5,
|
||||||
-0.5,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
357.2037037037038,
|
99.5,
|
||||||
-17.778619528619487,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
"roughness": 1,
|
"roughness": 1,
|
||||||
@@ -378,11 +378,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
|||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 6,
|
"version": 4,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 357.7037037037038,
|
"width": 100,
|
||||||
"x": 171,
|
"x": 255,
|
||||||
"y": 249.45454545454544,
|
"y": 239,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -482,7 +482,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
|||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 6,
|
"version": 4,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 255,
|
"x": 255,
|
||||||
@@ -660,7 +660,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
|||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 6,
|
"version": 4,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 255,
|
"x": 255,
|
||||||
@@ -1505,7 +1505,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
270.98528125,
|
272.485,
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -1526,10 +1526,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
|||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 7,
|
"version": 4,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 270.48528125,
|
"width": 272.985,
|
||||||
"x": 112.76171875,
|
"x": 111.262,
|
||||||
"y": 57,
|
"y": 57,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -1587,11 +1587,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
|||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 6,
|
"version": 4,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 0,
|
"width": 0,
|
||||||
"x": 83.015625,
|
"x": 77.017,
|
||||||
"y": 81.5,
|
"y": 79,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { clearElementsForExport } from "../element";
|
|||||||
import type { ExcalidrawElement, FileId } from "../element/types";
|
import type { ExcalidrawElement, FileId } from "../element/types";
|
||||||
import { CanvasError, ImageSceneDataError } from "../errors";
|
import { CanvasError, ImageSceneDataError } from "../errors";
|
||||||
import { calculateScrollCenter } from "../scene";
|
import { calculateScrollCenter } from "../scene";
|
||||||
|
import { decodeSvgBase64Payload } from "../scene/export";
|
||||||
import type { AppState, DataURL, LibraryItem } from "../types";
|
import type { AppState, DataURL, LibraryItem } from "../types";
|
||||||
import type { ValueOf } from "../utility-types";
|
import type { ValueOf } from "../utility-types";
|
||||||
import { bytesToHexString, isPromiseLike } from "../utils";
|
import { bytesToHexString, isPromiseLike } from "../utils";
|
||||||
@@ -47,7 +48,7 @@ const parseFileContents = async (blob: Blob | File): Promise<string> => {
|
|||||||
}
|
}
|
||||||
if (blob.type === MIME_TYPES.svg) {
|
if (blob.type === MIME_TYPES.svg) {
|
||||||
try {
|
try {
|
||||||
return (await import("./image")).decodeSvgMetadata({
|
return decodeSvgBase64Payload({
|
||||||
svg: contents,
|
svg: contents,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
|||||||
|
|
||||||
const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
|
const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
|
||||||
mimeTypes.push(MIME_TYPES[type]);
|
mimeTypes.push(MIME_TYPES[type]);
|
||||||
|
|
||||||
return mimeTypes;
|
return mimeTypes;
|
||||||
}, [] as string[]);
|
}, [] as string[]);
|
||||||
|
|
||||||
@@ -39,34 +38,62 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
|||||||
mimeTypes,
|
mimeTypes,
|
||||||
multiple: opts.multiple ?? false,
|
multiple: opts.multiple ?? false,
|
||||||
legacySetup: (resolve, reject, input) => {
|
legacySetup: (resolve, reject, input) => {
|
||||||
|
let isResolved = false;
|
||||||
|
let checkInterval: number | null = null;
|
||||||
|
|
||||||
|
// Increased delay for iOS to ensure file selection is complete
|
||||||
|
const CHECK_INTERVAL = 100; // 100ms
|
||||||
|
const MAX_CHECKS = 50; // 5 seconds total
|
||||||
|
let checkCount = 0;
|
||||||
|
|
||||||
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
|
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
|
||||||
const focusHandler = () => {
|
|
||||||
checkForFile();
|
|
||||||
document.addEventListener(EVENT.KEYUP, scheduleRejection);
|
|
||||||
document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
|
|
||||||
scheduleRejection();
|
|
||||||
};
|
|
||||||
const checkForFile = () => {
|
const checkForFile = () => {
|
||||||
// this hack might not work when expecting multiple files
|
if (isResolved) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (input.files?.length) {
|
if (input.files?.length) {
|
||||||
|
isResolved = true;
|
||||||
const ret = opts.multiple ? [...input.files] : input.files[0];
|
const ret = opts.multiple ? [...input.files] : input.files[0];
|
||||||
resolve(ret as RetType);
|
resolve(ret as RetType);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
checkCount++;
|
||||||
|
if (checkCount >= MAX_CHECKS) {
|
||||||
|
scheduleRejection();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const focusHandler = () => {
|
||||||
|
// Start checking for files only after focus event
|
||||||
|
checkInterval = window.setInterval(() => {
|
||||||
|
if (checkForFile()) {
|
||||||
|
clearInterval(checkInterval!);
|
||||||
|
}
|
||||||
|
}, CHECK_INTERVAL);
|
||||||
|
|
||||||
|
document.addEventListener(EVENT.KEYUP, scheduleRejection);
|
||||||
|
document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
|
||||||
|
};
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
window.addEventListener(EVENT.FOCUS, focusHandler);
|
window.addEventListener(EVENT.FOCUS, focusHandler);
|
||||||
});
|
});
|
||||||
const interval = window.setInterval(() => {
|
|
||||||
checkForFile();
|
|
||||||
}, INPUT_CHANGE_INTERVAL_MS);
|
|
||||||
return (rejectPromise) => {
|
return (rejectPromise) => {
|
||||||
clearInterval(interval);
|
if (checkInterval) {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
}
|
||||||
scheduleRejection.cancel();
|
scheduleRejection.cancel();
|
||||||
window.removeEventListener(EVENT.FOCUS, focusHandler);
|
window.removeEventListener(EVENT.FOCUS, focusHandler);
|
||||||
document.removeEventListener(EVENT.KEYUP, scheduleRejection);
|
document.removeEventListener(EVENT.KEYUP, scheduleRejection);
|
||||||
document.removeEventListener(EVENT.POINTER_UP, scheduleRejection);
|
document.removeEventListener(EVENT.POINTER_UP, scheduleRejection);
|
||||||
if (rejectPromise) {
|
|
||||||
// so that something is shown in console if we need to debug this
|
if (rejectPromise && !isResolved) {
|
||||||
console.warn("Opening the file was canceled (legacy-fs).");
|
console.warn("Opening the file was canceled (legacy-fs).");
|
||||||
rejectPromise(new AbortError());
|
rejectPromise(new AbortError());
|
||||||
}
|
}
|
||||||
@@ -82,6 +109,7 @@ export const fileSave = (
|
|||||||
name: string;
|
name: string;
|
||||||
/** file extension */
|
/** file extension */
|
||||||
extension: FILE_EXTENSION;
|
extension: FILE_EXTENSION;
|
||||||
|
mimeTypes?: string[];
|
||||||
description: string;
|
description: string;
|
||||||
/** existing FileSystemHandle */
|
/** existing FileSystemHandle */
|
||||||
fileHandle?: FileSystemHandle | null;
|
fileHandle?: FileSystemHandle | null;
|
||||||
@@ -93,10 +121,11 @@ export const fileSave = (
|
|||||||
fileName: `${opts.name}.${opts.extension}`,
|
fileName: `${opts.name}.${opts.extension}`,
|
||||||
description: opts.description,
|
description: opts.description,
|
||||||
extensions: [`.${opts.extension}`],
|
extensions: [`.${opts.extension}`],
|
||||||
|
mimeTypes: opts.mimeTypes,
|
||||||
},
|
},
|
||||||
opts.fileHandle,
|
opts.fileHandle,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type { FileSystemHandle };
|
|
||||||
export { nativeFileSystemSupported };
|
export { nativeFileSystemSupported };
|
||||||
|
export type { FileSystemHandle };
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import decodePng from "png-chunks-extract";
|
import decodePng from "png-chunks-extract";
|
||||||
import tEXt from "png-chunk-text";
|
import tEXt from "png-chunk-text";
|
||||||
import encodePng from "png-chunks-encode";
|
import encodePng from "png-chunks-encode";
|
||||||
import { stringToBase64, encode, decode, base64ToString } from "./encode";
|
import { encode, decode } from "./encode";
|
||||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
|
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
|
||||||
import { blobToArrayBuffer } from "./blob";
|
import { blobToArrayBuffer } from "./blob";
|
||||||
|
|
||||||
@@ -67,56 +67,3 @@ export const decodePngMetadata = async (blob: Blob) => {
|
|||||||
}
|
}
|
||||||
throw new Error("INVALID");
|
throw new Error("INVALID");
|
||||||
};
|
};
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// SVG
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const encodeSvgMetadata = ({ text }: { text: string }) => {
|
|
||||||
const base64 = stringToBase64(
|
|
||||||
JSON.stringify(encode({ text })),
|
|
||||||
true /* is already byte string */,
|
|
||||||
);
|
|
||||||
|
|
||||||
let metadata = "";
|
|
||||||
metadata += `<!-- payload-type:${MIME_TYPES.excalidraw} -->`;
|
|
||||||
metadata += `<!-- payload-version:2 -->`;
|
|
||||||
metadata += "<!-- payload-start -->";
|
|
||||||
metadata += base64;
|
|
||||||
metadata += "<!-- payload-end -->";
|
|
||||||
return metadata;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const decodeSvgMetadata = ({ svg }: { svg: string }) => {
|
|
||||||
if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) {
|
|
||||||
const match = svg.match(
|
|
||||||
/<!-- payload-start -->\s*(.+?)\s*<!-- payload-end -->/,
|
|
||||||
);
|
|
||||||
if (!match) {
|
|
||||||
throw new Error("INVALID");
|
|
||||||
}
|
|
||||||
const versionMatch = svg.match(/<!-- payload-version:(\d+) -->/);
|
|
||||||
const version = versionMatch?.[1] || "1";
|
|
||||||
const isByteString = version !== "1";
|
|
||||||
|
|
||||||
try {
|
|
||||||
const json = base64ToString(match[1], isByteString);
|
|
||||||
const encodedData = JSON.parse(json);
|
|
||||||
if (!("encoded" in encodedData)) {
|
|
||||||
// legacy, un-encoded scene JSON
|
|
||||||
if (
|
|
||||||
"type" in encodedData &&
|
|
||||||
encodedData.type === EXPORT_DATA_TYPES.excalidraw
|
|
||||||
) {
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
throw new Error("FAILED");
|
|
||||||
}
|
|
||||||
return decode(encodedData);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error);
|
|
||||||
throw new Error("FAILED");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error("INVALID");
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
DEFAULT_EXPORT_PADDING,
|
DEFAULT_EXPORT_PADDING,
|
||||||
DEFAULT_FILENAME,
|
DEFAULT_FILENAME,
|
||||||
|
IMAGE_MIME_TYPES,
|
||||||
isFirefox,
|
isFirefox,
|
||||||
MIME_TYPES,
|
MIME_TYPES,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
@@ -15,8 +16,9 @@ import type {
|
|||||||
ExcalidrawFrameLikeElement,
|
ExcalidrawFrameLikeElement,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
|
import { getElementsOverlappingFrame } from "../frame";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { isSomeElementSelected, getSelectedElements } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { exportToCanvas, exportToSvg } from "../scene/export";
|
import { exportToCanvas, exportToSvg } from "../scene/export";
|
||||||
import type { ExportType } from "../scene/types";
|
import type { ExportType } from "../scene/types";
|
||||||
import type { AppState, BinaryFiles } from "../types";
|
import type { AppState, BinaryFiles } from "../types";
|
||||||
@@ -25,7 +27,6 @@ import { canvasToBlob } from "./blob";
|
|||||||
import type { FileSystemHandle } from "./filesystem";
|
import type { FileSystemHandle } from "./filesystem";
|
||||||
import { fileSave } from "./filesystem";
|
import { fileSave } from "./filesystem";
|
||||||
import { serializeAsJSON } from "./json";
|
import { serializeAsJSON } from "./json";
|
||||||
import { getElementsOverlappingFrame } from "../frame";
|
|
||||||
|
|
||||||
export { loadFromBlob } from "./blob";
|
export { loadFromBlob } from "./blob";
|
||||||
export { loadFromJSON, saveAsJSON } from "./json";
|
export { loadFromJSON, saveAsJSON } from "./json";
|
||||||
@@ -130,6 +131,7 @@ export const exportCanvas = async (
|
|||||||
description: "Export to SVG",
|
description: "Export to SVG",
|
||||||
name,
|
name,
|
||||||
extension: appState.exportEmbedScene ? "excalidraw.svg" : "svg",
|
extension: appState.exportEmbedScene ? "excalidraw.svg" : "svg",
|
||||||
|
mimeTypes: [IMAGE_MIME_TYPES.svg],
|
||||||
fileHandle,
|
fileHandle,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -168,9 +170,8 @@ export const exportCanvas = async (
|
|||||||
return fileSave(blob, {
|
return fileSave(blob, {
|
||||||
description: "Export to PNG",
|
description: "Export to PNG",
|
||||||
name,
|
name,
|
||||||
// FIXME reintroduce `excalidraw.png` when most people upgrade away
|
extension: appState.exportEmbedScene ? "excalidraw.png" : "png",
|
||||||
// from 111.0.5563.64 (arm64), see #6349
|
mimeTypes: [IMAGE_MIME_TYPES.png],
|
||||||
extension: /* appState.exportEmbedScene ? "excalidraw.png" : */ "png",
|
|
||||||
fileHandle,
|
fileHandle,
|
||||||
});
|
});
|
||||||
} else if (type === "clipboard") {
|
} else if (type === "clipboard") {
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import type {
|
|||||||
} from "../types";
|
} from "../types";
|
||||||
import { restoreLibraryItems } from "./restore";
|
import { restoreLibraryItems } from "./restore";
|
||||||
import type App from "../components/App";
|
import type App from "../components/App";
|
||||||
import { atom } from "jotai";
|
import { atom, editorJotaiStore } from "../editor-jotai";
|
||||||
import { jotaiStore } from "../jotai";
|
|
||||||
import type { ExcalidrawElement } from "../element/types";
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
import { getCommonBoundingBox } from "../element/bounds";
|
import { getCommonBoundingBox } from "../element/bounds";
|
||||||
import { AbortError } from "../errors";
|
import { AbortError } from "../errors";
|
||||||
@@ -35,6 +34,9 @@ import type { MaybePromise } from "../utility-types";
|
|||||||
import { Emitter } from "../emitter";
|
import { Emitter } from "../emitter";
|
||||||
import { Queue } from "../queue";
|
import { Queue } from "../queue";
|
||||||
import { hashElementsVersion, hashString } from "../element";
|
import { hashElementsVersion, hashString } from "../element";
|
||||||
|
import { toValidURL } from "./url";
|
||||||
|
|
||||||
|
const ALLOWED_LIBRARY_HOSTNAMES = ["excalidraw.com"];
|
||||||
|
|
||||||
type LibraryUpdate = {
|
type LibraryUpdate = {
|
||||||
/** deleted library items since last onLibraryChange event */
|
/** deleted library items since last onLibraryChange event */
|
||||||
@@ -188,13 +190,13 @@ class Library {
|
|||||||
|
|
||||||
private notifyListeners = () => {
|
private notifyListeners = () => {
|
||||||
if (this.updateQueue.length > 0) {
|
if (this.updateQueue.length > 0) {
|
||||||
jotaiStore.set(libraryItemsAtom, (s) => ({
|
editorJotaiStore.set(libraryItemsAtom, (s) => ({
|
||||||
status: "loading",
|
status: "loading",
|
||||||
libraryItems: this.currLibraryItems,
|
libraryItems: this.currLibraryItems,
|
||||||
isInitialized: s.isInitialized,
|
isInitialized: s.isInitialized,
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
jotaiStore.set(libraryItemsAtom, {
|
editorJotaiStore.set(libraryItemsAtom, {
|
||||||
status: "loaded",
|
status: "loaded",
|
||||||
libraryItems: this.currLibraryItems,
|
libraryItems: this.currLibraryItems,
|
||||||
isInitialized: true,
|
isInitialized: true,
|
||||||
@@ -222,7 +224,7 @@ class Library {
|
|||||||
destroy = () => {
|
destroy = () => {
|
||||||
this.updateQueue = [];
|
this.updateQueue = [];
|
||||||
this.currLibraryItems = [];
|
this.currLibraryItems = [];
|
||||||
jotaiStore.set(libraryItemSvgsCache, new Map());
|
editorJotaiStore.set(libraryItemSvgsCache, new Map());
|
||||||
// TODO uncomment after/if we make jotai store scoped to each excal instance
|
// TODO uncomment after/if we make jotai store scoped to each excal instance
|
||||||
// jotaiStore.set(libraryItemsAtom, {
|
// jotaiStore.set(libraryItemsAtom, {
|
||||||
// status: "loading",
|
// status: "loading",
|
||||||
@@ -467,6 +469,28 @@ export const distributeLibraryItemsOnSquareGrid = (
|
|||||||
return resElements;
|
return resElements;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const validateLibraryUrl = (
|
||||||
|
libraryUrl: string,
|
||||||
|
/**
|
||||||
|
* If supplied, takes precedence over the default whitelist.
|
||||||
|
* Return `true` if the URL is valid.
|
||||||
|
*/
|
||||||
|
validator?: (libraryUrl: string) => boolean,
|
||||||
|
): boolean => {
|
||||||
|
if (
|
||||||
|
validator
|
||||||
|
? validator(libraryUrl)
|
||||||
|
: ALLOWED_LIBRARY_HOSTNAMES.includes(
|
||||||
|
new URL(libraryUrl).hostname.split(".").slice(-2).join("."),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`Invalid or disallowed library URL: "${libraryUrl}"`);
|
||||||
|
throw new Error("Invalid or disallowed library URL");
|
||||||
|
};
|
||||||
|
|
||||||
export const parseLibraryTokensFromUrl = () => {
|
export const parseLibraryTokensFromUrl = () => {
|
||||||
const libraryUrl =
|
const libraryUrl =
|
||||||
// current
|
// current
|
||||||
@@ -608,6 +632,11 @@ const persistLibraryUpdate = async (
|
|||||||
export const useHandleLibrary = (
|
export const useHandleLibrary = (
|
||||||
opts: {
|
opts: {
|
||||||
excalidrawAPI: ExcalidrawImperativeAPI | null;
|
excalidrawAPI: ExcalidrawImperativeAPI | null;
|
||||||
|
/**
|
||||||
|
* Return `true` if the library install url should be allowed.
|
||||||
|
* If not supplied, only the excalidraw.com base domain is allowed.
|
||||||
|
*/
|
||||||
|
validateLibraryUrl?: (libraryUrl: string) => boolean;
|
||||||
} & (
|
} & (
|
||||||
| {
|
| {
|
||||||
/** @deprecated we recommend using `opts.adapter` instead */
|
/** @deprecated we recommend using `opts.adapter` instead */
|
||||||
@@ -650,7 +679,13 @@ export const useHandleLibrary = (
|
|||||||
}) => {
|
}) => {
|
||||||
const libraryPromise = new Promise<Blob>(async (resolve, reject) => {
|
const libraryPromise = new Promise<Blob>(async (resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
const request = await fetch(decodeURIComponent(libraryUrl));
|
libraryUrl = decodeURIComponent(libraryUrl);
|
||||||
|
|
||||||
|
libraryUrl = toValidURL(libraryUrl);
|
||||||
|
|
||||||
|
validateLibraryUrl(libraryUrl, optsRef.current.validateLibraryUrl);
|
||||||
|
|
||||||
|
const request = await fetch(libraryUrl);
|
||||||
const blob = await request.blob();
|
const blob = await request.blob();
|
||||||
resolve(blob);
|
resolve(blob);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -678,7 +713,12 @@ export const useHandleLibrary = (
|
|||||||
defaultStatus: "published",
|
defaultStatus: "published",
|
||||||
openLibraryMenu: true,
|
openLibraryMenu: true,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
|
excalidrawAPI.updateScene({
|
||||||
|
appState: {
|
||||||
|
errorMessage: error.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) {
|
if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
|
ExcalidrawElbowArrowElement,
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawElementType,
|
ExcalidrawElementType,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
@@ -101,23 +102,38 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
|
|||||||
return DEFAULT_FONT_FAMILY;
|
return DEFAULT_FONT_FAMILY;
|
||||||
};
|
};
|
||||||
|
|
||||||
const repairBinding = (
|
const repairBinding = <T extends ExcalidrawLinearElement>(
|
||||||
element: ExcalidrawLinearElement,
|
element: T,
|
||||||
binding: PointBinding | FixedPointBinding | null,
|
binding: PointBinding | FixedPointBinding | null,
|
||||||
): PointBinding | FixedPointBinding | null => {
|
): T extends ExcalidrawElbowArrowElement
|
||||||
|
? FixedPointBinding | null
|
||||||
|
: PointBinding | FixedPointBinding | null => {
|
||||||
if (!binding) {
|
if (!binding) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const focus = binding.focus || 0;
|
||||||
...binding,
|
|
||||||
focus: binding.focus || 0,
|
if (isElbowArrow(element)) {
|
||||||
...(isElbowArrow(element) && isFixedPointBinding(binding)
|
const fixedPointBinding:
|
||||||
|
| ExcalidrawElbowArrowElement["startBinding"]
|
||||||
|
| ExcalidrawElbowArrowElement["endBinding"] = isFixedPointBinding(binding)
|
||||||
? {
|
? {
|
||||||
|
...binding,
|
||||||
|
focus,
|
||||||
fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]),
|
fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]),
|
||||||
}
|
}
|
||||||
: {}),
|
: null;
|
||||||
};
|
|
||||||
|
return fixedPointBinding;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...binding,
|
||||||
|
focus,
|
||||||
|
} as T extends ExcalidrawElbowArrowElement
|
||||||
|
? FixedPointBinding | null
|
||||||
|
: PointBinding | FixedPointBinding | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const restoreElementWithProperties = <
|
const restoreElementWithProperties = <
|
||||||
@@ -308,8 +324,7 @@ const restoreElement = (
|
|||||||
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
|
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Separate arrow from linear element
|
const base = {
|
||||||
return restoreElementWithProperties(element as ExcalidrawArrowElement, {
|
|
||||||
type: element.type,
|
type: element.type,
|
||||||
startBinding: repairBinding(element, element.startBinding),
|
startBinding: repairBinding(element, element.startBinding),
|
||||||
endBinding: repairBinding(element, element.endBinding),
|
endBinding: repairBinding(element, element.endBinding),
|
||||||
@@ -321,7 +336,20 @@ const restoreElement = (
|
|||||||
y,
|
y,
|
||||||
elbowed: (element as ExcalidrawArrowElement).elbowed,
|
elbowed: (element as ExcalidrawArrowElement).elbowed,
|
||||||
...getSizeFromPoints(points),
|
...getSizeFromPoints(points),
|
||||||
});
|
} as const;
|
||||||
|
|
||||||
|
// TODO: Separate arrow from linear element
|
||||||
|
return isElbowArrow(element)
|
||||||
|
? restoreElementWithProperties(element as ExcalidrawElbowArrowElement, {
|
||||||
|
...base,
|
||||||
|
elbowed: true,
|
||||||
|
startBinding: repairBinding(element, element.startBinding),
|
||||||
|
endBinding: repairBinding(element, element.endBinding),
|
||||||
|
fixedSegments: element.fixedSegments,
|
||||||
|
startIsSpecial: element.startIsSpecial,
|
||||||
|
endIsSpecial: element.endIsSpecial,
|
||||||
|
})
|
||||||
|
: restoreElementWithProperties(element as ExcalidrawArrowElement, base);
|
||||||
}
|
}
|
||||||
|
|
||||||
// generic elements
|
// generic elements
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ describe("normalizeLink", () => {
|
|||||||
expect(normalizeLink("file://")).toBe("file://");
|
expect(normalizeLink("file://")).toBe("file://");
|
||||||
expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)");
|
expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)");
|
||||||
expect(normalizeLink("[[test]]")).toBe("[[test]]");
|
expect(normalizeLink("[[test]]")).toBe("[[test]]");
|
||||||
expect(normalizeLink("<test>")).toBe("<test>");
|
expect(normalizeLink("<test>")).toBe("<test>");
|
||||||
|
expect(normalizeLink("test&")).toBe("test&");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { sanitizeUrl } from "@braintree/sanitize-url";
|
import { sanitizeUrl } from "@braintree/sanitize-url";
|
||||||
|
import { sanitizeHTMLAttribute } from "../utils";
|
||||||
export const sanitizeHTMLAttribute = (html: string) => {
|
|
||||||
return html.replace(/"/g, """);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const normalizeLink = (link: string) => {
|
export const normalizeLink = (link: string) => {
|
||||||
link = link.trim();
|
link = link.trim();
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
isFixedPointBinding,
|
isFixedPointBinding,
|
||||||
|
isFrameLikeElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
isRectangularElement,
|
isRectangularElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
@@ -503,12 +504,6 @@ export const bindLinearElement = (
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// update bound elements to make sure the binding tips are in sync with
|
|
||||||
// the normalized gap from above
|
|
||||||
if (!isElbowArrow(linearElement)) {
|
|
||||||
updateBoundElements(hoveredElement, elementsMap);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Don't bind both ends of a simple segment
|
// Don't bind both ends of a simple segment
|
||||||
@@ -575,7 +570,7 @@ export const getHoveredElementForBinding = (
|
|||||||
zoom,
|
zoom,
|
||||||
// disable fullshape snapping for frame elements so we
|
// disable fullshape snapping for frame elements so we
|
||||||
// can bind to frame children
|
// can bind to frame children
|
||||||
fullShape,
|
fullShape && !isFrameLikeElement(element),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -628,11 +623,9 @@ export const updateBoundElements = (
|
|||||||
simultaneouslyUpdated?: readonly ExcalidrawElement[];
|
simultaneouslyUpdated?: readonly ExcalidrawElement[];
|
||||||
newSize?: { width: number; height: number };
|
newSize?: { width: number; height: number };
|
||||||
changedElements?: Map<string, OrderedExcalidrawElement>;
|
changedElements?: Map<string, OrderedExcalidrawElement>;
|
||||||
zoom?: AppState["zoom"];
|
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const { newSize, simultaneouslyUpdated, changedElements, zoom } =
|
const { newSize, simultaneouslyUpdated } = options ?? {};
|
||||||
options ?? {};
|
|
||||||
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
|
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
|
||||||
simultaneouslyUpdated,
|
simultaneouslyUpdated,
|
||||||
);
|
);
|
||||||
@@ -666,7 +659,7 @@ export const updateBoundElements = (
|
|||||||
|
|
||||||
// `linearElement` is being moved/scaled already, just update the binding
|
// `linearElement` is being moved/scaled already, just update the binding
|
||||||
if (simultaneouslyUpdatedElementIds.has(element.id)) {
|
if (simultaneouslyUpdatedElementIds.has(element.id)) {
|
||||||
mutateElement(element, bindings);
|
mutateElement(element, bindings, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,23 +701,14 @@ export const updateBoundElements = (
|
|||||||
}> => update !== null,
|
}> => update !== null,
|
||||||
);
|
);
|
||||||
|
|
||||||
LinearElementEditor.movePoints(
|
LinearElementEditor.movePoints(element, updates, {
|
||||||
element,
|
...(changedElement.id === element.startBinding?.elementId
|
||||||
updates,
|
? { startBinding: bindings.startBinding }
|
||||||
elementsMap,
|
: {}),
|
||||||
{
|
...(changedElement.id === element.endBinding?.elementId
|
||||||
...(changedElement.id === element.startBinding?.elementId
|
? { endBinding: bindings.endBinding }
|
||||||
? { startBinding: bindings.startBinding }
|
: {}),
|
||||||
: {}),
|
});
|
||||||
...(changedElement.id === element.endBinding?.elementId
|
|
||||||
? { endBinding: bindings.endBinding }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
changedElements,
|
|
||||||
zoom,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const boundText = getBoundTextElement(element, elementsMap);
|
const boundText = getBoundTextElement(element, elementsMap);
|
||||||
if (boundText && !boundText.isDeleted) {
|
if (boundText && !boundText.isDeleted) {
|
||||||
@@ -783,9 +767,7 @@ export const getHeadingForElbowArrowSnap = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pointHeading = headingForPointFromElement(bindableElement, aabb, p);
|
return headingForPointFromElement(bindableElement, aabb, p);
|
||||||
|
|
||||||
return pointHeading;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDistanceForBinding = (
|
const getDistanceForBinding = (
|
||||||
@@ -2288,7 +2270,7 @@ export const getGlobalFixedPointForBindableElement = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getGlobalFixedPoints = (
|
export const getGlobalFixedPoints = (
|
||||||
arrow: ExcalidrawElbowArrowElement,
|
arrow: ExcalidrawElbowArrowElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
): [GlobalPoint, GlobalPoint] => {
|
): [GlobalPoint, GlobalPoint] => {
|
||||||
|
|||||||
@@ -556,6 +556,10 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
|
|||||||
case "diamond":
|
case "diamond":
|
||||||
case "diamond_outline":
|
case "diamond_outline":
|
||||||
return 12;
|
return 12;
|
||||||
|
case "crowfoot_many":
|
||||||
|
case "crowfoot_one":
|
||||||
|
case "crowfoot_one_or_many":
|
||||||
|
return 20;
|
||||||
default:
|
default:
|
||||||
return 15;
|
return 15;
|
||||||
}
|
}
|
||||||
@@ -669,6 +673,21 @@ export const getArrowheadPoints = (
|
|||||||
|
|
||||||
const angle = getArrowheadAngle(arrowhead);
|
const angle = getArrowheadAngle(arrowhead);
|
||||||
|
|
||||||
|
if (arrowhead === "crowfoot_many" || arrowhead === "crowfoot_one_or_many") {
|
||||||
|
// swap (xs, ys) with (x2, y2)
|
||||||
|
const [x3, y3] = pointRotateRads(
|
||||||
|
pointFrom(x2, y2),
|
||||||
|
pointFrom(xs, ys),
|
||||||
|
degreesToRadians(-angle as Degrees),
|
||||||
|
);
|
||||||
|
const [x4, y4] = pointRotateRads(
|
||||||
|
pointFrom(x2, y2),
|
||||||
|
pointFrom(xs, ys),
|
||||||
|
degreesToRadians(angle),
|
||||||
|
);
|
||||||
|
return [xs, ys, x3, y3, x4, y4];
|
||||||
|
}
|
||||||
|
|
||||||
// Return points
|
// Return points
|
||||||
const [x3, y3] = pointRotateRads(
|
const [x3, y3] = pointRotateRads(
|
||||||
pointFrom(xs, ys),
|
pointFrom(xs, ys),
|
||||||
|
|||||||
@@ -42,9 +42,20 @@ export const dragSelectedElements = (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedElements = _selectedElements.filter(
|
const selectedElements = _selectedElements.filter((element) => {
|
||||||
(el) => !(isElbowArrow(el) && el.startBinding && el.endBinding),
|
if (isElbowArrow(element) && element.startBinding && element.endBinding) {
|
||||||
);
|
const startElement = _selectedElements.find(
|
||||||
|
(el) => el.id === element.startBinding?.elementId,
|
||||||
|
);
|
||||||
|
const endElement = _selectedElements.find(
|
||||||
|
(el) => el.id === element.endBinding?.elementId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return startElement && endElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
// we do not want a frame and its elements to be selected at the same time
|
// we do not want a frame and its elements to be selected at the same time
|
||||||
// but when it happens (due to some bug), we want to avoid updating element
|
// but when it happens (due to some bug), we want to avoid updating element
|
||||||
@@ -78,10 +89,8 @@ export const dragSelectedElements = (
|
|||||||
|
|
||||||
elementsToUpdate.forEach((element) => {
|
elementsToUpdate.forEach((element) => {
|
||||||
updateElementCoords(pointerDownState, element, adjustedOffset);
|
updateElementCoords(pointerDownState, element, adjustedOffset);
|
||||||
if (
|
if (!isArrowElement(element)) {
|
||||||
// skip arrow labels since we calculate its position during render
|
// skip arrow labels since we calculate its position during render
|
||||||
!isArrowElement(element)
|
|
||||||
) {
|
|
||||||
const textElement = getBoundTextElement(
|
const textElement = getBoundTextElement(
|
||||||
element,
|
element,
|
||||||
scene.getNonDeletedElementsMap(),
|
scene.getNonDeletedElementsMap(),
|
||||||
@@ -89,10 +98,10 @@ export const dragSelectedElements = (
|
|||||||
if (textElement) {
|
if (textElement) {
|
||||||
updateElementCoords(pointerDownState, textElement, adjustedOffset);
|
updateElementCoords(pointerDownState, textElement, adjustedOffset);
|
||||||
}
|
}
|
||||||
|
updateBoundElements(element, scene.getElementsMapIncludingDeleted(), {
|
||||||
|
simultaneouslyUpdated: Array.from(elementsToUpdate),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
updateBoundElements(element, scene.getElementsMapIncludingDeleted(), {
|
|
||||||
simultaneouslyUpdated: Array.from(elementsToUpdate),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+112
-9
@@ -9,20 +9,121 @@ import {
|
|||||||
render,
|
render,
|
||||||
} from "../tests/test-utils";
|
} from "../tests/test-utils";
|
||||||
import { bindLinearElement } from "./binding";
|
import { bindLinearElement } from "./binding";
|
||||||
import { Excalidraw } from "../index";
|
import { Excalidraw, mutateElement } from "../index";
|
||||||
import { mutateElbowArrow } from "./routing";
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
ExcalidrawBindableElement,
|
ExcalidrawBindableElement,
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { ARROW_TYPE } from "../constants";
|
import { ARROW_TYPE } from "../constants";
|
||||||
|
import type { LocalPoint } from "../../math";
|
||||||
import { pointFrom } from "../../math";
|
import { pointFrom } from "../../math";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
const mouse = new Pointer("mouse");
|
const mouse = new Pointer("mouse");
|
||||||
|
|
||||||
|
describe("elbow arrow segment move", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
localStorage.clear();
|
||||||
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can move the second segment of a fully connected elbow arrow", () => {
|
||||||
|
UI.createElement("rectangle", {
|
||||||
|
x: -100,
|
||||||
|
y: -50,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
UI.createElement("rectangle", {
|
||||||
|
x: 200,
|
||||||
|
y: 150,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
UI.clickTool("arrow");
|
||||||
|
UI.clickOnTestId("elbow-arrow");
|
||||||
|
|
||||||
|
mouse.reset();
|
||||||
|
mouse.moveTo(0, 0);
|
||||||
|
mouse.click();
|
||||||
|
mouse.moveTo(200, 200);
|
||||||
|
mouse.click();
|
||||||
|
|
||||||
|
mouse.reset();
|
||||||
|
mouse.moveTo(100, 100);
|
||||||
|
mouse.down();
|
||||||
|
mouse.moveTo(115, 100);
|
||||||
|
mouse.up();
|
||||||
|
|
||||||
|
const arrow = h.scene.getSelectedElements(
|
||||||
|
h.state,
|
||||||
|
)[0] as ExcalidrawElbowArrowElement;
|
||||||
|
|
||||||
|
expect(h.state.selectedElementIds).toEqual({ [arrow.id]: true });
|
||||||
|
expect(arrow.fixedSegments?.length).toBe(1);
|
||||||
|
|
||||||
|
expect(arrow.points).toCloselyEqualPoints([
|
||||||
|
[0, 0],
|
||||||
|
[110, 0],
|
||||||
|
[110, 200],
|
||||||
|
[190, 200],
|
||||||
|
]);
|
||||||
|
|
||||||
|
mouse.reset();
|
||||||
|
mouse.moveTo(105, 74.275);
|
||||||
|
mouse.doubleClick();
|
||||||
|
|
||||||
|
expect(arrow.points).toCloselyEqualPoints([
|
||||||
|
[0, 0],
|
||||||
|
[110, 0],
|
||||||
|
[110, 200],
|
||||||
|
[190, 200],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can move the second segment of an unconnected elbow arrow", () => {
|
||||||
|
UI.clickTool("arrow");
|
||||||
|
UI.clickOnTestId("elbow-arrow");
|
||||||
|
|
||||||
|
mouse.reset();
|
||||||
|
mouse.moveTo(0, 0);
|
||||||
|
mouse.click();
|
||||||
|
mouse.moveTo(250, 200);
|
||||||
|
mouse.click();
|
||||||
|
|
||||||
|
mouse.reset();
|
||||||
|
mouse.moveTo(125, 100);
|
||||||
|
mouse.down();
|
||||||
|
mouse.moveTo(130, 100);
|
||||||
|
mouse.up();
|
||||||
|
|
||||||
|
const arrow = h.scene.getSelectedElements(
|
||||||
|
h.state,
|
||||||
|
)[0] as ExcalidrawArrowElement;
|
||||||
|
|
||||||
|
expect(arrow.points).toCloselyEqualPoints([
|
||||||
|
[0, 0],
|
||||||
|
[130, 0],
|
||||||
|
[130, 200],
|
||||||
|
[250, 200],
|
||||||
|
]);
|
||||||
|
|
||||||
|
mouse.reset();
|
||||||
|
mouse.moveTo(130, 100);
|
||||||
|
mouse.doubleClick();
|
||||||
|
|
||||||
|
expect(arrow.points).toCloselyEqualPoints([
|
||||||
|
[0, 0],
|
||||||
|
[125, 0],
|
||||||
|
[125, 200],
|
||||||
|
[250, 200],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("elbow arrow routing", () => {
|
describe("elbow arrow routing", () => {
|
||||||
it("can properly generate orthogonal arrow points", () => {
|
it("can properly generate orthogonal arrow points", () => {
|
||||||
const scene = new Scene();
|
const scene = new Scene();
|
||||||
@@ -31,10 +132,12 @@ describe("elbow arrow routing", () => {
|
|||||||
elbowed: true,
|
elbowed: true,
|
||||||
}) as ExcalidrawElbowArrowElement;
|
}) as ExcalidrawElbowArrowElement;
|
||||||
scene.insertElement(arrow);
|
scene.insertElement(arrow);
|
||||||
mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [
|
mutateElement(arrow, {
|
||||||
pointFrom(-45 - arrow.x, -100.1 - arrow.y),
|
points: [
|
||||||
pointFrom(45 - arrow.x, 99.9 - arrow.y),
|
pointFrom<LocalPoint>(-45 - arrow.x, -100.1 - arrow.y),
|
||||||
]);
|
pointFrom<LocalPoint>(45 - arrow.x, 99.9 - arrow.y),
|
||||||
|
],
|
||||||
|
});
|
||||||
expect(arrow.points).toEqual([
|
expect(arrow.points).toEqual([
|
||||||
[0, 0],
|
[0, 0],
|
||||||
[0, 100],
|
[0, 100],
|
||||||
@@ -81,7 +184,9 @@ describe("elbow arrow routing", () => {
|
|||||||
expect(arrow.startBinding).not.toBe(null);
|
expect(arrow.startBinding).not.toBe(null);
|
||||||
expect(arrow.endBinding).not.toBe(null);
|
expect(arrow.endBinding).not.toBe(null);
|
||||||
|
|
||||||
mutateElbowArrow(arrow, elementsMap, [pointFrom(0, 0), pointFrom(90, 200)]);
|
mutateElement(arrow, {
|
||||||
|
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
|
||||||
|
});
|
||||||
|
|
||||||
expect(arrow.points).toEqual([
|
expect(arrow.points).toEqual([
|
||||||
[0, 0],
|
[0, 0],
|
||||||
@@ -182,8 +287,6 @@ describe("elbow arrow ui", () => {
|
|||||||
expect(arrow.points.map((point) => point.map(Math.round))).toEqual([
|
expect(arrow.points.map((point) => point.map(Math.round))).toEqual([
|
||||||
[0, 0],
|
[0, 0],
|
||||||
[35, 0],
|
[35, 0],
|
||||||
[35, 90],
|
|
||||||
[35, 90], // Note that coordinates are rounded above!
|
|
||||||
[35, 165],
|
[35, 165],
|
||||||
[103, 165],
|
[103, 165],
|
||||||
]);
|
]);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,11 @@
|
|||||||
import { register } from "../actions/register";
|
import { register } from "../actions/register";
|
||||||
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
|
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
|
||||||
import type { ExcalidrawProps } from "../types";
|
import type { ExcalidrawProps } from "../types";
|
||||||
import { getFontString, updateActiveTool } from "../utils";
|
import {
|
||||||
|
getFontString,
|
||||||
|
sanitizeHTMLAttribute,
|
||||||
|
updateActiveTool,
|
||||||
|
} from "../utils";
|
||||||
import { setCursorForShape } from "../cursor";
|
import { setCursorForShape } from "../cursor";
|
||||||
import { newTextElement } from "./newElement";
|
import { newTextElement } from "./newElement";
|
||||||
import { wrapText } from "./textWrapping";
|
import { wrapText } from "./textWrapping";
|
||||||
@@ -11,7 +15,6 @@ import type {
|
|||||||
ExcalidrawIframeLikeElement,
|
ExcalidrawIframeLikeElement,
|
||||||
IframeData,
|
IframeData,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { sanitizeHTMLAttribute } from "../data/url";
|
|
||||||
import type { MarkRequired } from "../utility-types";
|
import type { MarkRequired } from "../utility-types";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
|
|||||||
@@ -452,20 +452,12 @@ const createBindingArrow = (
|
|||||||
bindingArrow as OrderedExcalidrawElement,
|
bindingArrow as OrderedExcalidrawElement,
|
||||||
);
|
);
|
||||||
|
|
||||||
LinearElementEditor.movePoints(
|
LinearElementEditor.movePoints(bindingArrow, [
|
||||||
bindingArrow,
|
|
||||||
[
|
|
||||||
{
|
|
||||||
index: 1,
|
|
||||||
point: bindingArrow.points[1],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
elementsMap as NonDeletedSceneElementsMap,
|
|
||||||
undefined,
|
|
||||||
{
|
{
|
||||||
changedElements,
|
index: 1,
|
||||||
|
point: bindingArrow.points[1],
|
||||||
},
|
},
|
||||||
);
|
]);
|
||||||
|
|
||||||
return bindingArrow;
|
return bindingArrow;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
pointScaleFromOrigin,
|
pointScaleFromOrigin,
|
||||||
radiansToDegrees,
|
radiansToDegrees,
|
||||||
triangleIncludesPoint,
|
triangleIncludesPoint,
|
||||||
|
vectorFromPoint,
|
||||||
} from "../../math";
|
} from "../../math";
|
||||||
import { getCenterForBounds, type Bounds } from "./bounds";
|
import { getCenterForBounds, type Bounds } from "./bounds";
|
||||||
import type { ExcalidrawBindableElement } from "./types";
|
import type { ExcalidrawBindableElement } from "./types";
|
||||||
@@ -52,9 +53,24 @@ export const vectorToHeading = (vec: Vector): Heading => {
|
|||||||
return HEADING_UP;
|
return HEADING_UP;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const headingForPoint = <P extends GlobalPoint | LocalPoint>(
|
||||||
|
p: P,
|
||||||
|
o: P,
|
||||||
|
) => vectorToHeading(vectorFromPoint<P>(p, o));
|
||||||
|
|
||||||
|
export const headingForPointIsHorizontal = <P extends GlobalPoint | LocalPoint>(
|
||||||
|
p: P,
|
||||||
|
o: P,
|
||||||
|
) => headingIsHorizontal(headingForPoint<P>(p, o));
|
||||||
|
|
||||||
export const compareHeading = (a: Heading, b: Heading) =>
|
export const compareHeading = (a: Heading, b: Heading) =>
|
||||||
a[0] === b[0] && a[1] === b[1];
|
a[0] === b[0] && a[1] === b[1];
|
||||||
|
|
||||||
|
export const headingIsHorizontal = (a: Heading) =>
|
||||||
|
compareHeading(a, HEADING_RIGHT) || compareHeading(a, HEADING_LEFT);
|
||||||
|
|
||||||
|
export const headingIsVertical = (a: Heading) => !headingIsHorizontal(a);
|
||||||
|
|
||||||
// Gets the heading for the point by creating a bounding box around the rotated
|
// Gets the heading for the point by creating a bounding box around the rotated
|
||||||
// close fitting bounding box, then creating 4 search cones around the center of
|
// close fitting bounding box, then creating 4 search cones around the center of
|
||||||
// the external bbox.
|
// the external bbox.
|
||||||
@@ -63,7 +79,7 @@ export const headingForPointFromElement = <
|
|||||||
>(
|
>(
|
||||||
element: Readonly<ExcalidrawBindableElement>,
|
element: Readonly<ExcalidrawBindableElement>,
|
||||||
aabb: Readonly<Bounds>,
|
aabb: Readonly<Bounds>,
|
||||||
p: Readonly<LocalPoint | GlobalPoint>,
|
p: Readonly<Point>,
|
||||||
): Heading => {
|
): Heading => {
|
||||||
const SEARCH_CONE_MULTIPLIER = 2;
|
const SEARCH_CONE_MULTIPLIER = 2;
|
||||||
|
|
||||||
@@ -117,14 +133,22 @@ export const headingForPointFromElement = <
|
|||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (triangleIncludesPoint([top, right, midPoint] as Triangle<Point>, p)) {
|
if (
|
||||||
|
triangleIncludesPoint<Point>([top, right, midPoint] as Triangle<Point>, p)
|
||||||
|
) {
|
||||||
return headingForDiamond(top, right);
|
return headingForDiamond(top, right);
|
||||||
} else if (
|
} else if (
|
||||||
triangleIncludesPoint([right, bottom, midPoint] as Triangle<Point>, p)
|
triangleIncludesPoint<Point>(
|
||||||
|
[right, bottom, midPoint] as Triangle<Point>,
|
||||||
|
p,
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
return headingForDiamond(right, bottom);
|
return headingForDiamond(right, bottom);
|
||||||
} else if (
|
} else if (
|
||||||
triangleIncludesPoint([bottom, left, midPoint] as Triangle<Point>, p)
|
triangleIncludesPoint<Point>(
|
||||||
|
[bottom, left, midPoint] as Triangle<Point>,
|
||||||
|
p,
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
return headingForDiamond(bottom, left);
|
return headingForDiamond(bottom, left);
|
||||||
}
|
}
|
||||||
@@ -153,17 +177,17 @@ export const headingForPointFromElement = <
|
|||||||
SEARCH_CONE_MULTIPLIER,
|
SEARCH_CONE_MULTIPLIER,
|
||||||
) as Point;
|
) as Point;
|
||||||
|
|
||||||
return triangleIncludesPoint(
|
return triangleIncludesPoint<Point>(
|
||||||
[topLeft, topRight, midPoint] as Triangle<Point>,
|
[topLeft, topRight, midPoint] as Triangle<Point>,
|
||||||
p,
|
p,
|
||||||
)
|
)
|
||||||
? HEADING_UP
|
? HEADING_UP
|
||||||
: triangleIncludesPoint(
|
: triangleIncludesPoint<Point>(
|
||||||
[topRight, bottomRight, midPoint] as Triangle<Point>,
|
[topRight, bottomRight, midPoint] as Triangle<Point>,
|
||||||
p,
|
p,
|
||||||
)
|
)
|
||||||
? HEADING_RIGHT
|
? HEADING_RIGHT
|
||||||
: triangleIncludesPoint(
|
: triangleIncludesPoint<Point>(
|
||||||
[bottomRight, bottomLeft, midPoint] as Triangle<Point>,
|
[bottomRight, bottomLeft, midPoint] as Triangle<Point>,
|
||||||
p,
|
p,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import type {
|
|||||||
ExcalidrawTextElementWithContainer,
|
ExcalidrawTextElementWithContainer,
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
NonDeletedSceneElementsMap,
|
NonDeletedSceneElementsMap,
|
||||||
OrderedExcalidrawElement,
|
|
||||||
FixedPointBinding,
|
FixedPointBinding,
|
||||||
SceneElementsMap,
|
SceneElementsMap,
|
||||||
|
FixedSegment,
|
||||||
|
ExcalidrawElbowArrowElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
|
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
|
||||||
import type { Bounds } from "./bounds";
|
import type { Bounds } from "./bounds";
|
||||||
@@ -24,6 +25,7 @@ import type {
|
|||||||
InteractiveCanvasAppState,
|
InteractiveCanvasAppState,
|
||||||
AppClassProperties,
|
AppClassProperties,
|
||||||
NullableGridSize,
|
NullableGridSize,
|
||||||
|
Zoom,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
|
|
||||||
@@ -32,7 +34,7 @@ import {
|
|||||||
getHoveredElementForBinding,
|
getHoveredElementForBinding,
|
||||||
isBindingEnabled,
|
isBindingEnabled,
|
||||||
} from "./binding";
|
} from "./binding";
|
||||||
import { invariant, toBrandedType, tupleToCoors } from "../utils";
|
import { invariant, tupleToCoors } from "../utils";
|
||||||
import {
|
import {
|
||||||
isBindingElement,
|
isBindingElement,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
@@ -44,7 +46,6 @@ import { DRAGGING_THRESHOLD } from "../constants";
|
|||||||
import type { Mutable } from "../utility-types";
|
import type { Mutable } from "../utility-types";
|
||||||
import { ShapeCache } from "../scene/ShapeCache";
|
import { ShapeCache } from "../scene/ShapeCache";
|
||||||
import type { Store } from "../store";
|
import type { Store } from "../store";
|
||||||
import { mutateElbowArrow } from "./routing";
|
|
||||||
import type Scene from "../scene/Scene";
|
import type Scene from "../scene/Scene";
|
||||||
import type { Radians } from "../../math";
|
import type { Radians } from "../../math";
|
||||||
import {
|
import {
|
||||||
@@ -56,6 +57,8 @@ import {
|
|||||||
type GlobalPoint,
|
type GlobalPoint,
|
||||||
type LocalPoint,
|
type LocalPoint,
|
||||||
pointDistance,
|
pointDistance,
|
||||||
|
pointTranslate,
|
||||||
|
vectorFromPoint,
|
||||||
} from "../../math";
|
} from "../../math";
|
||||||
import {
|
import {
|
||||||
getBezierCurveLength,
|
getBezierCurveLength,
|
||||||
@@ -65,6 +68,7 @@ import {
|
|||||||
mapIntervalToBezierT,
|
mapIntervalToBezierT,
|
||||||
} from "../shapes";
|
} from "../shapes";
|
||||||
import { getGridPoint } from "../snapping";
|
import { getGridPoint } from "../snapping";
|
||||||
|
import { headingIsHorizontal, vectorToHeading } from "./heading";
|
||||||
|
|
||||||
const editorMidPointsCache: {
|
const editorMidPointsCache: {
|
||||||
version: number | null;
|
version: number | null;
|
||||||
@@ -144,13 +148,13 @@ export class LinearElementEditor {
|
|||||||
* @param id the `elementId` from the instance of this class (so that we can
|
* @param id the `elementId` from the instance of this class (so that we can
|
||||||
* statically guarantee this method returns an ExcalidrawLinearElement)
|
* statically guarantee this method returns an ExcalidrawLinearElement)
|
||||||
*/
|
*/
|
||||||
static getElement(
|
static getElement<T extends ExcalidrawLinearElement>(
|
||||||
id: InstanceType<typeof LinearElementEditor>["elementId"],
|
id: InstanceType<typeof LinearElementEditor>["elementId"],
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
) {
|
): T | null {
|
||||||
const element = elementsMap.get(id);
|
const element = elementsMap.get(id);
|
||||||
if (element) {
|
if (element) {
|
||||||
return element as NonDeleted<ExcalidrawLinearElement>;
|
return element as NonDeleted<T>;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -291,20 +295,16 @@ export class LinearElementEditor {
|
|||||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||||
);
|
);
|
||||||
|
|
||||||
LinearElementEditor.movePoints(
|
LinearElementEditor.movePoints(element, [
|
||||||
element,
|
{
|
||||||
[
|
index: selectedIndex,
|
||||||
{
|
point: pointFrom(
|
||||||
index: selectedIndex,
|
width + referencePoint[0],
|
||||||
point: pointFrom(
|
height + referencePoint[1],
|
||||||
width + referencePoint[0],
|
),
|
||||||
height + referencePoint[1],
|
isDragging: selectedIndex === lastClickedPoint,
|
||||||
),
|
},
|
||||||
isDragging: selectedIndex === lastClickedPoint,
|
]);
|
||||||
},
|
|
||||||
],
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
const newDraggingPointPosition = LinearElementEditor.createPointAt(
|
const newDraggingPointPosition = LinearElementEditor.createPointAt(
|
||||||
element,
|
element,
|
||||||
@@ -339,7 +339,6 @@ export class LinearElementEditor {
|
|||||||
isDragging: pointIndex === lastClickedPoint,
|
isDragging: pointIndex === lastClickedPoint,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
elementsMap,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,19 +421,15 @@ export class LinearElementEditor {
|
|||||||
selectedPoint === element.points.length - 1
|
selectedPoint === element.points.length - 1
|
||||||
) {
|
) {
|
||||||
if (isPathALoop(element.points, appState.zoom.value)) {
|
if (isPathALoop(element.points, appState.zoom.value)) {
|
||||||
LinearElementEditor.movePoints(
|
LinearElementEditor.movePoints(element, [
|
||||||
element,
|
{
|
||||||
[
|
index: selectedPoint,
|
||||||
{
|
point:
|
||||||
index: selectedPoint,
|
selectedPoint === 0
|
||||||
point:
|
? element.points[element.points.length - 1]
|
||||||
selectedPoint === 0
|
: element.points[0],
|
||||||
? element.points[element.points.length - 1]
|
},
|
||||||
: element.points[0],
|
]);
|
||||||
},
|
|
||||||
],
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const bindingElement = isBindingEnabled(appState)
|
const bindingElement = isBindingEnabled(appState)
|
||||||
@@ -495,6 +490,7 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
// Since its not needed outside editor unless 2 pointer lines or bound text
|
// Since its not needed outside editor unless 2 pointer lines or bound text
|
||||||
if (
|
if (
|
||||||
|
!isElbowArrow(element) &&
|
||||||
!appState.editingLinearElement &&
|
!appState.editingLinearElement &&
|
||||||
element.points.length > 2 &&
|
element.points.length > 2 &&
|
||||||
!boundText
|
!boundText
|
||||||
@@ -533,6 +529,7 @@ export class LinearElementEditor {
|
|||||||
element,
|
element,
|
||||||
element.points[index],
|
element.points[index],
|
||||||
element.points[index + 1],
|
element.points[index + 1],
|
||||||
|
index,
|
||||||
appState.zoom,
|
appState.zoom,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
@@ -573,19 +570,23 @@ export class LinearElementEditor {
|
|||||||
scenePointer.x,
|
scenePointer.x,
|
||||||
scenePointer.y,
|
scenePointer.y,
|
||||||
);
|
);
|
||||||
if (clickedPointIndex >= 0) {
|
if (!isElbowArrow(element) && clickedPointIndex >= 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const points = LinearElementEditor.getPointsGlobalCoordinates(
|
const points = LinearElementEditor.getPointsGlobalCoordinates(
|
||||||
element,
|
element,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
if (points.length >= 3 && !appState.editingLinearElement) {
|
if (
|
||||||
|
points.length >= 3 &&
|
||||||
|
!appState.editingLinearElement &&
|
||||||
|
!isElbowArrow(element)
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const threshold =
|
const threshold =
|
||||||
LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value;
|
(LinearElementEditor.POINT_HANDLE_SIZE + 1) / appState.zoom.value;
|
||||||
|
|
||||||
const existingSegmentMidpointHitCoords =
|
const existingSegmentMidpointHitCoords =
|
||||||
linearElementEditor.segmentMidPointHoveredCoords;
|
linearElementEditor.segmentMidPointHoveredCoords;
|
||||||
@@ -604,10 +605,11 @@ export class LinearElementEditor {
|
|||||||
let index = 0;
|
let index = 0;
|
||||||
const midPoints: typeof editorMidPointsCache["points"] =
|
const midPoints: typeof editorMidPointsCache["points"] =
|
||||||
LinearElementEditor.getEditorMidPoints(element, elementsMap, appState);
|
LinearElementEditor.getEditorMidPoints(element, elementsMap, appState);
|
||||||
|
|
||||||
while (index < midPoints.length) {
|
while (index < midPoints.length) {
|
||||||
if (midPoints[index] !== null) {
|
if (midPoints[index] !== null) {
|
||||||
const distance = pointDistance(
|
const distance = pointDistance(
|
||||||
pointFrom(midPoints[index]![0], midPoints[index]![1]),
|
midPoints[index]!,
|
||||||
pointFrom(scenePointer.x, scenePointer.y),
|
pointFrom(scenePointer.x, scenePointer.y),
|
||||||
);
|
);
|
||||||
if (distance <= threshold) {
|
if (distance <= threshold) {
|
||||||
@@ -620,16 +622,25 @@ export class LinearElementEditor {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
static isSegmentTooShort(
|
static isSegmentTooShort<P extends GlobalPoint | LocalPoint>(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
startPoint: GlobalPoint | LocalPoint,
|
startPoint: P,
|
||||||
endPoint: GlobalPoint | LocalPoint,
|
endPoint: P,
|
||||||
zoom: AppState["zoom"],
|
index: number,
|
||||||
|
zoom: Zoom,
|
||||||
) {
|
) {
|
||||||
let distance = pointDistance(
|
if (isElbowArrow(element)) {
|
||||||
pointFrom(startPoint[0], startPoint[1]),
|
if (index >= 0 && index < element.points.length) {
|
||||||
pointFrom(endPoint[0], endPoint[1]),
|
return (
|
||||||
);
|
pointDistance(startPoint, endPoint) * zoom.value <
|
||||||
|
LinearElementEditor.POINT_HANDLE_SIZE / 2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let distance = pointDistance(startPoint, endPoint);
|
||||||
if (element.points.length > 2 && element.roundness) {
|
if (element.points.length > 2 && element.roundness) {
|
||||||
distance = getBezierCurveLength(element, endPoint);
|
distance = getBezierCurveLength(element, endPoint);
|
||||||
}
|
}
|
||||||
@@ -748,12 +759,8 @@ export class LinearElementEditor {
|
|||||||
segmentMidpoint,
|
segmentMidpoint,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
}
|
} else if (event.altKey && appState.editingLinearElement) {
|
||||||
if (event.altKey && appState.editingLinearElement) {
|
if (linearElementEditor.lastUncommittedPoint == null) {
|
||||||
if (
|
|
||||||
linearElementEditor.lastUncommittedPoint == null &&
|
|
||||||
!isElbowArrow(element)
|
|
||||||
) {
|
|
||||||
mutateElement(element, {
|
mutateElement(element, {
|
||||||
points: [
|
points: [
|
||||||
...element.points,
|
...element.points,
|
||||||
@@ -909,12 +916,7 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
if (!event.altKey) {
|
if (!event.altKey) {
|
||||||
if (lastPoint === lastUncommittedPoint) {
|
if (lastPoint === lastUncommittedPoint) {
|
||||||
LinearElementEditor.deletePoints(
|
LinearElementEditor.deletePoints(element, [points.length - 1]);
|
||||||
element,
|
|
||||||
[points.length - 1],
|
|
||||||
elementsMap,
|
|
||||||
app.state.zoom,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...appState.editingLinearElement,
|
...appState.editingLinearElement,
|
||||||
@@ -952,23 +954,14 @@ export class LinearElementEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (lastPoint === lastUncommittedPoint) {
|
if (lastPoint === lastUncommittedPoint) {
|
||||||
LinearElementEditor.movePoints(
|
LinearElementEditor.movePoints(element, [
|
||||||
element,
|
{
|
||||||
[
|
index: element.points.length - 1,
|
||||||
{
|
point: newPoint,
|
||||||
index: element.points.length - 1,
|
},
|
||||||
point: newPoint,
|
]);
|
||||||
},
|
|
||||||
],
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
LinearElementEditor.addPoints(
|
LinearElementEditor.addPoints(element, [{ point: newPoint }]);
|
||||||
element,
|
|
||||||
[{ point: newPoint }],
|
|
||||||
elementsMap,
|
|
||||||
app.state.zoom,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...appState.editingLinearElement,
|
...appState.editingLinearElement,
|
||||||
@@ -1197,16 +1190,12 @@ export class LinearElementEditor {
|
|||||||
// potentially expanding the bounding box
|
// potentially expanding the bounding box
|
||||||
if (pointAddedToEnd) {
|
if (pointAddedToEnd) {
|
||||||
const lastPoint = element.points[element.points.length - 1];
|
const lastPoint = element.points[element.points.length - 1];
|
||||||
LinearElementEditor.movePoints(
|
LinearElementEditor.movePoints(element, [
|
||||||
element,
|
{
|
||||||
[
|
index: element.points.length - 1,
|
||||||
{
|
point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
|
||||||
index: element.points.length - 1,
|
},
|
||||||
point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
|
]);
|
||||||
},
|
|
||||||
],
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1221,8 +1210,6 @@ export class LinearElementEditor {
|
|||||||
static deletePoints(
|
static deletePoints(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
pointIndices: readonly number[],
|
pointIndices: readonly number[],
|
||||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
|
||||||
zoom: AppState["zoom"],
|
|
||||||
) {
|
) {
|
||||||
let offsetX = 0;
|
let offsetX = 0;
|
||||||
let offsetY = 0;
|
let offsetY = 0;
|
||||||
@@ -1252,47 +1239,27 @@ export class LinearElementEditor {
|
|||||||
return acc;
|
return acc;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
LinearElementEditor._updatePoints(
|
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
|
||||||
element,
|
|
||||||
nextPoints,
|
|
||||||
offsetX,
|
|
||||||
offsetY,
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static addPoints(
|
static addPoints(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
targetPoints: { point: LocalPoint }[],
|
targetPoints: { point: LocalPoint }[],
|
||||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
|
||||||
zoom: AppState["zoom"],
|
|
||||||
) {
|
) {
|
||||||
const offsetX = 0;
|
const offsetX = 0;
|
||||||
const offsetY = 0;
|
const offsetY = 0;
|
||||||
|
|
||||||
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
|
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
|
||||||
LinearElementEditor._updatePoints(
|
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
|
||||||
element,
|
|
||||||
nextPoints,
|
|
||||||
offsetX,
|
|
||||||
offsetY,
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static movePoints(
|
static movePoints(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
|
targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
|
||||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
|
||||||
otherUpdates?: {
|
otherUpdates?: {
|
||||||
startBinding?: PointBinding | null;
|
startBinding?: PointBinding | null;
|
||||||
endBinding?: PointBinding | null;
|
endBinding?: PointBinding | null;
|
||||||
},
|
},
|
||||||
options?: {
|
|
||||||
changedElements?: Map<string, OrderedExcalidrawElement>;
|
|
||||||
isDragging?: boolean;
|
|
||||||
zoom?: AppState["zoom"];
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
const { points } = element;
|
const { points } = element;
|
||||||
|
|
||||||
@@ -1335,7 +1302,6 @@ export class LinearElementEditor {
|
|||||||
nextPoints,
|
nextPoints,
|
||||||
offsetX,
|
offsetX,
|
||||||
offsetY,
|
offsetY,
|
||||||
elementsMap,
|
|
||||||
otherUpdates,
|
otherUpdates,
|
||||||
{
|
{
|
||||||
isDragging: targetPoints.reduce(
|
isDragging: targetPoints.reduce(
|
||||||
@@ -1343,8 +1309,6 @@ export class LinearElementEditor {
|
|||||||
dragging || targetPoint.isDragging === true,
|
dragging || targetPoint.isDragging === true,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
changedElements: options?.changedElements,
|
|
||||||
zoom: options?.zoom,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1451,54 +1415,49 @@ export class LinearElementEditor {
|
|||||||
nextPoints: readonly LocalPoint[],
|
nextPoints: readonly LocalPoint[],
|
||||||
offsetX: number,
|
offsetX: number,
|
||||||
offsetY: number,
|
offsetY: number,
|
||||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
|
||||||
otherUpdates?: {
|
otherUpdates?: {
|
||||||
startBinding?: PointBinding | null;
|
startBinding?: PointBinding | null;
|
||||||
endBinding?: PointBinding | null;
|
endBinding?: PointBinding | null;
|
||||||
},
|
},
|
||||||
options?: {
|
options?: {
|
||||||
changedElements?: Map<string, OrderedExcalidrawElement>;
|
|
||||||
isDragging?: boolean;
|
isDragging?: boolean;
|
||||||
zoom?: AppState["zoom"];
|
zoom?: AppState["zoom"];
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
if (isElbowArrow(element)) {
|
if (isElbowArrow(element)) {
|
||||||
const bindings: {
|
const updates: {
|
||||||
startBinding?: FixedPointBinding | null;
|
startBinding?: FixedPointBinding | null;
|
||||||
endBinding?: FixedPointBinding | null;
|
endBinding?: FixedPointBinding | null;
|
||||||
|
points?: LocalPoint[];
|
||||||
} = {};
|
} = {};
|
||||||
if (otherUpdates?.startBinding !== undefined) {
|
if (otherUpdates?.startBinding !== undefined) {
|
||||||
bindings.startBinding =
|
updates.startBinding =
|
||||||
otherUpdates.startBinding !== null &&
|
otherUpdates.startBinding !== null &&
|
||||||
isFixedPointBinding(otherUpdates.startBinding)
|
isFixedPointBinding(otherUpdates.startBinding)
|
||||||
? otherUpdates.startBinding
|
? otherUpdates.startBinding
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
if (otherUpdates?.endBinding !== undefined) {
|
if (otherUpdates?.endBinding !== undefined) {
|
||||||
bindings.endBinding =
|
updates.endBinding =
|
||||||
otherUpdates.endBinding !== null &&
|
otherUpdates.endBinding !== null &&
|
||||||
isFixedPointBinding(otherUpdates.endBinding)
|
isFixedPointBinding(otherUpdates.endBinding)
|
||||||
? otherUpdates.endBinding
|
? otherUpdates.endBinding
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergedElementsMap = options?.changedElements
|
updates.points = Array.from(nextPoints);
|
||||||
? toBrandedType<SceneElementsMap>(
|
updates.points[0] = pointTranslate(
|
||||||
new Map([...elementsMap, ...options.changedElements]),
|
updates.points[0],
|
||||||
)
|
|
||||||
: elementsMap;
|
|
||||||
|
|
||||||
mutateElbowArrow(
|
|
||||||
element,
|
|
||||||
mergedElementsMap,
|
|
||||||
nextPoints,
|
|
||||||
vector(offsetX, offsetY),
|
vector(offsetX, offsetY),
|
||||||
bindings,
|
|
||||||
{
|
|
||||||
isDragging: options?.isDragging,
|
|
||||||
zoom: options?.zoom,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
updates.points[updates.points.length - 1] = pointTranslate(
|
||||||
|
updates.points[updates.points.length - 1],
|
||||||
|
vector(offsetX, offsetY),
|
||||||
|
);
|
||||||
|
|
||||||
|
mutateElement(element, updates, true, {
|
||||||
|
isDragging: options?.isDragging,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
const nextCoords = getElementPointsCoords(element, nextPoints);
|
const nextCoords = getElementPointsCoords(element, nextPoints);
|
||||||
const prevCoords = getElementPointsCoords(element, element.points);
|
const prevCoords = getElementPointsCoords(element, element.points);
|
||||||
@@ -1773,6 +1732,99 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
return coords;
|
return coords;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static moveFixedSegment(
|
||||||
|
linearElement: LinearElementEditor,
|
||||||
|
index: number,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
): LinearElementEditor {
|
||||||
|
const element = LinearElementEditor.getElement(
|
||||||
|
linearElement.elementId,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!element || !isElbowArrow(element)) {
|
||||||
|
return linearElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index && index > 0 && index < element.points.length) {
|
||||||
|
const isHorizontal = headingIsHorizontal(
|
||||||
|
vectorToHeading(
|
||||||
|
vectorFromPoint(element.points[index], element.points[index - 1]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const fixedSegments = (element.fixedSegments ?? []).reduce(
|
||||||
|
(segments, s) => {
|
||||||
|
segments[s.index] = s;
|
||||||
|
return segments;
|
||||||
|
},
|
||||||
|
{} as Record<number, FixedSegment>,
|
||||||
|
);
|
||||||
|
fixedSegments[index] = {
|
||||||
|
index,
|
||||||
|
start: pointFrom<LocalPoint>(
|
||||||
|
!isHorizontal ? x - element.x : element.points[index - 1][0],
|
||||||
|
isHorizontal ? y - element.y : element.points[index - 1][1],
|
||||||
|
),
|
||||||
|
end: pointFrom<LocalPoint>(
|
||||||
|
!isHorizontal ? x - element.x : element.points[index][0],
|
||||||
|
isHorizontal ? y - element.y : element.points[index][1],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const nextFixedSegments = Object.values(fixedSegments).sort(
|
||||||
|
(a, b) => a.index - b.index,
|
||||||
|
);
|
||||||
|
|
||||||
|
const offset = nextFixedSegments
|
||||||
|
.map((segment) => segment.index)
|
||||||
|
.reduce((count, idx) => (idx < index ? count + 1 : count), 0);
|
||||||
|
|
||||||
|
mutateElement(element, {
|
||||||
|
fixedSegments: nextFixedSegments,
|
||||||
|
});
|
||||||
|
|
||||||
|
const point = pointFrom<GlobalPoint>(
|
||||||
|
element.x +
|
||||||
|
(element.fixedSegments![offset].start[0] +
|
||||||
|
element.fixedSegments![offset].end[0]) /
|
||||||
|
2,
|
||||||
|
element.y +
|
||||||
|
(element.fixedSegments![offset].start[1] +
|
||||||
|
element.fixedSegments![offset].end[1]) /
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...linearElement,
|
||||||
|
segmentMidPointHoveredCoords: point,
|
||||||
|
pointerDownState: {
|
||||||
|
...linearElement.pointerDownState,
|
||||||
|
segmentMidpoint: {
|
||||||
|
added: false,
|
||||||
|
index: element.fixedSegments![offset].index,
|
||||||
|
value: point,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return linearElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
static deleteFixedSegment(
|
||||||
|
element: ExcalidrawElbowArrowElement,
|
||||||
|
index: number,
|
||||||
|
): void {
|
||||||
|
mutateElement(element, {
|
||||||
|
fixedSegments: element.fixedSegments?.filter(
|
||||||
|
(segment) => segment.index !== index,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
mutateElement(element, {}, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeSelectedPoints = (
|
const normalizeSelectedPoints = (
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import type { ExcalidrawElement } from "./types";
|
import type { ExcalidrawElement, SceneElementsMap } from "./types";
|
||||||
import Scene from "../scene/Scene";
|
import Scene from "../scene/Scene";
|
||||||
import { getSizeFromPoints } from "../points";
|
import { getSizeFromPoints } from "../points";
|
||||||
import { randomInteger } from "../random";
|
import { randomInteger } from "../random";
|
||||||
import { getUpdatedTimestamp } from "../utils";
|
import { getUpdatedTimestamp, toBrandedType } from "../utils";
|
||||||
import type { Mutable } from "../utility-types";
|
import type { Mutable } from "../utility-types";
|
||||||
import { ShapeCache } from "../scene/ShapeCache";
|
import { ShapeCache } from "../scene/ShapeCache";
|
||||||
|
import { isElbowArrow } from "./typeChecks";
|
||||||
|
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||||
|
import type { Radians } from "../../math";
|
||||||
|
|
||||||
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
||||||
Partial<TElement>,
|
Partial<TElement>,
|
||||||
@@ -19,14 +22,49 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
|||||||
element: TElement,
|
element: TElement,
|
||||||
updates: ElementUpdate<TElement>,
|
updates: ElementUpdate<TElement>,
|
||||||
informMutation = true,
|
informMutation = true,
|
||||||
|
options?: {
|
||||||
|
// Currently only for elbow arrows.
|
||||||
|
// If true, the elbow arrow tries to bind to the nearest element. If false
|
||||||
|
// it tries to keep the same bound element, if any.
|
||||||
|
isDragging?: boolean;
|
||||||
|
},
|
||||||
): TElement => {
|
): TElement => {
|
||||||
let didChange = false;
|
let didChange = false;
|
||||||
|
|
||||||
// casting to any because can't use `in` operator
|
// casting to any because can't use `in` operator
|
||||||
// (see https://github.com/microsoft/TypeScript/issues/21732)
|
// (see https://github.com/microsoft/TypeScript/issues/21732)
|
||||||
const { points, fileId } = updates as any;
|
const { points, fixedSegments, fileId } = updates as any;
|
||||||
|
|
||||||
if (typeof points !== "undefined") {
|
if (
|
||||||
|
isElbowArrow(element) &&
|
||||||
|
(Object.keys(updates).length === 0 || // normalization case
|
||||||
|
typeof points !== "undefined" || // repositioning
|
||||||
|
typeof fixedSegments !== "undefined") // segment fixing
|
||||||
|
) {
|
||||||
|
const elementsMap = toBrandedType<SceneElementsMap>(
|
||||||
|
Scene.getScene(element)?.getNonDeletedElementsMap() ?? new Map(),
|
||||||
|
);
|
||||||
|
|
||||||
|
updates = {
|
||||||
|
...updates,
|
||||||
|
angle: 0 as Radians,
|
||||||
|
...updateElbowArrowPoints(
|
||||||
|
{
|
||||||
|
...element,
|
||||||
|
x: updates.x || element.x,
|
||||||
|
y: updates.y || element.y,
|
||||||
|
},
|
||||||
|
elementsMap,
|
||||||
|
{
|
||||||
|
fixedSegments,
|
||||||
|
points,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
isDragging: options?.isDragging,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} else if (typeof points !== "undefined") {
|
||||||
updates = { ...getSizeFromPoints(points), ...updates };
|
updates = { ...getSizeFromPoints(points), ...updates };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import type {
|
|||||||
ExcalidrawIframeElement,
|
ExcalidrawIframeElement,
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
|
FixedSegment,
|
||||||
|
ExcalidrawElbowArrowElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import {
|
import {
|
||||||
arrayToMap,
|
arrayToMap,
|
||||||
@@ -450,15 +452,34 @@ export const newLinearElement = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const newArrowElement = (
|
export const newArrowElement = <T extends boolean>(
|
||||||
opts: {
|
opts: {
|
||||||
type: ExcalidrawArrowElement["type"];
|
type: ExcalidrawArrowElement["type"];
|
||||||
startArrowhead?: Arrowhead | null;
|
startArrowhead?: Arrowhead | null;
|
||||||
endArrowhead?: Arrowhead | null;
|
endArrowhead?: Arrowhead | null;
|
||||||
points?: ExcalidrawArrowElement["points"];
|
points?: ExcalidrawArrowElement["points"];
|
||||||
elbowed?: boolean;
|
elbowed?: T;
|
||||||
|
fixedSegments?: FixedSegment[] | null;
|
||||||
} & ElementConstructorOpts,
|
} & ElementConstructorOpts,
|
||||||
): NonDeleted<ExcalidrawArrowElement> => {
|
): T extends true
|
||||||
|
? NonDeleted<ExcalidrawElbowArrowElement>
|
||||||
|
: NonDeleted<ExcalidrawArrowElement> => {
|
||||||
|
if (opts.elbowed) {
|
||||||
|
return {
|
||||||
|
..._newElementBase<ExcalidrawElbowArrowElement>(opts.type, opts),
|
||||||
|
points: opts.points || [],
|
||||||
|
lastCommittedPoint: null,
|
||||||
|
startBinding: null,
|
||||||
|
endBinding: null,
|
||||||
|
startArrowhead: opts.startArrowhead || null,
|
||||||
|
endArrowhead: opts.endArrowhead || null,
|
||||||
|
elbowed: true,
|
||||||
|
fixedSegments: opts.fixedSegments || [],
|
||||||
|
startIsSpecial: false,
|
||||||
|
endIsSpecial: false,
|
||||||
|
} as NonDeleted<ExcalidrawElbowArrowElement>;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
..._newElementBase<ExcalidrawArrowElement>(opts.type, opts),
|
..._newElementBase<ExcalidrawArrowElement>(opts.type, opts),
|
||||||
points: opts.points || [],
|
points: opts.points || [],
|
||||||
@@ -467,8 +488,10 @@ export const newArrowElement = (
|
|||||||
endBinding: null,
|
endBinding: null,
|
||||||
startArrowhead: opts.startArrowhead || null,
|
startArrowhead: opts.startArrowhead || null,
|
||||||
endArrowhead: opts.endArrowhead || null,
|
endArrowhead: opts.endArrowhead || null,
|
||||||
elbowed: opts.elbowed || false,
|
elbowed: false,
|
||||||
};
|
} as T extends true
|
||||||
|
? NonDeleted<ExcalidrawElbowArrowElement>
|
||||||
|
: NonDeleted<ExcalidrawArrowElement>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const newImageElement = (
|
export const newImageElement = (
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
ExcalidrawImageElement,
|
ExcalidrawImageElement,
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
SceneElementsMap,
|
SceneElementsMap,
|
||||||
|
ExcalidrawElbowArrowElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import type { Mutable } from "../utility-types";
|
import type { Mutable } from "../utility-types";
|
||||||
import {
|
import {
|
||||||
@@ -53,7 +54,6 @@ import {
|
|||||||
import { wrapText } from "./textWrapping";
|
import { wrapText } from "./textWrapping";
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import { isInGroup } from "../groups";
|
import { isInGroup } from "../groups";
|
||||||
import { mutateElbowArrow } from "./routing";
|
|
||||||
import type { GlobalPoint } from "../../math";
|
import type { GlobalPoint } from "../../math";
|
||||||
import {
|
import {
|
||||||
pointCenter,
|
pointCenter,
|
||||||
@@ -177,10 +177,10 @@ export const transformElements = (
|
|||||||
elementsMap,
|
elementsMap,
|
||||||
transformHandleType,
|
transformHandleType,
|
||||||
scene,
|
scene,
|
||||||
|
originalElements,
|
||||||
{
|
{
|
||||||
shouldResizeFromCenter,
|
shouldResizeFromCenter,
|
||||||
shouldMaintainAspectRatio,
|
shouldMaintainAspectRatio,
|
||||||
originalElementsMap: originalElements,
|
|
||||||
flipByX,
|
flipByX,
|
||||||
flipByY,
|
flipByY,
|
||||||
nextWidth,
|
nextWidth,
|
||||||
@@ -531,8 +531,10 @@ const rotateMultipleElements = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (isElbowArrow(element)) {
|
if (isElbowArrow(element)) {
|
||||||
const points = getArrowLocalFixedPoints(element, elementsMap);
|
// Needed to re-route the arrow
|
||||||
mutateElbowArrow(element, elementsMap, points);
|
mutateElement(element, {
|
||||||
|
points: getArrowLocalFixedPoints(element, elementsMap),
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
mutateElement(
|
mutateElement(
|
||||||
element,
|
element,
|
||||||
@@ -1201,6 +1203,7 @@ export const resizeMultipleElements = (
|
|||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
handleDirection: TransformHandleDirection,
|
handleDirection: TransformHandleDirection,
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
|
originalElementsMap: ElementsMap,
|
||||||
{
|
{
|
||||||
shouldMaintainAspectRatio = false,
|
shouldMaintainAspectRatio = false,
|
||||||
shouldResizeFromCenter = false,
|
shouldResizeFromCenter = false,
|
||||||
@@ -1208,7 +1211,6 @@ export const resizeMultipleElements = (
|
|||||||
flipByY = false,
|
flipByY = false,
|
||||||
nextHeight,
|
nextHeight,
|
||||||
nextWidth,
|
nextWidth,
|
||||||
originalElementsMap,
|
|
||||||
originalBoundingBox,
|
originalBoundingBox,
|
||||||
}: {
|
}: {
|
||||||
nextWidth?: number;
|
nextWidth?: number;
|
||||||
@@ -1217,7 +1219,6 @@ export const resizeMultipleElements = (
|
|||||||
shouldResizeFromCenter?: boolean;
|
shouldResizeFromCenter?: boolean;
|
||||||
flipByX?: boolean;
|
flipByX?: boolean;
|
||||||
flipByY?: boolean;
|
flipByY?: boolean;
|
||||||
originalElementsMap?: ElementsMap;
|
|
||||||
// added to improve performance
|
// added to improve performance
|
||||||
originalBoundingBox?: BoundingBox;
|
originalBoundingBox?: BoundingBox;
|
||||||
} = {},
|
} = {},
|
||||||
@@ -1387,6 +1388,9 @@ export const resizeMultipleElements = (
|
|||||||
fontSize?: ExcalidrawTextElement["fontSize"];
|
fontSize?: ExcalidrawTextElement["fontSize"];
|
||||||
scale?: ExcalidrawImageElement["scale"];
|
scale?: ExcalidrawImageElement["scale"];
|
||||||
boundTextFontSize?: ExcalidrawTextElement["fontSize"];
|
boundTextFontSize?: ExcalidrawTextElement["fontSize"];
|
||||||
|
startBinding?: ExcalidrawElbowArrowElement["startBinding"];
|
||||||
|
endBinding?: ExcalidrawElbowArrowElement["endBinding"];
|
||||||
|
fixedSegments?: ExcalidrawElbowArrowElement["fixedSegments"];
|
||||||
};
|
};
|
||||||
}[] = [];
|
}[] = [];
|
||||||
|
|
||||||
@@ -1427,6 +1431,44 @@ export const resizeMultipleElements = (
|
|||||||
...rescaledPoints,
|
...rescaledPoints,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isElbowArrow(orig)) {
|
||||||
|
// Mirror fixed point binding for elbow arrows
|
||||||
|
// when resize goes into the negative direction
|
||||||
|
if (orig.startBinding) {
|
||||||
|
update.startBinding = {
|
||||||
|
...orig.startBinding,
|
||||||
|
fixedPoint: [
|
||||||
|
flipByX
|
||||||
|
? -orig.startBinding.fixedPoint[0] + 1
|
||||||
|
: orig.startBinding.fixedPoint[0],
|
||||||
|
flipByY
|
||||||
|
? -orig.startBinding.fixedPoint[1] + 1
|
||||||
|
: orig.startBinding.fixedPoint[1],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (orig.endBinding) {
|
||||||
|
update.endBinding = {
|
||||||
|
...orig.endBinding,
|
||||||
|
fixedPoint: [
|
||||||
|
flipByX
|
||||||
|
? -orig.endBinding.fixedPoint[0] + 1
|
||||||
|
: orig.endBinding.fixedPoint[0],
|
||||||
|
flipByY
|
||||||
|
? -orig.endBinding.fixedPoint[1] + 1
|
||||||
|
: orig.endBinding.fixedPoint[1],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (orig.fixedSegments && rescaledPoints.points) {
|
||||||
|
update.fixedSegments = orig.fixedSegments.map((segment) => ({
|
||||||
|
...segment,
|
||||||
|
start: rescaledPoints.points[segment.index - 1],
|
||||||
|
end: rescaledPoints.points[segment.index],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isImageElement(orig)) {
|
if (isImageElement(orig)) {
|
||||||
update.scale = [
|
update.scale = [
|
||||||
orig.scale[0] * flipFactorX,
|
orig.scale[0] * flipFactorX,
|
||||||
@@ -1472,7 +1514,10 @@ export const resizeMultipleElements = (
|
|||||||
} of elementsAndUpdates) {
|
} of elementsAndUpdates) {
|
||||||
const { width, height, angle } = update;
|
const { width, height, angle } = update;
|
||||||
|
|
||||||
mutateElement(element, update, false);
|
mutateElement(element, update, false, {
|
||||||
|
// needed for the fixed binding point udpate to take effect
|
||||||
|
isDragging: true,
|
||||||
|
});
|
||||||
|
|
||||||
updateBoundElements(element, elementsMap as SceneElementsMap, {
|
updateBoundElements(element, elementsMap as SceneElementsMap, {
|
||||||
simultaneouslyUpdated: elementsToUpdate,
|
simultaneouslyUpdated: elementsToUpdate,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -303,7 +303,10 @@ export type Arrowhead =
|
|||||||
| "triangle"
|
| "triangle"
|
||||||
| "triangle_outline"
|
| "triangle_outline"
|
||||||
| "diamond"
|
| "diamond"
|
||||||
| "diamond_outline";
|
| "diamond_outline"
|
||||||
|
| "crowfoot_one"
|
||||||
|
| "crowfoot_many"
|
||||||
|
| "crowfoot_one_or_many";
|
||||||
|
|
||||||
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
||||||
Readonly<{
|
Readonly<{
|
||||||
@@ -316,6 +319,12 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
|||||||
endArrowhead: Arrowhead | null;
|
endArrowhead: Arrowhead | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
export type FixedSegment = {
|
||||||
|
start: LocalPoint;
|
||||||
|
end: LocalPoint;
|
||||||
|
index: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type ExcalidrawArrowElement = ExcalidrawLinearElement &
|
export type ExcalidrawArrowElement = ExcalidrawLinearElement &
|
||||||
Readonly<{
|
Readonly<{
|
||||||
type: "arrow";
|
type: "arrow";
|
||||||
@@ -328,6 +337,23 @@ export type ExcalidrawElbowArrowElement = Merge<
|
|||||||
elbowed: true;
|
elbowed: true;
|
||||||
startBinding: FixedPointBinding | null;
|
startBinding: FixedPointBinding | null;
|
||||||
endBinding: FixedPointBinding | null;
|
endBinding: FixedPointBinding | null;
|
||||||
|
fixedSegments: FixedSegment[] | null;
|
||||||
|
/**
|
||||||
|
* Marks that the 3rd point should be used as the 2nd point of the arrow in
|
||||||
|
* order to temporarily hide the first segment of the arrow without losing
|
||||||
|
* the data from the points array. It allows creating the expected arrow
|
||||||
|
* path when the arrow with fixed segments is bound on a horizontal side and
|
||||||
|
* moved to a vertical and vica versa.
|
||||||
|
*/
|
||||||
|
startIsSpecial: boolean | null;
|
||||||
|
/**
|
||||||
|
* Marks that the 3rd point backwards from the end should be used as the 2nd
|
||||||
|
* point of the arrow in order to temporarily hide the last segment of the
|
||||||
|
* arrow without losing the data from the points array. It allows creating
|
||||||
|
* the expected arrow path when the arrow with fixed segments is bound on a
|
||||||
|
* horizontal side and moved to a vertical and vica versa.
|
||||||
|
*/
|
||||||
|
endIsSpecial: boolean | null;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
|||||||
@@ -190,7 +190,6 @@ export const syncInvalidIndices = (
|
|||||||
): OrderedExcalidrawElement[] => {
|
): OrderedExcalidrawElement[] => {
|
||||||
const indicesGroups = getInvalidIndicesGroups(elements);
|
const indicesGroups = getInvalidIndicesGroups(elements);
|
||||||
const elementsUpdates = generateIndices(elements, indicesGroups);
|
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||||
|
|
||||||
for (const [element, update] of elementsUpdates) {
|
for (const [element, update] of elementsUpdates) {
|
||||||
mutateElement(element, update, false);
|
mutateElement(element, update, false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,10 @@ export const selectGroupsForSelectedElements = (function () {
|
|||||||
const groupElementsIndex: Record<GroupId, string[]> = {};
|
const groupElementsIndex: Record<GroupId, string[]> = {};
|
||||||
const selectedElementIdsInGroups = elements.reduce(
|
const selectedElementIdsInGroups = elements.reduce(
|
||||||
(acc: Record<string, true>, element) => {
|
(acc: Record<string, true>, element) => {
|
||||||
|
if (element.isDeleted) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
const groupId = element.groupIds.find((id) => selectedGroupIds[id]);
|
const groupId = element.groupIds.find((id) => selectedGroupIds[id]);
|
||||||
|
|
||||||
if (groupId) {
|
if (groupId) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { atom, useAtom } from "jotai";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { COLOR_PALETTE } from "../colors";
|
import { COLOR_PALETTE } from "../colors";
|
||||||
import { jotaiScope } from "../jotai";
|
import { atom, useAtom } from "../editor-jotai";
|
||||||
import { exportToSvg } from "../../utils/export";
|
import { exportToSvg } from "../../utils/export";
|
||||||
import type { LibraryItem } from "../types";
|
import type { LibraryItem } from "../types";
|
||||||
|
|
||||||
@@ -64,7 +63,7 @@ export const useLibraryItemSvg = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useLibraryCache = () => {
|
export const useLibraryCache = () => {
|
||||||
const [svgCache] = useAtom(libraryItemSvgsCache, jotaiScope);
|
const [svgCache] = useAtom(libraryItemSvgsCache);
|
||||||
|
|
||||||
const clearLibraryCache = () => svgCache.clear();
|
const clearLibraryCache = () => svgCache.clear();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "../editor-jotai";
|
||||||
import throttle from "lodash.throttle";
|
import throttle from "lodash.throttle";
|
||||||
|
|
||||||
const scrollPositionAtom = atom<number>(0);
|
const scrollPositionAtom = atom<number>(0);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import fallbackLangData from "./locales/en.json";
|
import fallbackLangData from "./locales/en.json";
|
||||||
import percentages from "./locales/percentages.json";
|
import percentages from "./locales/percentages.json";
|
||||||
import { jotaiScope, jotaiStore } from "./jotai";
|
import { useAtomValue, editorJotaiStore, atom } from "./editor-jotai";
|
||||||
import { atom, useAtomValue } from "jotai";
|
|
||||||
import type { NestedKeyOf } from "./utility-types";
|
import type { NestedKeyOf } from "./utility-types";
|
||||||
|
|
||||||
const COMPLETION_THRESHOLD = 85;
|
const COMPLETION_THRESHOLD = 85;
|
||||||
@@ -103,7 +102,7 @@ export const setLanguage = async (lang: Language) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
jotaiStore.set(editorLangCodeAtom, lang.code);
|
editorJotaiStore.set(editorLangCodeAtom, lang.code);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getLanguage = () => currentLang;
|
export const getLanguage = () => currentLang;
|
||||||
@@ -165,6 +164,6 @@ const editorLangCodeAtom = atom(defaultLang.code);
|
|||||||
// - component is rendered internally by <Excalidraw>, but the component
|
// - component is rendered internally by <Excalidraw>, but the component
|
||||||
// is memoized w/o being updated on `langCode`, `AppState`, or `UIAppState`
|
// is memoized w/o being updated on `langCode`, `AppState`, or `UIAppState`
|
||||||
export const useI18n = () => {
|
export const useI18n = () => {
|
||||||
const langCode = useAtomValue(editorLangCodeAtom, jotaiScope);
|
const langCode = useAtomValue(editorLangCodeAtom);
|
||||||
return { t, langCode };
|
return { t, langCode };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ import "./fonts/fonts.css";
|
|||||||
import type { AppProps, ExcalidrawProps } from "./types";
|
import type { AppProps, ExcalidrawProps } from "./types";
|
||||||
import { defaultLang } from "./i18n";
|
import { defaultLang } from "./i18n";
|
||||||
import { DEFAULT_UI_OPTIONS } from "./constants";
|
import { DEFAULT_UI_OPTIONS } from "./constants";
|
||||||
import { Provider } from "jotai";
|
import { EditorJotaiProvider, editorJotaiStore } from "./editor-jotai";
|
||||||
import { jotaiScope, jotaiStore } from "./jotai";
|
|
||||||
import Footer from "./components/footer/FooterCenter";
|
import Footer from "./components/footer/FooterCenter";
|
||||||
import MainMenu from "./components/main-menu/MainMenu";
|
import MainMenu from "./components/main-menu/MainMenu";
|
||||||
import WelcomeScreen from "./components/welcome-screen/WelcomeScreen";
|
import WelcomeScreen from "./components/welcome-screen/WelcomeScreen";
|
||||||
@@ -108,7 +107,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Provider unstable_createStore={() => jotaiStore} scope={jotaiScope}>
|
<EditorJotaiProvider store={editorJotaiStore}>
|
||||||
<InitializeApp langCode={langCode} theme={theme}>
|
<InitializeApp langCode={langCode} theme={theme}>
|
||||||
<App
|
<App
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
@@ -145,7 +144,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
|||||||
{children}
|
{children}
|
||||||
</App>
|
</App>
|
||||||
</InitializeApp>
|
</InitializeApp>
|
||||||
</Provider>
|
</EditorJotaiProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -46,6 +46,10 @@
|
|||||||
"arrowhead_triangle_outline": "Triangle (outline)",
|
"arrowhead_triangle_outline": "Triangle (outline)",
|
||||||
"arrowhead_diamond": "Diamond",
|
"arrowhead_diamond": "Diamond",
|
||||||
"arrowhead_diamond_outline": "Diamond (outline)",
|
"arrowhead_diamond_outline": "Diamond (outline)",
|
||||||
|
"arrowhead_crowfoot_many": "Crow's foot (many)",
|
||||||
|
"arrowhead_crowfoot_one": "Crow's foot (one)",
|
||||||
|
"arrowhead_crowfoot_one_or_many": "Crow's foot (one or many)",
|
||||||
|
"more_options": "More options",
|
||||||
"arrowtypes": "Arrow type",
|
"arrowtypes": "Arrow type",
|
||||||
"arrowtype_sharp": "Sharp arrow",
|
"arrowtype_sharp": "Sharp arrow",
|
||||||
"arrowtype_round": "Curved arrow",
|
"arrowtype_round": "Curved arrow",
|
||||||
@@ -160,7 +164,8 @@
|
|||||||
"imageCropping": "Image cropping",
|
"imageCropping": "Image cropping",
|
||||||
"unCroppedDimension": "Uncropped dimension",
|
"unCroppedDimension": "Uncropped dimension",
|
||||||
"copyElementLink": "Copy link to object",
|
"copyElementLink": "Copy link to object",
|
||||||
"linkToElement": "Link to object"
|
"linkToElement": "Link to object",
|
||||||
|
"wrapSelectionInFrame": "Wrap selection in frame"
|
||||||
},
|
},
|
||||||
"elementLink": {
|
"elementLink": {
|
||||||
"title": "Link to object",
|
"title": "Link to object",
|
||||||
|
|||||||
@@ -70,7 +70,8 @@
|
|||||||
"fractional-indexing": "3.2.0",
|
"fractional-indexing": "3.2.0",
|
||||||
"fuzzy": "0.1.3",
|
"fuzzy": "0.1.3",
|
||||||
"image-blob-reduce": "3.0.1",
|
"image-blob-reduce": "3.0.1",
|
||||||
"jotai": "1.13.1",
|
"jotai": "2.11.0",
|
||||||
|
"jotai-scope": "0.7.2",
|
||||||
"lodash.throttle": "4.1.1",
|
"lodash.throttle": "4.1.1",
|
||||||
"nanoid": "3.3.3",
|
"nanoid": "3.3.3",
|
||||||
"open-color": "1.9.1",
|
"open-color": "1.9.1",
|
||||||
@@ -116,6 +117,7 @@
|
|||||||
"fonteditor-core": "2.4.1",
|
"fonteditor-core": "2.4.1",
|
||||||
"harfbuzzjs": "0.3.6",
|
"harfbuzzjs": "0.3.6",
|
||||||
"import-meta-loader": "1.1.0",
|
"import-meta-loader": "1.1.0",
|
||||||
|
"jest-diff": "29.7.0",
|
||||||
"mini-css-extract-plugin": "2.6.1",
|
"mini-css-extract-plugin": "2.6.1",
|
||||||
"postcss-loader": "7.0.1",
|
"postcss-loader": "7.0.1",
|
||||||
"sass-loader": "13.0.2",
|
"sass-loader": "13.0.2",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
getOmitSidesForDevice,
|
getOmitSidesForDevice,
|
||||||
shouldShowBoundingBox,
|
shouldShowBoundingBox,
|
||||||
} from "../element/transformHandles";
|
} from "../element/transformHandles";
|
||||||
import { arrayToMap, throttleRAF } from "../utils";
|
import { arrayToMap, invariant, throttleRAF } from "../utils";
|
||||||
import {
|
import {
|
||||||
DEFAULT_TRANSFORM_HANDLE_SPACING,
|
DEFAULT_TRANSFORM_HANDLE_SPACING,
|
||||||
FRAME_STYLE,
|
FRAME_STYLE,
|
||||||
@@ -78,9 +78,32 @@ import type {
|
|||||||
InteractiveSceneRenderConfig,
|
InteractiveSceneRenderConfig,
|
||||||
RenderableElementsMap,
|
RenderableElementsMap,
|
||||||
} from "../scene/types";
|
} from "../scene/types";
|
||||||
import type { GlobalPoint, LocalPoint, Radians } from "../../math";
|
import {
|
||||||
|
pointFrom,
|
||||||
|
type GlobalPoint,
|
||||||
|
type LocalPoint,
|
||||||
|
type Radians,
|
||||||
|
} from "../../math";
|
||||||
import { getCornerRadius } from "../shapes";
|
import { getCornerRadius } from "../shapes";
|
||||||
|
|
||||||
|
const renderElbowArrowMidPointHighlight = (
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
appState: InteractiveCanvasAppState,
|
||||||
|
) => {
|
||||||
|
invariant(appState.selectedLinearElement, "selectedLinearElement is null");
|
||||||
|
|
||||||
|
const { segmentMidPointHoveredCoords } = appState.selectedLinearElement;
|
||||||
|
|
||||||
|
invariant(segmentMidPointHoveredCoords, "midPointCoords is null");
|
||||||
|
|
||||||
|
context.save();
|
||||||
|
context.translate(appState.scrollX, appState.scrollY);
|
||||||
|
|
||||||
|
highlightPoint(segmentMidPointHoveredCoords, context, appState);
|
||||||
|
|
||||||
|
context.restore();
|
||||||
|
};
|
||||||
|
|
||||||
const renderLinearElementPointHighlight = (
|
const renderLinearElementPointHighlight = (
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
appState: InteractiveCanvasAppState,
|
appState: InteractiveCanvasAppState,
|
||||||
@@ -490,7 +513,7 @@ const renderLinearPointHandles = (
|
|||||||
context.save();
|
context.save();
|
||||||
context.translate(appState.scrollX, appState.scrollY);
|
context.translate(appState.scrollX, appState.scrollY);
|
||||||
context.lineWidth = 1 / appState.zoom.value;
|
context.lineWidth = 1 / appState.zoom.value;
|
||||||
const points = LinearElementEditor.getPointsGlobalCoordinates(
|
const points: GlobalPoint[] = LinearElementEditor.getPointsGlobalCoordinates(
|
||||||
element,
|
element,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
@@ -510,55 +533,57 @@ const renderLinearPointHandles = (
|
|||||||
renderSingleLinearPoint(context, appState, point, radius, isSelected);
|
renderSingleLinearPoint(context, appState, point, radius, isSelected);
|
||||||
});
|
});
|
||||||
|
|
||||||
//Rendering segment mid points
|
// Rendering segment mid points
|
||||||
const midPoints = LinearElementEditor.getEditorMidPoints(
|
if (isElbowArrow(element)) {
|
||||||
element,
|
const fixedSegments =
|
||||||
elementsMap,
|
element.fixedSegments?.map((segment) => segment.index) || [];
|
||||||
appState,
|
points.slice(0, -1).forEach((p, idx) => {
|
||||||
).filter((midPoint): midPoint is GlobalPoint => midPoint !== null);
|
if (
|
||||||
|
!LinearElementEditor.isSegmentTooShort(
|
||||||
midPoints.forEach((segmentMidPoint) => {
|
element,
|
||||||
if (
|
points[idx + 1],
|
||||||
appState?.selectedLinearElement?.segmentMidPointHoveredCoords &&
|
points[idx],
|
||||||
LinearElementEditor.arePointsEqual(
|
idx,
|
||||||
segmentMidPoint,
|
appState.zoom,
|
||||||
appState.selectedLinearElement.segmentMidPointHoveredCoords,
|
)
|
||||||
)
|
) {
|
||||||
) {
|
|
||||||
// The order of renderingSingleLinearPoint and highLight points is different
|
|
||||||
// inside vs outside editor as hover states are different,
|
|
||||||
// in editor when hovered the original point is not visible as hover state fully covers it whereas outside the
|
|
||||||
// editor original point is visible and hover state is just an outer circle.
|
|
||||||
if (appState.editingLinearElement) {
|
|
||||||
renderSingleLinearPoint(
|
renderSingleLinearPoint(
|
||||||
context,
|
context,
|
||||||
appState,
|
appState,
|
||||||
segmentMidPoint,
|
pointFrom<GlobalPoint>(
|
||||||
radius,
|
(p[0] + points[idx + 1][0]) / 2,
|
||||||
false,
|
(p[1] + points[idx + 1][1]) / 2,
|
||||||
);
|
),
|
||||||
highlightPoint(segmentMidPoint, context, appState);
|
POINT_HANDLE_SIZE / 2,
|
||||||
} else {
|
|
||||||
highlightPoint(segmentMidPoint, context, appState);
|
|
||||||
renderSingleLinearPoint(
|
|
||||||
context,
|
|
||||||
appState,
|
|
||||||
segmentMidPoint,
|
|
||||||
radius,
|
|
||||||
false,
|
false,
|
||||||
|
!fixedSegments.includes(idx + 1),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (appState.editingLinearElement || points.length === 2) {
|
});
|
||||||
renderSingleLinearPoint(
|
} else {
|
||||||
context,
|
const midPoints = LinearElementEditor.getEditorMidPoints(
|
||||||
appState,
|
element,
|
||||||
segmentMidPoint,
|
elementsMap,
|
||||||
POINT_HANDLE_SIZE / 2,
|
appState,
|
||||||
false,
|
).filter(
|
||||||
true,
|
(midPoint, idx, midPoints): midPoint is GlobalPoint =>
|
||||||
);
|
midPoint !== null &&
|
||||||
}
|
!(isElbowArrow(element) && (idx === 0 || idx === midPoints.length - 1)),
|
||||||
});
|
);
|
||||||
|
|
||||||
|
midPoints.forEach((segmentMidPoint) => {
|
||||||
|
if (appState.editingLinearElement || points.length === 2) {
|
||||||
|
renderSingleLinearPoint(
|
||||||
|
context,
|
||||||
|
appState,
|
||||||
|
segmentMidPoint,
|
||||||
|
POINT_HANDLE_SIZE / 2,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
context.restore();
|
context.restore();
|
||||||
};
|
};
|
||||||
@@ -864,6 +889,12 @@ const _renderInteractiveScene = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
isElbowArrow(selectedElements[0]) &&
|
||||||
|
appState.selectedLinearElement &&
|
||||||
|
appState.selectedLinearElement.segmentMidPointHoveredCoords
|
||||||
|
) {
|
||||||
|
renderElbowArrowMidPointHighlight(context, appState);
|
||||||
|
} else if (
|
||||||
appState.selectedLinearElement &&
|
appState.selectedLinearElement &&
|
||||||
appState.selectedLinearElement.hoverPointIndex >= 0 &&
|
appState.selectedLinearElement.hoverPointIndex >= 0 &&
|
||||||
!(
|
!(
|
||||||
@@ -875,6 +906,7 @@ const _renderInteractiveScene = ({
|
|||||||
) {
|
) {
|
||||||
renderLinearElementPointHighlight(context, appState, elementsMap);
|
renderLinearElementPointHighlight(context, appState, elementsMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paint selected elements
|
// Paint selected elements
|
||||||
if (!appState.multiElement && !appState.editingLinearElement) {
|
if (!appState.multiElement && !appState.editingLinearElement) {
|
||||||
const showBoundingBox = shouldShowBoundingBox(selectedElements, appState);
|
const showBoundingBox = shouldShowBoundingBox(selectedElements, appState);
|
||||||
|
|||||||
@@ -449,7 +449,7 @@ const renderElementToSvg = (
|
|||||||
|
|
||||||
symbol.appendChild(image);
|
symbol.appendChild(image);
|
||||||
|
|
||||||
root.prepend(symbol);
|
(root.querySelector("defs") || root).prepend(symbol);
|
||||||
}
|
}
|
||||||
|
|
||||||
const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use");
|
const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use");
|
||||||
|
|||||||
@@ -23,13 +23,9 @@ import {
|
|||||||
} from "../element/typeChecks";
|
} from "../element/typeChecks";
|
||||||
import { canChangeRoundness } from "./comparisons";
|
import { canChangeRoundness } from "./comparisons";
|
||||||
import type { EmbedsValidationStatus } from "../types";
|
import type { EmbedsValidationStatus } from "../types";
|
||||||
import {
|
import { pointFrom, pointDistance, type LocalPoint } from "../../math";
|
||||||
pointFrom,
|
|
||||||
pointDistance,
|
|
||||||
type GlobalPoint,
|
|
||||||
type LocalPoint,
|
|
||||||
} from "../../math";
|
|
||||||
import { getCornerRadius, isPathALoop } from "../shapes";
|
import { getCornerRadius, isPathALoop } from "../shapes";
|
||||||
|
import { headingForPointIsHorizontal } from "../element/heading";
|
||||||
|
|
||||||
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
|
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
|
||||||
|
|
||||||
@@ -177,6 +173,19 @@ const getArrowheadShapes = (
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const generateCrowfootOne = (
|
||||||
|
arrowheadPoints: number[] | null,
|
||||||
|
options: Options,
|
||||||
|
) => {
|
||||||
|
if (arrowheadPoints === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, , x3, y3, x4, y4] = arrowheadPoints;
|
||||||
|
|
||||||
|
return [generator.line(x3, y3, x4, y4, options)];
|
||||||
|
};
|
||||||
|
|
||||||
switch (arrowhead) {
|
switch (arrowhead) {
|
||||||
case "dot":
|
case "dot":
|
||||||
case "circle":
|
case "circle":
|
||||||
@@ -255,8 +264,12 @@ const getArrowheadShapes = (
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
case "crowfoot_one":
|
||||||
|
return generateCrowfootOne(arrowheadPoints, options);
|
||||||
case "bar":
|
case "bar":
|
||||||
case "arrow":
|
case "arrow":
|
||||||
|
case "crowfoot_many":
|
||||||
|
case "crowfoot_one_or_many":
|
||||||
default: {
|
default: {
|
||||||
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
|
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
|
||||||
|
|
||||||
@@ -272,6 +285,12 @@ const getArrowheadShapes = (
|
|||||||
return [
|
return [
|
||||||
generator.line(x3, y3, x2, y2, options),
|
generator.line(x3, y3, x2, y2, options),
|
||||||
generator.line(x4, y4, x2, y2, options),
|
generator.line(x4, y4, x2, y2, options),
|
||||||
|
...(arrowhead === "crowfoot_one_or_many"
|
||||||
|
? generateCrowfootOne(
|
||||||
|
getArrowheadPoints(element, shape, position, "crowfoot_one"),
|
||||||
|
options,
|
||||||
|
)
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -504,45 +523,53 @@ export const _generateElementShape = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateElbowArrowShape = <Point extends GlobalPoint | LocalPoint>(
|
const generateElbowArrowShape = (
|
||||||
points: readonly Point[],
|
points: readonly LocalPoint[],
|
||||||
radius: number,
|
radius: number,
|
||||||
) => {
|
) => {
|
||||||
const subpoints = [] as [number, number][];
|
const subpoints = [] as [number, number][];
|
||||||
for (let i = 1; i < points.length - 1; i += 1) {
|
for (let i = 1; i < points.length - 1; i += 1) {
|
||||||
const prev = points[i - 1];
|
const prev = points[i - 1];
|
||||||
const next = points[i + 1];
|
const next = points[i + 1];
|
||||||
|
const point = points[i];
|
||||||
|
const prevIsHorizontal = headingForPointIsHorizontal(point, prev);
|
||||||
|
const nextIsHorizontal = headingForPointIsHorizontal(next, point);
|
||||||
const corner = Math.min(
|
const corner = Math.min(
|
||||||
radius,
|
radius,
|
||||||
pointDistance(points[i], next) / 2,
|
pointDistance(points[i], next) / 2,
|
||||||
pointDistance(points[i], prev) / 2,
|
pointDistance(points[i], prev) / 2,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (prev[0] < points[i][0] && prev[1] === points[i][1]) {
|
if (prevIsHorizontal) {
|
||||||
// LEFT
|
if (prev[0] < point[0]) {
|
||||||
subpoints.push([points[i][0] - corner, points[i][1]]);
|
// LEFT
|
||||||
} else if (prev[0] === points[i][0] && prev[1] < points[i][1]) {
|
subpoints.push([points[i][0] - corner, points[i][1]]);
|
||||||
|
} else {
|
||||||
|
// RIGHT
|
||||||
|
subpoints.push([points[i][0] + corner, points[i][1]]);
|
||||||
|
}
|
||||||
|
} else if (prev[1] < point[1]) {
|
||||||
// UP
|
// UP
|
||||||
subpoints.push([points[i][0], points[i][1] - corner]);
|
subpoints.push([points[i][0], points[i][1] - corner]);
|
||||||
} else if (prev[0] > points[i][0] && prev[1] === points[i][1]) {
|
|
||||||
// RIGHT
|
|
||||||
subpoints.push([points[i][0] + corner, points[i][1]]);
|
|
||||||
} else {
|
} else {
|
||||||
subpoints.push([points[i][0], points[i][1] + corner]);
|
subpoints.push([points[i][0], points[i][1] + corner]);
|
||||||
}
|
}
|
||||||
|
|
||||||
subpoints.push(points[i] as [number, number]);
|
subpoints.push(points[i] as [number, number]);
|
||||||
|
|
||||||
if (next[0] < points[i][0] && next[1] === points[i][1]) {
|
if (nextIsHorizontal) {
|
||||||
// LEFT
|
if (next[0] < point[0]) {
|
||||||
subpoints.push([points[i][0] - corner, points[i][1]]);
|
// LEFT
|
||||||
} else if (next[0] === points[i][0] && next[1] < points[i][1]) {
|
subpoints.push([points[i][0] - corner, points[i][1]]);
|
||||||
|
} else {
|
||||||
|
// RIGHT
|
||||||
|
subpoints.push([points[i][0] + corner, points[i][1]]);
|
||||||
|
}
|
||||||
|
} else if (next[1] < point[1]) {
|
||||||
// UP
|
// UP
|
||||||
subpoints.push([points[i][0], points[i][1] - corner]);
|
subpoints.push([points[i][0], points[i][1] - corner]);
|
||||||
} else if (next[0] > points[i][0] && next[1] === points[i][1]) {
|
|
||||||
// RIGHT
|
|
||||||
subpoints.push([points[i][0] + corner, points[i][1]]);
|
|
||||||
} else {
|
} else {
|
||||||
|
// DOWN
|
||||||
subpoints.push([points[i][0], points[i][1] + corner]);
|
subpoints.push([points[i][0], points[i][1] + corner]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
SVG_NS,
|
SVG_NS,
|
||||||
THEME,
|
THEME,
|
||||||
THEME_FILTER,
|
THEME_FILTER,
|
||||||
|
MIME_TYPES,
|
||||||
|
EXPORT_DATA_TYPES,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import { serializeAsJSON } from "../data/json";
|
import { serializeAsJSON } from "../data/json";
|
||||||
@@ -39,8 +41,7 @@ import type { RenderableElementsMap } from "./types";
|
|||||||
import { syncInvalidIndices } from "../fractionalIndex";
|
import { syncInvalidIndices } from "../fractionalIndex";
|
||||||
import { renderStaticScene } from "../renderer/staticScene";
|
import { renderStaticScene } from "../renderer/staticScene";
|
||||||
import { Fonts } from "../fonts";
|
import { Fonts } from "../fonts";
|
||||||
|
import { base64ToString, decode, encode, stringToBase64 } from "../data/encode";
|
||||||
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
|
|
||||||
|
|
||||||
const truncateText = (element: ExcalidrawTextElement, maxWidth: number) => {
|
const truncateText = (element: ExcalidrawTextElement, maxWidth: number) => {
|
||||||
if (element.width <= maxWidth) {
|
if (element.width <= maxWidth) {
|
||||||
@@ -254,6 +255,13 @@ export const exportToCanvas = async (
|
|||||||
return canvas;
|
return canvas;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createHTMLComment = (text: string) => {
|
||||||
|
// surrounding with spaces to maintain prettified consistency with previous
|
||||||
|
// iterations
|
||||||
|
// <!-- comment -->
|
||||||
|
return document.createComment(` ${text} `);
|
||||||
|
};
|
||||||
|
|
||||||
export const exportToSvg = async (
|
export const exportToSvg = async (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: {
|
appState: {
|
||||||
@@ -302,31 +310,20 @@ export const exportToSvg = async (
|
|||||||
exportPadding = 0;
|
exportPadding = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
let metadata = "";
|
|
||||||
|
|
||||||
// we need to serialize the "original" elements before we put them through
|
|
||||||
// the tempScene hack which duplicates and regenerates ids
|
|
||||||
if (exportEmbedScene) {
|
|
||||||
try {
|
|
||||||
metadata = (await import("../data/image")).encodeSvgMetadata({
|
|
||||||
// when embedding scene, we want to embed the origionally supplied
|
|
||||||
// elements which don't contain the temp frame labels.
|
|
||||||
// But it also requires that the exportToSvg is being supplied with
|
|
||||||
// only the elements that we're exporting, and no extra.
|
|
||||||
text: serializeAsJSON(elements, appState, files || {}, "local"),
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [minX, minY, width, height] = getCanvasSize(
|
const [minX, minY, width, height] = getCanvasSize(
|
||||||
exportingFrame ? [exportingFrame] : getRootElements(elementsForRender),
|
exportingFrame ? [exportingFrame] : getRootElements(elementsForRender),
|
||||||
exportPadding,
|
exportPadding,
|
||||||
);
|
);
|
||||||
|
|
||||||
// initialize SVG root
|
const offsetX = -minX + exportPadding;
|
||||||
|
const offsetY = -minY + exportPadding;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// initialize SVG root element
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const svgRoot = document.createElementNS(SVG_NS, "svg");
|
const svgRoot = document.createElementNS(SVG_NS, "svg");
|
||||||
|
|
||||||
svgRoot.setAttribute("version", "1.1");
|
svgRoot.setAttribute("version", "1.1");
|
||||||
svgRoot.setAttribute("xmlns", SVG_NS);
|
svgRoot.setAttribute("xmlns", SVG_NS);
|
||||||
svgRoot.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
svgRoot.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||||||
@@ -336,53 +333,105 @@ export const exportToSvg = async (
|
|||||||
svgRoot.setAttribute("filter", THEME_FILTER);
|
svgRoot.setAttribute("filter", THEME_FILTER);
|
||||||
}
|
}
|
||||||
|
|
||||||
const offsetX = -minX + exportPadding;
|
const defsElement = svgRoot.ownerDocument.createElementNS(SVG_NS, "defs");
|
||||||
const offsetY = -minY + exportPadding;
|
|
||||||
|
const metadataElement = svgRoot.ownerDocument.createElementNS(
|
||||||
|
SVG_NS,
|
||||||
|
"metadata",
|
||||||
|
);
|
||||||
|
|
||||||
|
svgRoot.appendChild(createHTMLComment("svg-source:excalidraw"));
|
||||||
|
svgRoot.appendChild(metadataElement);
|
||||||
|
svgRoot.appendChild(defsElement);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// scene embed
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// we need to serialize the "original" elements before we put them through
|
||||||
|
// the tempScene hack which duplicates and regenerates ids
|
||||||
|
if (exportEmbedScene) {
|
||||||
|
try {
|
||||||
|
encodeSvgBase64Payload({
|
||||||
|
metadataElement,
|
||||||
|
// when embedding scene, we want to embed the origionally supplied
|
||||||
|
// elements which don't contain the temp frame labels.
|
||||||
|
// But it also requires that the exportToSvg is being supplied with
|
||||||
|
// only the elements that we're exporting, and no extra.
|
||||||
|
payload: serializeAsJSON(elements, appState, files || {}, "local"),
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// frame clip paths
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const frameElements = getFrameLikeElements(elements);
|
const frameElements = getFrameLikeElements(elements);
|
||||||
|
|
||||||
let exportingFrameClipPath = "";
|
if (frameElements.length) {
|
||||||
const elementsMap = arrayToMap(elements);
|
const elementsMap = arrayToMap(elements);
|
||||||
for (const frame of frameElements) {
|
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap);
|
|
||||||
const cx = (x2 - x1) / 2 - (frame.x - x1);
|
|
||||||
const cy = (y2 - y1) / 2 - (frame.y - y1);
|
|
||||||
|
|
||||||
exportingFrameClipPath += `<clipPath id=${frame.id}>
|
for (const frame of frameElements) {
|
||||||
<rect transform="translate(${frame.x + offsetX} ${
|
const clipPath = svgRoot.ownerDocument.createElementNS(
|
||||||
frame.y + offsetY
|
SVG_NS,
|
||||||
}) rotate(${frame.angle} ${cx} ${cy})"
|
"clipPath",
|
||||||
width="${frame.width}"
|
);
|
||||||
height="${frame.height}"
|
|
||||||
${
|
clipPath.setAttribute("id", frame.id);
|
||||||
exportingFrame
|
|
||||||
? ""
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap);
|
||||||
: `rx=${FRAME_STYLE.radius} ry=${FRAME_STYLE.radius}`
|
const cx = (x2 - x1) / 2 - (frame.x - x1);
|
||||||
}
|
const cy = (y2 - y1) / 2 - (frame.y - y1);
|
||||||
>
|
|
||||||
</rect>
|
const rect = svgRoot.ownerDocument.createElementNS(SVG_NS, "rect");
|
||||||
</clipPath>`;
|
rect.setAttribute(
|
||||||
|
"transform",
|
||||||
|
`translate(${frame.x + offsetX} ${frame.y + offsetY}) rotate(${
|
||||||
|
frame.angle
|
||||||
|
} ${cx} ${cy})`,
|
||||||
|
);
|
||||||
|
rect.setAttribute("width", `${frame.width}`);
|
||||||
|
rect.setAttribute("height", `${frame.height}`);
|
||||||
|
|
||||||
|
if (!exportingFrame) {
|
||||||
|
rect.setAttribute("rx", `${FRAME_STYLE.radius}`);
|
||||||
|
rect.setAttribute("ry", `${FRAME_STYLE.radius}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
clipPath.appendChild(rect);
|
||||||
|
|
||||||
|
defsElement.appendChild(clipPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// inline font faces
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const fontFaces = !opts?.skipInliningFonts
|
const fontFaces = !opts?.skipInliningFonts
|
||||||
? await Fonts.generateFontFaceDeclarations(elements)
|
? await Fonts.generateFontFaceDeclarations(elements)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const delimiter = "\n "; // 6 spaces
|
const delimiter = "\n "; // 6 spaces
|
||||||
|
|
||||||
svgRoot.innerHTML = `
|
const style = svgRoot.ownerDocument.createElementNS(SVG_NS, "style");
|
||||||
${SVG_EXPORT_TAG}
|
style.classList.add("style-fonts");
|
||||||
${metadata}
|
style.appendChild(
|
||||||
<defs>
|
document.createTextNode(`${delimiter}${fontFaces.join(delimiter)}`),
|
||||||
<style class="style-fonts">${delimiter}${fontFaces.join(delimiter)}
|
);
|
||||||
</style>
|
|
||||||
${exportingFrameClipPath}
|
defsElement.appendChild(style);
|
||||||
</defs>
|
|
||||||
`;
|
// ---------------------------------------------------------------------------
|
||||||
|
// background
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// render background rect
|
// render background rect
|
||||||
if (appState.exportBackground && viewBackgroundColor) {
|
if (appState.exportBackground && viewBackgroundColor) {
|
||||||
const rect = svgRoot.ownerDocument!.createElementNS(SVG_NS, "rect");
|
const rect = svgRoot.ownerDocument.createElementNS(SVG_NS, "rect");
|
||||||
rect.setAttribute("x", "0");
|
rect.setAttribute("x", "0");
|
||||||
rect.setAttribute("y", "0");
|
rect.setAttribute("y", "0");
|
||||||
rect.setAttribute("width", `${width}`);
|
rect.setAttribute("width", `${width}`);
|
||||||
@@ -391,6 +440,10 @@ export const exportToSvg = async (
|
|||||||
svgRoot.appendChild(rect);
|
svgRoot.appendChild(rect);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// render elements
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const rsvg = rough.svg(svgRoot);
|
const rsvg = rough.svg(svgRoot);
|
||||||
|
|
||||||
const renderEmbeddables = opts?.renderEmbeddables ?? false;
|
const renderEmbeddables = opts?.renderEmbeddables ?? false;
|
||||||
@@ -420,9 +473,66 @@ export const exportToSvg = async (
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
return svgRoot;
|
return svgRoot;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const encodeSvgBase64Payload = ({
|
||||||
|
payload,
|
||||||
|
metadataElement,
|
||||||
|
}: {
|
||||||
|
payload: string;
|
||||||
|
metadataElement: SVGMetadataElement;
|
||||||
|
}) => {
|
||||||
|
const base64 = stringToBase64(
|
||||||
|
JSON.stringify(encode({ text: payload })),
|
||||||
|
true /* is already byte string */,
|
||||||
|
);
|
||||||
|
|
||||||
|
metadataElement.appendChild(
|
||||||
|
createHTMLComment(`payload-type:${MIME_TYPES.excalidraw}`),
|
||||||
|
);
|
||||||
|
metadataElement.appendChild(createHTMLComment("payload-version:2"));
|
||||||
|
metadataElement.appendChild(createHTMLComment("payload-start"));
|
||||||
|
metadataElement.appendChild(document.createTextNode(base64));
|
||||||
|
metadataElement.appendChild(createHTMLComment("payload-end"));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decodeSvgBase64Payload = ({ svg }: { svg: string }) => {
|
||||||
|
if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) {
|
||||||
|
const match = svg.match(
|
||||||
|
/<!-- payload-start -->\s*(.+?)\s*<!-- payload-end -->/,
|
||||||
|
);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error("INVALID");
|
||||||
|
}
|
||||||
|
const versionMatch = svg.match(/<!-- payload-version:(\d+) -->/);
|
||||||
|
const version = versionMatch?.[1] || "1";
|
||||||
|
const isByteString = version !== "1";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const json = base64ToString(match[1], isByteString);
|
||||||
|
const encodedData = JSON.parse(json);
|
||||||
|
if (!("encoded" in encodedData)) {
|
||||||
|
// legacy, un-encoded scene JSON
|
||||||
|
if (
|
||||||
|
"type" in encodedData &&
|
||||||
|
encodedData.type === EXPORT_DATA_TYPES.excalidraw
|
||||||
|
) {
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
throw new Error("FAILED");
|
||||||
|
}
|
||||||
|
return decode(encodedData);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
throw new Error("FAILED");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("INVALID");
|
||||||
|
};
|
||||||
|
|
||||||
// calculate smallest area to fit the contents in
|
// calculate smallest area to fit the contents in
|
||||||
const getCanvasSize = (
|
const getCanvasSize = (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
|||||||
"category": "element",
|
"category": "element",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"separator",
|
||||||
{
|
{
|
||||||
"label": "labels.selectAllElementsInFrame",
|
"label": "labels.selectAllElementsInFrame",
|
||||||
"name": "selectAllElementsInFrame",
|
"name": "selectAllElementsInFrame",
|
||||||
@@ -115,6 +116,15 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
|||||||
"category": "history",
|
"category": "history",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"label": "labels.wrapSelectionInFrame",
|
||||||
|
"name": "wrapSelectionInFrame",
|
||||||
|
"perform": [Function],
|
||||||
|
"predicate": [Function],
|
||||||
|
"trackEvent": {
|
||||||
|
"category": "element",
|
||||||
|
},
|
||||||
|
},
|
||||||
"separator",
|
"separator",
|
||||||
{
|
{
|
||||||
"PanelComponent": [Function],
|
"PanelComponent": [Function],
|
||||||
@@ -1006,14 +1016,14 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
|||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 1278240551,
|
"seed": 1,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 453191,
|
"versionNonce": 1278240551,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
@@ -1042,14 +1052,14 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
|||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 449462985,
|
"seed": 1,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 401146281,
|
"versionNonce": 449462985,
|
||||||
"width": 100,
|
"width": 100,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
@@ -4731,6 +4741,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
|||||||
"category": "element",
|
"category": "element",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"separator",
|
||||||
{
|
{
|
||||||
"label": "labels.selectAllElementsInFrame",
|
"label": "labels.selectAllElementsInFrame",
|
||||||
"name": "selectAllElementsInFrame",
|
"name": "selectAllElementsInFrame",
|
||||||
@@ -4749,6 +4760,15 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
|||||||
"category": "history",
|
"category": "history",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"label": "labels.wrapSelectionInFrame",
|
||||||
|
"name": "wrapSelectionInFrame",
|
||||||
|
"perform": [Function],
|
||||||
|
"predicate": [Function],
|
||||||
|
"trackEvent": {
|
||||||
|
"category": "element",
|
||||||
|
},
|
||||||
|
},
|
||||||
"separator",
|
"separator",
|
||||||
{
|
{
|
||||||
"PanelComponent": [Function],
|
"PanelComponent": [Function],
|
||||||
@@ -5942,6 +5962,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
|||||||
"category": "element",
|
"category": "element",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"separator",
|
||||||
{
|
{
|
||||||
"label": "labels.selectAllElementsInFrame",
|
"label": "labels.selectAllElementsInFrame",
|
||||||
"name": "selectAllElementsInFrame",
|
"name": "selectAllElementsInFrame",
|
||||||
@@ -5960,6 +5981,15 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
|||||||
"category": "history",
|
"category": "history",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"label": "labels.wrapSelectionInFrame",
|
||||||
|
"name": "wrapSelectionInFrame",
|
||||||
|
"perform": [Function],
|
||||||
|
"predicate": [Function],
|
||||||
|
"trackEvent": {
|
||||||
|
"category": "element",
|
||||||
|
},
|
||||||
|
},
|
||||||
"separator",
|
"separator",
|
||||||
{
|
{
|
||||||
"PanelComponent": [Function],
|
"PanelComponent": [Function],
|
||||||
@@ -7876,6 +7906,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
"category": "element",
|
"category": "element",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"separator",
|
||||||
{
|
{
|
||||||
"label": "labels.selectAllElementsInFrame",
|
"label": "labels.selectAllElementsInFrame",
|
||||||
"name": "selectAllElementsInFrame",
|
"name": "selectAllElementsInFrame",
|
||||||
@@ -7894,6 +7925,15 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
"category": "history",
|
"category": "history",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"label": "labels.wrapSelectionInFrame",
|
||||||
|
"name": "wrapSelectionInFrame",
|
||||||
|
"perform": [Function],
|
||||||
|
"predicate": [Function],
|
||||||
|
"trackEvent": {
|
||||||
|
"category": "element",
|
||||||
|
},
|
||||||
|
},
|
||||||
"separator",
|
"separator",
|
||||||
{
|
{
|
||||||
"PanelComponent": [Function],
|
"PanelComponent": [Function],
|
||||||
@@ -8854,6 +8894,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
"category": "element",
|
"category": "element",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"separator",
|
||||||
{
|
{
|
||||||
"label": "labels.selectAllElementsInFrame",
|
"label": "labels.selectAllElementsInFrame",
|
||||||
"name": "selectAllElementsInFrame",
|
"name": "selectAllElementsInFrame",
|
||||||
@@ -8872,6 +8913,15 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
"category": "history",
|
"category": "history",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"label": "labels.wrapSelectionInFrame",
|
||||||
|
"name": "wrapSelectionInFrame",
|
||||||
|
"perform": [Function],
|
||||||
|
"predicate": [Function],
|
||||||
|
"trackEvent": {
|
||||||
|
"category": "element",
|
||||||
|
},
|
||||||
|
},
|
||||||
"separator",
|
"separator",
|
||||||
{
|
{
|
||||||
"PanelComponent": [Function],
|
"PanelComponent": [Function],
|
||||||
@@ -9792,14 +9842,14 @@ exports[`contextMenu element > shows context menu for element > [end of test] el
|
|||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 1278240551,
|
"seed": 1,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 453191,
|
"versionNonce": 1278240551,
|
||||||
"width": 200,
|
"width": 200,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
@@ -9826,14 +9876,14 @@ exports[`contextMenu element > shows context menu for element > [end of test] el
|
|||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 449462985,
|
"seed": 1,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"versionNonce": 401146281,
|
"versionNonce": 449462985,
|
||||||
"width": 200,
|
"width": 200,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -197,7 +197,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
|||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 125,
|
"height": 99,
|
||||||
"id": "id166",
|
"id": "id166",
|
||||||
"index": "a2",
|
"index": "a2",
|
||||||
"isDeleted": false,
|
"isDeleted": false,
|
||||||
@@ -211,8 +211,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
125,
|
"98.20800",
|
||||||
125,
|
99,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
"roughness": 1,
|
"roughness": 1,
|
||||||
@@ -226,9 +226,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
|||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 47,
|
"version": 40,
|
||||||
"width": 125,
|
"width": "98.20800",
|
||||||
"x": 0,
|
"x": 1,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -298,7 +298,7 @@ History {
|
|||||||
"focus": "0.00990",
|
"focus": "0.00990",
|
||||||
"gap": 1,
|
"gap": 1,
|
||||||
},
|
},
|
||||||
"height": "0.98000",
|
"height": "0.98017",
|
||||||
"points": [
|
"points": [
|
||||||
[
|
[
|
||||||
0,
|
0,
|
||||||
@@ -306,7 +306,7 @@ History {
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
98,
|
98,
|
||||||
"-0.98000",
|
"-0.98017",
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
"startBinding": {
|
"startBinding": {
|
||||||
@@ -320,10 +320,10 @@ History {
|
|||||||
"endBinding": {
|
"endBinding": {
|
||||||
"elementId": "id165",
|
"elementId": "id165",
|
||||||
"fixedPoint": null,
|
"fixedPoint": null,
|
||||||
"focus": "-0.02040",
|
"focus": "-0.02000",
|
||||||
"gap": 1,
|
"gap": 1,
|
||||||
},
|
},
|
||||||
"height": "0.02000",
|
"height": "0.00169",
|
||||||
"points": [
|
"points": [
|
||||||
[
|
[
|
||||||
0,
|
0,
|
||||||
@@ -331,13 +331,13 @@ History {
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
98,
|
98,
|
||||||
"0.02000",
|
"0.00169",
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
"startBinding": {
|
"startBinding": {
|
||||||
"elementId": "id164",
|
"elementId": "id164",
|
||||||
"fixedPoint": null,
|
"fixedPoint": null,
|
||||||
"focus": "0.01959",
|
"focus": "0.02000",
|
||||||
"gap": 1,
|
"gap": 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -393,20 +393,18 @@ History {
|
|||||||
"focus": 0,
|
"focus": 0,
|
||||||
"gap": 1,
|
"gap": 1,
|
||||||
},
|
},
|
||||||
"height": 125,
|
"height": 99,
|
||||||
"points": [
|
"points": [
|
||||||
[
|
[
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
125,
|
"98.20800",
|
||||||
125,
|
99,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
"startBinding": null,
|
"startBinding": null,
|
||||||
"width": 125,
|
|
||||||
"x": 0,
|
|
||||||
"y": 0,
|
"y": 0,
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
@@ -416,7 +414,7 @@ History {
|
|||||||
"focus": "0.00990",
|
"focus": "0.00990",
|
||||||
"gap": 1,
|
"gap": 1,
|
||||||
},
|
},
|
||||||
"height": "0.98000",
|
"height": "0.98161",
|
||||||
"points": [
|
"points": [
|
||||||
[
|
[
|
||||||
0,
|
0,
|
||||||
@@ -424,7 +422,7 @@ History {
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
98,
|
98,
|
||||||
"-0.98000",
|
"-0.98161",
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
"startBinding": {
|
"startBinding": {
|
||||||
@@ -433,9 +431,7 @@ History {
|
|||||||
"focus": "0.02970",
|
"focus": "0.02970",
|
||||||
"gap": 1,
|
"gap": 1,
|
||||||
},
|
},
|
||||||
"width": 98,
|
"y": "0.99245",
|
||||||
"x": 1,
|
|
||||||
"y": "0.99000",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"id169" => Delta {
|
"id169" => Delta {
|
||||||
@@ -827,9 +823,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
|||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 37,
|
"version": 30,
|
||||||
"width": 100,
|
"width": 0,
|
||||||
"x": 150,
|
"x": 200,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -866,8 +862,6 @@ History {
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
"width": 0,
|
|
||||||
"x": 149,
|
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"points": [
|
"points": [
|
||||||
@@ -876,12 +870,10 @@ History {
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"98.00000",
|
100,
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
"width": "98.00000",
|
|
||||||
"x": "1.00000",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -938,8 +930,6 @@ History {
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
"startBinding": null,
|
"startBinding": null,
|
||||||
"width": 100,
|
|
||||||
"x": 150,
|
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"endBinding": {
|
"endBinding": {
|
||||||
@@ -964,8 +954,6 @@ History {
|
|||||||
"focus": 0,
|
"focus": 0,
|
||||||
"gap": 1,
|
"gap": 1,
|
||||||
},
|
},
|
||||||
"width": 0,
|
|
||||||
"x": 149,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -2375,9 +2363,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
|||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 12,
|
"version": 10,
|
||||||
"width": 498,
|
"width": 498,
|
||||||
"x": "1.00000",
|
"x": 1,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -2516,7 +2504,7 @@ History {
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"98.00000",
|
100,
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -2535,8 +2523,8 @@ History {
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"width": "98.00000",
|
"width": 100,
|
||||||
"x": 1,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
@@ -10995,7 +10983,9 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
|
|||||||
"focus": "-0.00161",
|
"focus": "-0.00161",
|
||||||
"gap": "3.53708",
|
"gap": "3.53708",
|
||||||
},
|
},
|
||||||
|
"endIsSpecial": null,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
|
"fixedSegments": null,
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": "448.10100",
|
"height": "448.10100",
|
||||||
@@ -11012,9 +11002,13 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"451.90000",
|
"225.95000",
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
"225.95000",
|
||||||
|
"448.10100",
|
||||||
|
],
|
||||||
[
|
[
|
||||||
"451.90000",
|
"451.90000",
|
||||||
"448.10100",
|
"448.10100",
|
||||||
@@ -11034,6 +11028,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
|
|||||||
"focus": "-0.00159",
|
"focus": "-0.00159",
|
||||||
"gap": 5,
|
"gap": 5,
|
||||||
},
|
},
|
||||||
|
"startIsSpecial": null,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
@@ -11159,7 +11154,9 @@ History {
|
|||||||
"focus": "-0.00161",
|
"focus": "-0.00161",
|
||||||
"gap": "3.53708",
|
"gap": "3.53708",
|
||||||
},
|
},
|
||||||
|
"endIsSpecial": false,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
|
"fixedSegments": [],
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": "236.10000",
|
"height": "236.10000",
|
||||||
@@ -11197,6 +11194,7 @@ History {
|
|||||||
"focus": "-0.00159",
|
"focus": "-0.00159",
|
||||||
"gap": 5,
|
"gap": 5,
|
||||||
},
|
},
|
||||||
|
"startIsSpecial": false,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
@@ -15179,9 +15177,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
|||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 12,
|
"version": 10,
|
||||||
"width": "98.00000",
|
"width": "98.00000",
|
||||||
"x": "1.00000",
|
"x": 1,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -15220,7 +15218,7 @@ History {
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"98.00000",
|
100,
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -15233,7 +15231,7 @@ History {
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"98.00000",
|
100,
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -15529,7 +15527,7 @@ History {
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"98.00000",
|
100,
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -15548,8 +15546,8 @@ History {
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"width": "98.00000",
|
"width": 100,
|
||||||
"x": 1,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
@@ -15878,9 +15876,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
|||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 12,
|
"version": 10,
|
||||||
"width": "98.00000",
|
"width": "98.00000",
|
||||||
"x": "1.00000",
|
"x": 1,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -16152,7 +16150,7 @@ History {
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"98.00000",
|
100,
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -16171,8 +16169,8 @@ History {
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"width": "98.00000",
|
"width": 100,
|
||||||
"x": 1,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
@@ -16501,9 +16499,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
|||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 12,
|
"version": 10,
|
||||||
"width": "98.00000",
|
"width": "98.00000",
|
||||||
"x": "1.00000",
|
"x": 1,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -16775,7 +16773,7 @@ History {
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"98.00000",
|
100,
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -16794,8 +16792,8 @@ History {
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"width": "98.00000",
|
"width": 100,
|
||||||
"x": 1,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
@@ -17122,9 +17120,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
|||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 12,
|
"version": 10,
|
||||||
"width": "98.00000",
|
"width": "98.00000",
|
||||||
"x": "1.00000",
|
"x": 1,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -17180,7 +17178,7 @@ History {
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"98.00000",
|
100,
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -17198,7 +17196,7 @@ History {
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"98.00000",
|
100,
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -17467,7 +17465,7 @@ History {
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"98.00000",
|
100,
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -17486,8 +17484,8 @@ History {
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"width": "98.00000",
|
"width": 100,
|
||||||
"x": 1,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
@@ -17840,9 +17838,9 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
|||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 13,
|
"version": 11,
|
||||||
"width": "98.00000",
|
"width": "98.00000",
|
||||||
"x": "1.00000",
|
"x": 1,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -17913,7 +17911,7 @@ History {
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"98.00000",
|
100,
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -17932,7 +17930,7 @@ History {
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"98.00000",
|
100,
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -18201,7 +18199,7 @@ History {
|
|||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"98.00000",
|
100,
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -18220,8 +18218,8 @@ History {
|
|||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"width": "98.00000",
|
"width": 100,
|
||||||
"x": 1,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ exports[`move element > rectangles with binding arrow 6`] = `
|
|||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 7,
|
"version": 7,
|
||||||
"versionNonce": 2066753033,
|
"versionNonce": 745419401,
|
||||||
"width": 300,
|
"width": 300,
|
||||||
"x": 201,
|
"x": 201,
|
||||||
"y": 2,
|
"y": 2,
|
||||||
@@ -232,8 +232,8 @@ exports[`move element > rectangles with binding arrow 7`] = `
|
|||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 15,
|
"version": 11,
|
||||||
"versionNonce": 271613161,
|
"versionNonce": 1996028265,
|
||||||
"width": 81,
|
"width": 81,
|
||||||
"x": 110,
|
"x": 110,
|
||||||
"y": 50,
|
"y": 50,
|
||||||
|
|||||||
@@ -4307,14 +4307,20 @@ History {
|
|||||||
"appStateChange": AppStateChange {
|
"appStateChange": AppStateChange {
|
||||||
"delta": Delta {
|
"delta": Delta {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
|
"editingGroupId": null,
|
||||||
"selectedElementIds": {
|
"selectedElementIds": {
|
||||||
"id1": true,
|
"id1": true,
|
||||||
},
|
},
|
||||||
|
"selectedGroupIds": {
|
||||||
|
"id4": false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
|
"editingGroupId": "id4",
|
||||||
"selectedElementIds": {
|
"selectedElementIds": {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
|
"selectedGroupIds": {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -4337,14 +4343,16 @@ History {
|
|||||||
"appStateChange": AppStateChange {
|
"appStateChange": AppStateChange {
|
||||||
"delta": Delta {
|
"delta": Delta {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"editingGroupId": null,
|
|
||||||
"selectedElementIds": {},
|
"selectedElementIds": {},
|
||||||
|
"selectedGroupIds": {},
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"editingGroupId": "id4",
|
|
||||||
"selectedElementIds": {
|
"selectedElementIds": {
|
||||||
"id1": true,
|
"id1": true,
|
||||||
},
|
},
|
||||||
|
"selectedGroupIds": {
|
||||||
|
"id4": false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ describe("contextMenu element", () => {
|
|||||||
"cut",
|
"cut",
|
||||||
"copy",
|
"copy",
|
||||||
"paste",
|
"paste",
|
||||||
|
"wrapSelectionInFrame",
|
||||||
"copyStyles",
|
"copyStyles",
|
||||||
"pasteStyles",
|
"pasteStyles",
|
||||||
"deleteSelectedElements",
|
"deleteSelectedElements",
|
||||||
@@ -213,6 +214,7 @@ describe("contextMenu element", () => {
|
|||||||
"cut",
|
"cut",
|
||||||
"copy",
|
"copy",
|
||||||
"paste",
|
"paste",
|
||||||
|
"wrapSelectionInFrame",
|
||||||
"copyStyles",
|
"copyStyles",
|
||||||
"pasteStyles",
|
"pasteStyles",
|
||||||
"deleteSelectedElements",
|
"deleteSelectedElements",
|
||||||
@@ -269,6 +271,7 @@ describe("contextMenu element", () => {
|
|||||||
"cut",
|
"cut",
|
||||||
"copy",
|
"copy",
|
||||||
"paste",
|
"paste",
|
||||||
|
"wrapSelectionInFrame",
|
||||||
"copyStyles",
|
"copyStyles",
|
||||||
"pasteStyles",
|
"pasteStyles",
|
||||||
"deleteSelectedElements",
|
"deleteSelectedElements",
|
||||||
|
|||||||
@@ -171,8 +171,8 @@ describe("Crop an image", () => {
|
|||||||
// test corner handle aspect ratio preserving
|
// test corner handle aspect ratio preserving
|
||||||
UI.crop(image, "se", naturalWidth, naturalHeight, [initialWidth, 0], true);
|
UI.crop(image, "se", naturalWidth, naturalHeight, [initialWidth, 0], true);
|
||||||
expect(image.width / image.height).toBe(resizedWidth / resizedHeight);
|
expect(image.width / image.height).toBe(resizedWidth / resizedHeight);
|
||||||
expect(image.width).toBeLessThanOrEqual(initialWidth);
|
expect(image.width).toBeLessThanOrEqual(initialWidth + 0.0001);
|
||||||
expect(image.height).toBeLessThanOrEqual(initialHeight);
|
expect(image.height).toBeLessThanOrEqual(initialHeight + 0.0001);
|
||||||
|
|
||||||
// reset
|
// reset
|
||||||
image = API.createElement({ type: "image", width: 200, height: 100 });
|
image = API.createElement({ type: "image", width: 200, height: 100 });
|
||||||
@@ -194,7 +194,7 @@ describe("Crop an image", () => {
|
|||||||
expect(image.width).toBeCloseTo(image.height);
|
expect(image.width).toBeCloseTo(image.height);
|
||||||
// max height should be reached
|
// max height should be reached
|
||||||
expect(image.height).toBeCloseTo(initialHeight);
|
expect(image.height).toBeCloseTo(initialHeight);
|
||||||
expect(image.width).toBe(initialHeight);
|
expect(image.width).toBeCloseTo(initialHeight);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,17 @@ import React from "react";
|
|||||||
import { render, waitFor } from "./test-utils";
|
import { render, waitFor } from "./test-utils";
|
||||||
import { Excalidraw } from "../index";
|
import { Excalidraw } from "../index";
|
||||||
import { API } from "./helpers/api";
|
import { API } from "./helpers/api";
|
||||||
import {
|
import { encodePngMetadata } from "../data/image";
|
||||||
encodePngMetadata,
|
|
||||||
encodeSvgMetadata,
|
|
||||||
decodeSvgMetadata,
|
|
||||||
} from "../data/image";
|
|
||||||
import { serializeAsJSON } from "../data/json";
|
import { serializeAsJSON } from "../data/json";
|
||||||
import { exportToSvg } from "../scene/export";
|
import {
|
||||||
|
decodeSvgBase64Payload,
|
||||||
|
encodeSvgBase64Payload,
|
||||||
|
exportToSvg,
|
||||||
|
} from "../scene/export";
|
||||||
import type { FileId } from "../element/types";
|
import type { FileId } from "../element/types";
|
||||||
import { getDataURL } from "../data/blob";
|
import { getDataURL } from "../data/blob";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
|
import { SVG_NS } from "../constants";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
@@ -62,15 +63,32 @@ describe("export", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("test encoding/decoding scene for SVG export", async () => {
|
it("test encoding/decoding scene for SVG export", async () => {
|
||||||
const encoded = encodeSvgMetadata({
|
const metadataElement = document.createElementNS(SVG_NS, "metadata");
|
||||||
text: serializeAsJSON(testElements, h.state, {}, "local"),
|
|
||||||
|
encodeSvgBase64Payload({
|
||||||
|
metadataElement,
|
||||||
|
payload: serializeAsJSON(testElements, h.state, {}, "local"),
|
||||||
});
|
});
|
||||||
const decoded = JSON.parse(decodeSvgMetadata({ svg: encoded }));
|
|
||||||
|
const decoded = JSON.parse(
|
||||||
|
decodeSvgBase64Payload({ svg: metadataElement.innerHTML }),
|
||||||
|
);
|
||||||
expect(decoded.elements).toEqual([
|
expect(decoded.elements).toEqual([
|
||||||
expect.objectContaining({ type: "text", text: "😀" }),
|
expect.objectContaining({ type: "text", text: "😀" }),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("export svg-embedded scene", async () => {
|
||||||
|
const svg = await exportToSvg(
|
||||||
|
testElements,
|
||||||
|
{ ...getDefaultAppState(), exportEmbedScene: true },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const svgText = svg.outerHTML;
|
||||||
|
|
||||||
|
expect(svgText).toMatchSnapshot(`svg-embdedded scene export output`);
|
||||||
|
});
|
||||||
|
|
||||||
it("import embedded png (legacy v1)", async () => {
|
it("import embedded png (legacy v1)", async () => {
|
||||||
await API.drop(await API.loadFile("./fixtures/test_embedded_v1.png"));
|
await API.drop(await API.loadFile("./fixtures/test_embedded_v1.png"));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
ExcalidrawMagicFrameElement,
|
ExcalidrawMagicFrameElement,
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
|
FixedSegment,
|
||||||
} from "../../element/types";
|
} from "../../element/types";
|
||||||
import { newElement, newTextElement, newLinearElement } from "../../element";
|
import { newElement, newTextElement, newLinearElement } from "../../element";
|
||||||
import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS } from "../../constants";
|
import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS } from "../../constants";
|
||||||
@@ -197,6 +198,7 @@ export class API {
|
|||||||
? ExcalidrawArrowElement["endArrowhead"] | ExcalidrawElbowArrowElement["endArrowhead"]
|
? ExcalidrawArrowElement["endArrowhead"] | ExcalidrawElbowArrowElement["endArrowhead"]
|
||||||
: never;
|
: never;
|
||||||
elbowed?: boolean;
|
elbowed?: boolean;
|
||||||
|
fixedSegments?: FixedSegment[] | null;
|
||||||
}): T extends "arrow" | "line"
|
}): T extends "arrow" | "line"
|
||||||
? ExcalidrawLinearElement
|
? ExcalidrawLinearElement
|
||||||
: T extends "freedraw"
|
: T extends "freedraw"
|
||||||
@@ -220,7 +222,6 @@ export class API {
|
|||||||
| "width"
|
| "width"
|
||||||
| "height"
|
| "height"
|
||||||
| "type"
|
| "type"
|
||||||
| "seed"
|
|
||||||
| "version"
|
| "version"
|
||||||
| "versionNonce"
|
| "versionNonce"
|
||||||
| "isDeleted"
|
| "isDeleted"
|
||||||
@@ -228,6 +229,7 @@ export class API {
|
|||||||
| "link"
|
| "link"
|
||||||
| "updated"
|
| "updated"
|
||||||
> = {
|
> = {
|
||||||
|
seed: 1,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
frameId: rest.frameId ?? null,
|
frameId: rest.frameId ?? null,
|
||||||
|
|||||||
@@ -2084,7 +2084,8 @@ describe("history", () => {
|
|||||||
)[0] as ExcalidrawElbowArrowElement;
|
)[0] as ExcalidrawElbowArrowElement;
|
||||||
expect(modifiedArrow.points).toEqual([
|
expect(modifiedArrow.points).toEqual([
|
||||||
[0, 0],
|
[0, 0],
|
||||||
[451.9000000000001, 0],
|
[225.95000000000005, 0],
|
||||||
|
[225.95000000000005, 448.10100010002003],
|
||||||
[451.9000000000001, 448.10100010002003],
|
[451.9000000000001, 448.10100010002003],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type {
|
|||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawTextElementWithContainer,
|
ExcalidrawTextElementWithContainer,
|
||||||
FontString,
|
FontString,
|
||||||
SceneElementsMap,
|
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { Excalidraw, mutateElement } from "../index";
|
import { Excalidraw, mutateElement } from "../index";
|
||||||
import { reseed } from "../random";
|
import { reseed } from "../random";
|
||||||
@@ -1353,23 +1352,19 @@ describe("Test Linear Elements", () => {
|
|||||||
const [origStartX, origStartY] = [line.x, line.y];
|
const [origStartX, origStartY] = [line.x, line.y];
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
LinearElementEditor.movePoints(
|
LinearElementEditor.movePoints(line, [
|
||||||
line,
|
{
|
||||||
[
|
index: 0,
|
||||||
{
|
point: pointFrom(line.points[0][0] + 10, line.points[0][1] + 10),
|
||||||
index: 0,
|
},
|
||||||
point: pointFrom(line.points[0][0] + 10, line.points[0][1] + 10),
|
{
|
||||||
},
|
index: line.points.length - 1,
|
||||||
{
|
point: pointFrom(
|
||||||
index: line.points.length - 1,
|
line.points[line.points.length - 1][0] - 10,
|
||||||
point: pointFrom(
|
line.points[line.points.length - 1][1] - 10,
|
||||||
line.points[line.points.length - 1][0] - 10,
|
),
|
||||||
line.points[line.points.length - 1][1] - 10,
|
},
|
||||||
),
|
]);
|
||||||
},
|
|
||||||
],
|
|
||||||
new Map() as SceneElementsMap,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
expect(line.x).toBe(origStartX + 10);
|
expect(line.x).toBe(origStartX + 10);
|
||||||
expect(line.y).toBe(origStartY + 10);
|
expect(line.y).toBe(origStartY + 10);
|
||||||
|
|||||||
@@ -535,7 +535,7 @@ describe("arrow element", () => {
|
|||||||
|
|
||||||
UI.resize([rectangle, arrow], "nw", [300, 350]);
|
UI.resize([rectangle, arrow], "nw", [300, 350]);
|
||||||
|
|
||||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.144, 2);
|
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.144);
|
||||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
|
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -10,6 +10,7 @@ import { STORAGE_KEYS } from "../../../excalidraw-app/app_constants";
|
|||||||
import { getSelectedElements } from "../scene/selection";
|
import { getSelectedElements } from "../scene/selection";
|
||||||
import type { ExcalidrawElement } from "../element/types";
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
import { UI } from "./helpers/ui";
|
import { UI } from "./helpers/ui";
|
||||||
|
import { diffStringsUnified } from "jest-diff";
|
||||||
|
|
||||||
const customQueries = {
|
const customQueries = {
|
||||||
...queries,
|
...queries,
|
||||||
@@ -246,6 +247,36 @@ expect.extend({
|
|||||||
pass: false,
|
pass: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
toCloselyEqualPoints(received, expected, precision) {
|
||||||
|
if (!Array.isArray(received) || !Array.isArray(expected)) {
|
||||||
|
throw new Error("expected and received are not point arrays");
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMPARE = 1 / Math.pow(10, precision || 2);
|
||||||
|
const pass = received.every(
|
||||||
|
(point, idx) =>
|
||||||
|
Math.abs(expected[idx]?.[0] - point[0]) < COMPARE &&
|
||||||
|
Math.abs(expected[idx]?.[1] - point[1]) < COMPARE,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!pass) {
|
||||||
|
return {
|
||||||
|
message: () => ` The provided array of points are not close enough.
|
||||||
|
|
||||||
|
${diffStringsUnified(
|
||||||
|
JSON.stringify(expected, undefined, 2),
|
||||||
|
JSON.stringify(received, undefined, 2),
|
||||||
|
)}`,
|
||||||
|
pass: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: () => `expected ${received} to not be close to ${expected}`,
|
||||||
|
pass: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import * as utils from "../utils";
|
import { isTransparent, sanitizeHTMLAttribute } from "../utils";
|
||||||
|
|
||||||
describe("Test isTransparent", () => {
|
describe("Test isTransparent", () => {
|
||||||
it("should return true when color is rgb transparent", () => {
|
it("should return true when color is rgb transparent", () => {
|
||||||
expect(utils.isTransparent("#ff00")).toEqual(true);
|
expect(isTransparent("#ff00")).toEqual(true);
|
||||||
expect(utils.isTransparent("#fff00000")).toEqual(true);
|
expect(isTransparent("#fff00000")).toEqual(true);
|
||||||
expect(utils.isTransparent("transparent")).toEqual(true);
|
expect(isTransparent("transparent")).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return false when color is not transparent", () => {
|
it("should return false when color is not transparent", () => {
|
||||||
expect(utils.isTransparent("#ced4da")).toEqual(false);
|
expect(isTransparent("#ced4da")).toEqual(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitizeHTMLAttribute()", () => {
|
||||||
|
it("should escape HTML attribute special characters & not double escape", () => {
|
||||||
|
expect(sanitizeHTMLAttribute(`&"'><`)).toBe("&"'><");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user