Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46c6916e1d | |||
| bec662eaf6 | |||
| 068edfc1cd | |||
| 4672bb948c | |||
| 49fbad32ac | |||
| 24858c9ace | |||
| fdbf316cc1 | |||
| eec3f67d72 | |||
| 0cbf244bd8 | |||
| 795511ee6b |
+1
-15
@@ -3,20 +3,6 @@
|
|||||||
"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": [
|
"@typescript-eslint/consistent-type-imports": ["error", { "prefer": "type-imports", "disallowTypeAnnotations": false, "fixStyle": "separate-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\")."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -6797,9 +6797,9 @@ send@0.18.0:
|
|||||||
statuses "2.0.1"
|
statuses "2.0.1"
|
||||||
|
|
||||||
serialize-javascript@^6.0.0:
|
serialize-javascript@^6.0.0:
|
||||||
version "6.0.2"
|
version "6.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2"
|
resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.0.tgz#efae5d88f45d7924141da8b5c3a7a7e663fefeb8"
|
||||||
integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==
|
integrity sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==
|
||||||
dependencies:
|
dependencies:
|
||||||
randombytes "^2.1.0"
|
randombytes "^2.1.0"
|
||||||
|
|
||||||
|
|||||||
+7
-10
@@ -90,13 +90,9 @@ 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 {
|
import { Provider, useAtom, useAtomValue } from "jotai";
|
||||||
Provider,
|
import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
|
||||||
useAtom,
|
import { appJotaiStore } from "./app-jotai";
|
||||||
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";
|
||||||
@@ -121,7 +117,7 @@ import {
|
|||||||
share,
|
share,
|
||||||
youtubeIcon,
|
youtubeIcon,
|
||||||
} from "../packages/excalidraw/components/icons";
|
} from "../packages/excalidraw/components/icons";
|
||||||
import { useHandleAppTheme } from "./useHandleAppTheme";
|
import { appThemeAtom, 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, {
|
||||||
@@ -332,7 +328,8 @@ const ExcalidrawWrapper = () => {
|
|||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
const isCollabDisabled = isRunningInIframe();
|
const isCollabDisabled = isRunningInIframe();
|
||||||
|
|
||||||
const { editorTheme, appTheme, setAppTheme } = useHandleAppTheme();
|
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
|
||||||
|
const { editorTheme } = useHandleAppTheme();
|
||||||
|
|
||||||
const [langCode, setLangCode] = useAppLangCode();
|
const [langCode, setLangCode] = useAppLangCode();
|
||||||
|
|
||||||
@@ -1144,7 +1141,7 @@ const ExcalidrawApp = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TopErrorBoundary>
|
<TopErrorBoundary>
|
||||||
<Provider store={appJotaiStore}>
|
<Provider unstable_createStore={() => appJotaiStore}>
|
||||||
<ExcalidrawWrapper />
|
<ExcalidrawWrapper />
|
||||||
</Provider>
|
</Provider>
|
||||||
</TopErrorBoundary>
|
</TopErrorBoundary>
|
||||||
|
|||||||
@@ -1,37 +1,3 @@
|
|||||||
// eslint-disable-next-line no-restricted-imports
|
import { unstable_createStore } from "jotai";
|
||||||
import {
|
|
||||||
atom,
|
|
||||||
Provider,
|
|
||||||
useAtom,
|
|
||||||
useAtomValue,
|
|
||||||
useSetAtom,
|
|
||||||
createStore,
|
|
||||||
type PrimitiveAtom,
|
|
||||||
} from "jotai";
|
|
||||||
import { useLayoutEffect } from "react";
|
|
||||||
|
|
||||||
export const appJotaiStore = createStore();
|
export const appJotaiStore = unstable_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,7 +79,8 @@ 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 { appJotaiStore, atom } from "../app-jotai";
|
import { atom } from "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;
|
||||||
|
|||||||
@@ -27,12 +27,12 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@excalidraw/random-username": "1.0.0",
|
"@excalidraw/random-username": "1.0.0",
|
||||||
"@sentry/browser": "9.0.1",
|
"@sentry/browser": "6.2.5",
|
||||||
"callsites": "4.2.0",
|
"@sentry/integrations": "6.2.5",
|
||||||
"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": "2.11.0",
|
"jotai": "1.13.1",
|
||||||
"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",
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import * as Sentry from "@sentry/browser";
|
import * as Sentry from "@sentry/browser";
|
||||||
import callsites from "callsites";
|
import * as SentryIntegrations from "@sentry/integrations";
|
||||||
|
|
||||||
const SentryEnvHostnameMap: { [key: string]: string } = {
|
const SentryEnvHostnameMap: { [key: string]: string } = {
|
||||||
"excalidraw.com": "production",
|
"excalidraw.com": "production",
|
||||||
"staging.excalidraw.com": "staging",
|
|
||||||
"vercel.app": "staging",
|
"vercel.app": "staging",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -24,13 +23,9 @@ Sentry.init({
|
|||||||
release: import.meta.env.VITE_APP_GIT_SHA,
|
release: import.meta.env.VITE_APP_GIT_SHA,
|
||||||
ignoreErrors: [
|
ignoreErrors: [
|
||||||
"undefined is not an object (evaluating 'window.__pad.performLoop')", // Only happens on Safari, but spams our servers. Doesn't break anything
|
"undefined is not an object (evaluating 'window.__pad.performLoop')", // Only happens on Safari, but spams our servers. Doesn't break anything
|
||||||
"InvalidStateError: Failed to execute 'transaction' on 'IDBDatabase': The database connection is closing.", // Not much we can do about the IndexedDB closing error
|
|
||||||
/(Failed to fetch|(fetch|loading) dynamically imported module)/i, // This is happening when a service worker tries to load an old asset
|
|
||||||
/QuotaExceededError: (The quota has been exceeded|.*setItem.*Storage)/i, // localStorage quota exceeded
|
|
||||||
"Internal error opening backing store for indexedDB.open", // Private mode and disabled indexedDB
|
|
||||||
],
|
],
|
||||||
integrations: [
|
integrations: [
|
||||||
Sentry.captureConsoleIntegration({
|
new SentryIntegrations.CaptureConsole({
|
||||||
levels: ["error"],
|
levels: ["error"],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -38,44 +33,6 @@ Sentry.init({
|
|||||||
if (event.request?.url) {
|
if (event.request?.url) {
|
||||||
event.request.url = event.request.url.replace(/#.*$/, "");
|
event.request.url = event.request.url.replace(/#.*$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!event.exception) {
|
|
||||||
event.exception = {
|
|
||||||
values: [
|
|
||||||
{
|
|
||||||
type: "ConsoleError",
|
|
||||||
value: event.message ?? "Unknown error",
|
|
||||||
stacktrace: {
|
|
||||||
frames: callsites()
|
|
||||||
.slice(1)
|
|
||||||
.filter(
|
|
||||||
(frame) =>
|
|
||||||
frame.getFileName() &&
|
|
||||||
!frame.getFileName()?.includes("@sentry_browser.js"),
|
|
||||||
)
|
|
||||||
.map((frame) => ({
|
|
||||||
filename: frame.getFileName() ?? undefined,
|
|
||||||
function: frame.getFunctionName() ?? undefined,
|
|
||||||
in_app: !(
|
|
||||||
frame.getFileName()?.includes("node_modules") ?? false
|
|
||||||
),
|
|
||||||
lineno: frame.getLineNumber() ?? undefined,
|
|
||||||
colno: frame.getColumnNumber() ?? undefined,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
mechanism: {
|
|
||||||
type: "instrument",
|
|
||||||
handled: true,
|
|
||||||
data: {
|
|
||||||
function: "console.error",
|
|
||||||
handler: "Sentry.beforeSend",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return event;
|
return event;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
|
import { atom, useAtom, useAtomValue } from "jotai";
|
||||||
import { useCopyStatus } from "../../packages/excalidraw/hooks/useCopiedIndicator";
|
|
||||||
import { atom, useAtom, useAtomValue } from "../app-jotai";
|
|
||||||
|
|
||||||
import "./ShareDialog.scss";
|
import "./ShareDialog.scss";
|
||||||
|
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
|
||||||
|
import { useCopyStatus } from "../../packages/excalidraw/hooks/useCopiedIndicator";
|
||||||
|
|
||||||
type OnExportToBackend = () => void;
|
type OnExportToBackend = () => void;
|
||||||
type ShareDialogType = "share" | "collaborationOnly";
|
type ShareDialogType = "share" | "collaborationOnly";
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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";
|
||||||
@@ -5,18 +6,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] = useState<Theme | "system">(() => {
|
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
|
||||||
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(() => {
|
||||||
@@ -65,5 +66,5 @@ export const useHandleAppTheme = () => {
|
|||||||
}
|
}
|
||||||
}, [appTheme]);
|
}, [appTheme]);
|
||||||
|
|
||||||
return { editorTheme, appTheme, setAppTheme };
|
return { editorTheme };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ import type { AppClassProperties, AppState, UIAppState } from "../types";
|
|||||||
import { arrayToMap, getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
export const alignActionsPredicate = (
|
const alignActionsPredicate = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: UIAppState,
|
appState: UIAppState,
|
||||||
|
_: unknown,
|
||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
) => {
|
) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
@@ -46,7 +48,6 @@ const alignSelectedElements = (
|
|||||||
selectedElements,
|
selectedElements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
alignment,
|
alignment,
|
||||||
app.scene,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const updatedElementsMap = arrayToMap(updatedElements);
|
const updatedElementsMap = arrayToMap(updatedElements);
|
||||||
@@ -63,8 +64,7 @@ export const actionAlignTop = register({
|
|||||||
label: "labels.alignTop",
|
label: "labels.alignTop",
|
||||||
icon: AlignTopIcon,
|
icon: AlignTopIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: (elements, appState, appProps, app) =>
|
predicate: alignActionsPredicate,
|
||||||
alignActionsPredicate(appState, app),
|
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@@ -79,7 +79,7 @@ export const actionAlignTop = register({
|
|||||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
|
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
hidden={!alignActionsPredicate(appState, app)}
|
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||||
type="button"
|
type="button"
|
||||||
icon={AlignTopIcon}
|
icon={AlignTopIcon}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
@@ -97,8 +97,7 @@ export const actionAlignBottom = register({
|
|||||||
label: "labels.alignBottom",
|
label: "labels.alignBottom",
|
||||||
icon: AlignBottomIcon,
|
icon: AlignBottomIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: (elements, appState, appProps, app) =>
|
predicate: alignActionsPredicate,
|
||||||
alignActionsPredicate(appState, app),
|
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@@ -113,7 +112,7 @@ export const actionAlignBottom = register({
|
|||||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
|
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
hidden={!alignActionsPredicate(appState, app)}
|
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||||
type="button"
|
type="button"
|
||||||
icon={AlignBottomIcon}
|
icon={AlignBottomIcon}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
@@ -131,8 +130,7 @@ export const actionAlignLeft = register({
|
|||||||
label: "labels.alignLeft",
|
label: "labels.alignLeft",
|
||||||
icon: AlignLeftIcon,
|
icon: AlignLeftIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: (elements, appState, appProps, app) =>
|
predicate: alignActionsPredicate,
|
||||||
alignActionsPredicate(appState, app),
|
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@@ -147,7 +145,7 @@ export const actionAlignLeft = register({
|
|||||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
|
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
hidden={!alignActionsPredicate(appState, app)}
|
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||||
type="button"
|
type="button"
|
||||||
icon={AlignLeftIcon}
|
icon={AlignLeftIcon}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
@@ -165,8 +163,7 @@ export const actionAlignRight = register({
|
|||||||
label: "labels.alignRight",
|
label: "labels.alignRight",
|
||||||
icon: AlignRightIcon,
|
icon: AlignRightIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: (elements, appState, appProps, app) =>
|
predicate: alignActionsPredicate,
|
||||||
alignActionsPredicate(appState, app),
|
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@@ -181,7 +178,7 @@ export const actionAlignRight = register({
|
|||||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
|
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
hidden={!alignActionsPredicate(appState, app)}
|
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||||
type="button"
|
type="button"
|
||||||
icon={AlignRightIcon}
|
icon={AlignRightIcon}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
@@ -199,8 +196,7 @@ export const actionAlignVerticallyCentered = register({
|
|||||||
label: "labels.centerVertically",
|
label: "labels.centerVertically",
|
||||||
icon: CenterVerticallyIcon,
|
icon: CenterVerticallyIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: (elements, appState, appProps, app) =>
|
predicate: alignActionsPredicate,
|
||||||
alignActionsPredicate(appState, app),
|
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@@ -213,7 +209,7 @@ export const actionAlignVerticallyCentered = register({
|
|||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
hidden={!alignActionsPredicate(appState, app)}
|
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||||
type="button"
|
type="button"
|
||||||
icon={CenterVerticallyIcon}
|
icon={CenterVerticallyIcon}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
@@ -229,8 +225,7 @@ export const actionAlignHorizontallyCentered = register({
|
|||||||
label: "labels.centerHorizontally",
|
label: "labels.centerHorizontally",
|
||||||
icon: CenterHorizontallyIcon,
|
icon: CenterHorizontallyIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: (elements, appState, appProps, app) =>
|
predicate: alignActionsPredicate,
|
||||||
alignActionsPredicate(appState, app),
|
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@@ -243,7 +238,7 @@ export const actionAlignHorizontallyCentered = register({
|
|||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
hidden={!alignActionsPredicate(appState, app)}
|
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||||
type="button"
|
type="button"
|
||||||
icon={CenterHorizontallyIcon}
|
icon={CenterHorizontallyIcon}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
computeBoundTextPosition,
|
computeBoundTextPosition,
|
||||||
computeContainerDimensionForBoundText,
|
computeContainerDimensionForBoundText,
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
|
measureText,
|
||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
import {
|
import {
|
||||||
@@ -34,7 +35,6 @@ import { arrayToMap, getFontString } from "../utils";
|
|||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { syncMovedIndices } from "../fractionalIndex";
|
import { syncMovedIndices } from "../fractionalIndex";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
import { measureText } from "../element/textMeasurements";
|
|
||||||
|
|
||||||
export const actionUnbindText = register({
|
export const actionUnbindText = register({
|
||||||
name: "unbindText",
|
name: "unbindText",
|
||||||
|
|||||||
@@ -1,211 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Excalidraw, mutateElement } from "../index";
|
|
||||||
import { act, assertElements, render } from "../tests/test-utils";
|
|
||||||
import { API } from "../tests/helpers/api";
|
|
||||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
|
||||||
|
|
||||||
const { h } = window;
|
|
||||||
|
|
||||||
describe("deleting selected elements when frame selected should keep children + select them", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
await render(<Excalidraw />);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("frame only", async () => {
|
|
||||||
const f1 = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const r1 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
frameId: f1.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([f1, r1]);
|
|
||||||
|
|
||||||
API.setSelectedElements([f1]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDeleteSelected);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: f1.id, isDeleted: true },
|
|
||||||
{ id: r1.id, isDeleted: false, selected: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("frame + text container (text's frameId set)", async () => {
|
|
||||||
const f1 = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const r1 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
frameId: f1.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const t1 = API.createElement({
|
|
||||||
type: "text",
|
|
||||||
width: 200,
|
|
||||||
height: 100,
|
|
||||||
fontSize: 20,
|
|
||||||
containerId: r1.id,
|
|
||||||
frameId: f1.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
mutateElement(r1, {
|
|
||||||
boundElements: [{ type: "text", id: t1.id }],
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([f1, r1, t1]);
|
|
||||||
|
|
||||||
API.setSelectedElements([f1]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDeleteSelected);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: f1.id, isDeleted: true },
|
|
||||||
{ id: r1.id, isDeleted: false, selected: true },
|
|
||||||
{ id: t1.id, isDeleted: false },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("frame + text container (text's frameId not set)", async () => {
|
|
||||||
const f1 = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const r1 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
frameId: f1.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const t1 = API.createElement({
|
|
||||||
type: "text",
|
|
||||||
width: 200,
|
|
||||||
height: 100,
|
|
||||||
fontSize: 20,
|
|
||||||
containerId: r1.id,
|
|
||||||
frameId: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
mutateElement(r1, {
|
|
||||||
boundElements: [{ type: "text", id: t1.id }],
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([f1, r1, t1]);
|
|
||||||
|
|
||||||
API.setSelectedElements([f1]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDeleteSelected);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: f1.id, isDeleted: true },
|
|
||||||
{ id: r1.id, isDeleted: false, selected: true },
|
|
||||||
{ id: t1.id, isDeleted: false },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("frame + text container (text selected too)", async () => {
|
|
||||||
const f1 = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const r1 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
frameId: f1.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const t1 = API.createElement({
|
|
||||||
type: "text",
|
|
||||||
width: 200,
|
|
||||||
height: 100,
|
|
||||||
fontSize: 20,
|
|
||||||
containerId: r1.id,
|
|
||||||
frameId: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
mutateElement(r1, {
|
|
||||||
boundElements: [{ type: "text", id: t1.id }],
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([f1, r1, t1]);
|
|
||||||
|
|
||||||
API.setSelectedElements([f1, t1]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDeleteSelected);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: f1.id, isDeleted: true },
|
|
||||||
{ id: r1.id, isDeleted: false, selected: true },
|
|
||||||
{ id: t1.id, isDeleted: false },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("frame + labeled arrow", async () => {
|
|
||||||
const f1 = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const a1 = API.createElement({
|
|
||||||
type: "arrow",
|
|
||||||
frameId: f1.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const t1 = API.createElement({
|
|
||||||
type: "text",
|
|
||||||
width: 200,
|
|
||||||
height: 100,
|
|
||||||
fontSize: 20,
|
|
||||||
containerId: a1.id,
|
|
||||||
frameId: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
mutateElement(a1, {
|
|
||||||
boundElements: [{ type: "text", id: t1.id }],
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([f1, a1, t1]);
|
|
||||||
|
|
||||||
API.setSelectedElements([f1, t1]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDeleteSelected);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: f1.id, isDeleted: true },
|
|
||||||
{ id: a1.id, isDeleted: false, selected: true },
|
|
||||||
{ id: t1.id, isDeleted: false },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("frame + children selected", async () => {
|
|
||||||
const f1 = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
const r1 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
frameId: f1.id,
|
|
||||||
});
|
|
||||||
API.setElements([f1, r1]);
|
|
||||||
|
|
||||||
API.setSelectedElements([f1, r1]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDeleteSelected);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: f1.id, isDeleted: true },
|
|
||||||
{ id: r1.id, isDeleted: false, selected: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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, selectGroupsForSelectedElements } from "../groups";
|
import { getElementsInGroup } 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,14 @@ 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 { getContainerElement } from "../element/textElement";
|
import { mutateElbowArrow } from "../element/routing";
|
||||||
import { getFrameChildren } from "../frame";
|
|
||||||
|
|
||||||
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,141 +33,48 @@ const deleteSelectedElements = (
|
|||||||
).map((el) => el.id),
|
).map((el) => el.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedElementIds: Record<ExcalidrawElement["id"], true> = {};
|
return {
|
||||||
|
elements: elements.map((el) => {
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
if (appState.selectedElementIds[el.id]) {
|
||||||
|
if (el.boundElements) {
|
||||||
const processedElements = new Set<ExcalidrawElement["id"]>();
|
el.boundElements.forEach((candidate) => {
|
||||||
|
const bound = app.scene
|
||||||
for (const frameId of framesToBeDeleted) {
|
.getNonDeletedElementsMap()
|
||||||
const frameChildren = getFrameChildren(elements, frameId);
|
.get(candidate.id);
|
||||||
for (const el of frameChildren) {
|
if (bound && isElbowArrow(bound)) {
|
||||||
if (processedElements.has(el.id)) {
|
mutateElement(bound, {
|
||||||
continue;
|
startBinding:
|
||||||
}
|
el.id === bound.startBinding?.elementId
|
||||||
|
? null
|
||||||
if (isBoundToContainer(el)) {
|
: bound.startBinding,
|
||||||
const containerElement = getContainerElement(el, elementsMap);
|
endBinding:
|
||||||
if (containerElement) {
|
el.id === bound.endBinding?.elementId
|
||||||
selectedElementIds[containerElement.id] = true;
|
? null
|
||||||
|
: bound.endBinding,
|
||||||
|
});
|
||||||
|
mutateElbowArrow(bound, elementsMap, bound.points);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
return newElementWith(el, { isDeleted: true });
|
||||||
selectedElementIds[el.id] = true;
|
|
||||||
}
|
}
|
||||||
processedElements.add(el.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let shouldSelectEditingGroup = true;
|
|
||||||
|
|
||||||
const nextElements = elements.map((el) => {
|
|
||||||
if (appState.selectedElementIds[el.id]) {
|
|
||||||
const boundElement = isBoundToContainer(el)
|
|
||||||
? getContainerElement(el, elementsMap)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
|
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
|
||||||
shouldSelectEditingGroup = false;
|
return newElementWith(el, { isDeleted: true });
|
||||||
selectedElementIds[el.id] = true;
|
|
||||||
return el;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
boundElement?.frameId &&
|
isBoundToContainer(el) &&
|
||||||
framesToBeDeleted.has(boundElement?.frameId)
|
appState.selectedElementIds[el.containerId]
|
||||||
) {
|
) {
|
||||||
return el;
|
return newElementWith(el, { isDeleted: true });
|
||||||
}
|
}
|
||||||
|
return el;
|
||||||
if (el.boundElements) {
|
}),
|
||||||
el.boundElements.forEach((candidate) => {
|
|
||||||
const bound = app.scene.getNonDeletedElementsMap().get(candidate.id);
|
|
||||||
if (bound && isElbowArrow(bound)) {
|
|
||||||
mutateElement(bound, {
|
|
||||||
startBinding:
|
|
||||||
el.id === bound.startBinding?.elementId
|
|
||||||
? null
|
|
||||||
: bound.startBinding,
|
|
||||||
endBinding:
|
|
||||||
el.id === bound.endBinding?.elementId ? null : bound.endBinding,
|
|
||||||
});
|
|
||||||
mutateElement(bound, { points: bound.points });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return newElementWith(el, { isDeleted: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// if deleting a frame, remove the children from it and select them
|
|
||||||
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
|
|
||||||
shouldSelectEditingGroup = false;
|
|
||||||
if (!isBoundToContainer(el)) {
|
|
||||||
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 {
|
|
||||||
elements: nextElements,
|
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
...selectGroupsForSelectedElements(
|
selectedElementIds: {},
|
||||||
{
|
selectedGroupIds: {},
|
||||||
selectedElementIds,
|
|
||||||
editingGroupId: nextEditingGroupId,
|
|
||||||
},
|
|
||||||
nextElements,
|
|
||||||
appState,
|
|
||||||
null,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -250,7 +157,12 @@ export const actionDeleteSelected = register({
|
|||||||
: endBindingElement,
|
: endBindingElement,
|
||||||
};
|
};
|
||||||
|
|
||||||
LinearElementEditor.deletePoints(element, selectedPointsIndices);
|
LinearElementEditor.deletePoints(
|
||||||
|
element,
|
||||||
|
selectedPointsIndices,
|
||||||
|
elementsMap,
|
||||||
|
appState.zoom,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
@@ -268,13 +180,11 @@ export const actionDeleteSelected = register({
|
|||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let { elements: nextElements, appState: nextAppState } =
|
let { elements: nextElements, appState: nextAppState } =
|
||||||
deleteSelectedElements(elements, appState, app);
|
deleteSelectedElements(elements, appState, app);
|
||||||
|
|
||||||
fixBindingsAfterDeletion(
|
fixBindingsAfterDeletion(
|
||||||
nextElements,
|
nextElements,
|
||||||
nextElements.filter((el) => el.isDeleted),
|
elements.filter(({ id }) => appState.selectedElementIds[id]),
|
||||||
);
|
);
|
||||||
|
|
||||||
nextAppState = handleGroupEditingState(nextAppState, nextElements);
|
nextAppState = handleGroupEditingState(nextAppState, nextElements);
|
||||||
|
|||||||
@@ -1,530 +0,0 @@
|
|||||||
import { Excalidraw } from "../index";
|
|
||||||
import {
|
|
||||||
act,
|
|
||||||
assertElements,
|
|
||||||
getCloneByOrigId,
|
|
||||||
render,
|
|
||||||
} from "../tests/test-utils";
|
|
||||||
import { API } from "../tests/helpers/api";
|
|
||||||
import { actionDuplicateSelection } from "./actionDuplicateSelection";
|
|
||||||
import React from "react";
|
|
||||||
import { ORIG_ID } from "../constants";
|
|
||||||
|
|
||||||
const { h } = window;
|
|
||||||
|
|
||||||
describe("actionDuplicateSelection", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
await render(<Excalidraw />);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("duplicating frames", () => {
|
|
||||||
it("frame selected only", async () => {
|
|
||||||
const frame = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const rectangle = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
frameId: frame.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([frame, rectangle]);
|
|
||||||
API.setSelectedElements([frame]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: frame.id },
|
|
||||||
{ id: rectangle.id, frameId: frame.id },
|
|
||||||
{ [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id },
|
|
||||||
{ [ORIG_ID]: frame.id, selected: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("frame selected only (with text container)", async () => {
|
|
||||||
const frame = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
|
|
||||||
|
|
||||||
API.setElements([frame, rectangle, text]);
|
|
||||||
API.setSelectedElements([frame]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: frame.id },
|
|
||||||
{ id: rectangle.id, frameId: frame.id },
|
|
||||||
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
|
|
||||||
{ [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id },
|
|
||||||
{
|
|
||||||
[ORIG_ID]: text.id,
|
|
||||||
containerId: getCloneByOrigId(rectangle.id)?.id,
|
|
||||||
frameId: getCloneByOrigId(frame.id)?.id,
|
|
||||||
},
|
|
||||||
{ [ORIG_ID]: frame.id, selected: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("frame + text container selected (order A)", async () => {
|
|
||||||
const frame = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
|
|
||||||
|
|
||||||
API.setElements([frame, rectangle, text]);
|
|
||||||
API.setSelectedElements([frame, rectangle]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: frame.id },
|
|
||||||
{ id: rectangle.id, frameId: frame.id },
|
|
||||||
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
|
|
||||||
{
|
|
||||||
[ORIG_ID]: rectangle.id,
|
|
||||||
frameId: getCloneByOrigId(frame.id)?.id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
[ORIG_ID]: text.id,
|
|
||||||
containerId: getCloneByOrigId(rectangle.id)?.id,
|
|
||||||
frameId: getCloneByOrigId(frame.id)?.id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
[ORIG_ID]: frame.id,
|
|
||||||
selected: true,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("frame + text container selected (order B)", async () => {
|
|
||||||
const frame = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
|
|
||||||
|
|
||||||
API.setElements([text, rectangle, frame]);
|
|
||||||
API.setSelectedElements([rectangle, frame]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: rectangle.id, frameId: frame.id },
|
|
||||||
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
|
|
||||||
{ id: frame.id },
|
|
||||||
{
|
|
||||||
type: "rectangle",
|
|
||||||
[ORIG_ID]: `${rectangle.id}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
[ORIG_ID]: `${text.id}`,
|
|
||||||
type: "text",
|
|
||||||
containerId: getCloneByOrigId(rectangle.id)?.id,
|
|
||||||
frameId: getCloneByOrigId(frame.id)?.id,
|
|
||||||
},
|
|
||||||
{ [ORIG_ID]: `${frame.id}`, type: "frame", selected: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("duplicating frame children", () => {
|
|
||||||
it("frame child selected", () => {
|
|
||||||
const frame = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const rectangle = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
frameId: frame.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([frame, rectangle]);
|
|
||||||
API.setSelectedElements([rectangle]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: frame.id },
|
|
||||||
{ id: rectangle.id, frameId: frame.id },
|
|
||||||
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("frame text container selected (rectangle selected)", () => {
|
|
||||||
const frame = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
|
|
||||||
|
|
||||||
API.setElements([frame, rectangle, text]);
|
|
||||||
API.setSelectedElements([rectangle]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: frame.id },
|
|
||||||
{ id: rectangle.id, frameId: frame.id },
|
|
||||||
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
|
|
||||||
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
|
|
||||||
{
|
|
||||||
[ORIG_ID]: text.id,
|
|
||||||
containerId: getCloneByOrigId(rectangle.id).id,
|
|
||||||
frameId: frame.id,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("frame bound text selected (container not selected)", () => {
|
|
||||||
const frame = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
|
|
||||||
|
|
||||||
API.setElements([frame, rectangle, text]);
|
|
||||||
API.setSelectedElements([text]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: frame.id },
|
|
||||||
{ id: rectangle.id, frameId: frame.id },
|
|
||||||
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
|
|
||||||
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
|
|
||||||
{
|
|
||||||
[ORIG_ID]: text.id,
|
|
||||||
containerId: getCloneByOrigId(rectangle.id).id,
|
|
||||||
frameId: frame.id,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("frame text container selected (text not exists)", () => {
|
|
||||||
const frame = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [rectangle] = API.createTextContainer({ frameId: frame.id });
|
|
||||||
|
|
||||||
API.setElements([frame, rectangle]);
|
|
||||||
API.setSelectedElements([rectangle]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: frame.id },
|
|
||||||
{ id: rectangle.id, frameId: frame.id },
|
|
||||||
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// shouldn't happen
|
|
||||||
it("frame bound text selected (container not exists)", () => {
|
|
||||||
const frame = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [, text] = API.createTextContainer({ frameId: frame.id });
|
|
||||||
|
|
||||||
API.setElements([frame, text]);
|
|
||||||
API.setSelectedElements([text]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: frame.id },
|
|
||||||
{ id: text.id, frameId: frame.id },
|
|
||||||
{ [ORIG_ID]: text.id, frameId: frame.id },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("frame bound container selected (text has no frameId)", () => {
|
|
||||||
const frame = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [rectangle, text] = API.createTextContainer({
|
|
||||||
frameId: frame.id,
|
|
||||||
label: { frameId: null },
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([frame, rectangle, text]);
|
|
||||||
API.setSelectedElements([rectangle]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: frame.id },
|
|
||||||
{ id: rectangle.id, frameId: frame.id },
|
|
||||||
{ id: text.id, containerId: rectangle.id },
|
|
||||||
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
|
|
||||||
{
|
|
||||||
[ORIG_ID]: text.id,
|
|
||||||
containerId: getCloneByOrigId(rectangle.id).id,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("duplicating multiple frames", () => {
|
|
||||||
it("multiple frames selected (no children)", () => {
|
|
||||||
const frame1 = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const rect1 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
frameId: frame1.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const frame2 = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const rect2 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
frameId: frame2.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const ellipse = API.createElement({
|
|
||||||
type: "ellipse",
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([rect1, frame1, ellipse, rect2, frame2]);
|
|
||||||
API.setSelectedElements([frame1, frame2]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: rect1.id, frameId: frame1.id },
|
|
||||||
{ id: frame1.id },
|
|
||||||
{ [ORIG_ID]: rect1.id, frameId: getCloneByOrigId(frame1.id)?.id },
|
|
||||||
{ [ORIG_ID]: frame1.id, selected: true },
|
|
||||||
{ id: ellipse.id },
|
|
||||||
{ id: rect2.id, frameId: frame2.id },
|
|
||||||
{ id: frame2.id },
|
|
||||||
{ [ORIG_ID]: rect2.id, frameId: getCloneByOrigId(frame2.id)?.id },
|
|
||||||
{ [ORIG_ID]: frame2.id, selected: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("multiple frames selected (no children) + unrelated element", () => {
|
|
||||||
const frame1 = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const rect1 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
frameId: frame1.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const frame2 = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
});
|
|
||||||
|
|
||||||
const rect2 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
frameId: frame2.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const ellipse = API.createElement({
|
|
||||||
type: "ellipse",
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([rect1, frame1, ellipse, rect2, frame2]);
|
|
||||||
API.setSelectedElements([frame1, ellipse, frame2]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: rect1.id, frameId: frame1.id },
|
|
||||||
{ id: frame1.id },
|
|
||||||
{ [ORIG_ID]: rect1.id, frameId: getCloneByOrigId(frame1.id)?.id },
|
|
||||||
{ [ORIG_ID]: frame1.id, selected: true },
|
|
||||||
{ id: ellipse.id },
|
|
||||||
{ [ORIG_ID]: ellipse.id, selected: true },
|
|
||||||
{ id: rect2.id, frameId: frame2.id },
|
|
||||||
{ id: frame2.id },
|
|
||||||
{ [ORIG_ID]: rect2.id, frameId: getCloneByOrigId(frame2.id)?.id },
|
|
||||||
{ [ORIG_ID]: frame2.id, selected: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("duplicating containers/bound elements", () => {
|
|
||||||
it("labeled arrow (arrow selected)", () => {
|
|
||||||
const [arrow, text] = API.createLabeledArrow();
|
|
||||||
|
|
||||||
API.setElements([arrow, text]);
|
|
||||||
API.setSelectedElements([arrow]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: arrow.id },
|
|
||||||
{ id: text.id, containerId: arrow.id },
|
|
||||||
{ [ORIG_ID]: arrow.id, selected: true },
|
|
||||||
{ [ORIG_ID]: text.id, containerId: getCloneByOrigId(arrow.id)?.id },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// shouldn't happen
|
|
||||||
it("labeled arrow (text selected)", () => {
|
|
||||||
const [arrow, text] = API.createLabeledArrow();
|
|
||||||
|
|
||||||
API.setElements([arrow, text]);
|
|
||||||
API.setSelectedElements([text]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: arrow.id },
|
|
||||||
{ id: text.id, containerId: arrow.id },
|
|
||||||
{ [ORIG_ID]: arrow.id, selected: true },
|
|
||||||
{ [ORIG_ID]: text.id, containerId: getCloneByOrigId(arrow.id)?.id },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("duplicating groups", () => {
|
|
||||||
it("duplicate group containing frame (children don't have groupIds set)", () => {
|
|
||||||
const frame = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
groupIds: ["A"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const [rectangle, text] = API.createTextContainer({
|
|
||||||
frameId: frame.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const ellipse = API.createElement({
|
|
||||||
type: "ellipse",
|
|
||||||
groupIds: ["A"],
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([rectangle, text, frame, ellipse]);
|
|
||||||
API.setSelectedElements([frame, ellipse]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: rectangle.id, frameId: frame.id },
|
|
||||||
{ id: text.id, frameId: frame.id },
|
|
||||||
{ id: frame.id },
|
|
||||||
{ id: ellipse.id },
|
|
||||||
{ [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id },
|
|
||||||
{ [ORIG_ID]: text.id, frameId: getCloneByOrigId(frame.id)?.id },
|
|
||||||
{ [ORIG_ID]: frame.id, selected: true },
|
|
||||||
{ [ORIG_ID]: ellipse.id, selected: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("duplicate group containing frame (children have groupIds)", () => {
|
|
||||||
const frame = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
groupIds: ["A"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const [rectangle, text] = API.createTextContainer({
|
|
||||||
frameId: frame.id,
|
|
||||||
groupIds: ["A"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const ellipse = API.createElement({
|
|
||||||
type: "ellipse",
|
|
||||||
groupIds: ["A"],
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([rectangle, text, frame, ellipse]);
|
|
||||||
API.setSelectedElements([frame, ellipse]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: rectangle.id, frameId: frame.id },
|
|
||||||
{ id: text.id, frameId: frame.id },
|
|
||||||
{ id: frame.id },
|
|
||||||
{ id: ellipse.id },
|
|
||||||
{
|
|
||||||
[ORIG_ID]: rectangle.id,
|
|
||||||
frameId: getCloneByOrigId(frame.id)?.id,
|
|
||||||
// FIXME shouldn't be selected (in selectGroupsForSelectedElements)
|
|
||||||
selected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
[ORIG_ID]: text.id,
|
|
||||||
frameId: getCloneByOrigId(frame.id)?.id,
|
|
||||||
// FIXME shouldn't be selected (in selectGroupsForSelectedElements)
|
|
||||||
selected: true,
|
|
||||||
},
|
|
||||||
{ [ORIG_ID]: frame.id, selected: true },
|
|
||||||
{ [ORIG_ID]: ellipse.id, selected: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("duplicating element nested in group", () => {
|
|
||||||
const ellipse = API.createElement({
|
|
||||||
type: "ellipse",
|
|
||||||
groupIds: ["B"],
|
|
||||||
});
|
|
||||||
const rect1 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
groupIds: ["A", "B"],
|
|
||||||
});
|
|
||||||
const rect2 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
groupIds: ["A", "B"],
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([ellipse, rect1, rect2]);
|
|
||||||
API.setSelectedElements([ellipse], "B");
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: ellipse.id },
|
|
||||||
{ [ORIG_ID]: ellipse.id, groupIds: ["B"], selected: true },
|
|
||||||
{ id: rect1.id, groupIds: ["A", "B"] },
|
|
||||||
{ id: rect2.id, groupIds: ["A", "B"] },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -5,13 +5,7 @@ import { duplicateElement, getNonDeletedElements } from "../element";
|
|||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import {
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
arrayToMap,
|
|
||||||
castArray,
|
|
||||||
findLastIndex,
|
|
||||||
getShortcutKey,
|
|
||||||
invariant,
|
|
||||||
} from "../utils";
|
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import {
|
import {
|
||||||
selectGroupsForSelectedElements,
|
selectGroupsForSelectedElements,
|
||||||
@@ -25,13 +19,8 @@ import { DEFAULT_GRID_SIZE } from "../constants";
|
|||||||
import {
|
import {
|
||||||
bindTextToShapeAfterDuplication,
|
bindTextToShapeAfterDuplication,
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
getContainerElement,
|
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
import {
|
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
|
||||||
hasBoundTextElement,
|
|
||||||
isBoundToContainer,
|
|
||||||
isFrameLikeElement,
|
|
||||||
} from "../element/typeChecks";
|
|
||||||
import { normalizeElementOrder } from "../element/sortElements";
|
import { normalizeElementOrder } from "../element/sortElements";
|
||||||
import { DuplicateIcon } from "../components/icons";
|
import { DuplicateIcon } from "../components/icons";
|
||||||
import {
|
import {
|
||||||
@@ -42,6 +31,7 @@ import {
|
|||||||
excludeElementsInFramesFromSelection,
|
excludeElementsInFramesFromSelection,
|
||||||
getSelectedElements,
|
getSelectedElements,
|
||||||
} from "../scene/selection";
|
} from "../scene/selection";
|
||||||
|
import { syncMovedIndices } from "../fractionalIndex";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionDuplicateSelection = register({
|
export const actionDuplicateSelection = register({
|
||||||
@@ -69,20 +59,8 @@ export const actionDuplicateSelection = register({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextState = duplicateElements(elements, appState);
|
|
||||||
|
|
||||||
if (app.props.onDuplicate && nextState.elements) {
|
|
||||||
const mappedElements = app.props.onDuplicate(
|
|
||||||
nextState.elements,
|
|
||||||
elements,
|
|
||||||
);
|
|
||||||
if (mappedElements) {
|
|
||||||
nextState.elements = mappedElements;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...nextState,
|
...duplicateElements(elements, appState),
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -104,69 +82,37 @@ export const actionDuplicateSelection = register({
|
|||||||
const duplicateElements = (
|
const duplicateElements = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
): Partial<Exclude<ActionResult, false>> => {
|
): Partial<ActionResult> => {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// step (1)
|
||||||
|
|
||||||
|
const sortedElements = normalizeElementOrder(elements);
|
||||||
const groupIdMap = new Map();
|
const groupIdMap = new Map();
|
||||||
const newElements: ExcalidrawElement[] = [];
|
const newElements: ExcalidrawElement[] = [];
|
||||||
const oldElements: ExcalidrawElement[] = [];
|
const oldElements: ExcalidrawElement[] = [];
|
||||||
const oldIdToDuplicatedId = new Map();
|
const oldIdToDuplicatedId = new Map();
|
||||||
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
|
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
|
||||||
|
|
||||||
const elementsMap = arrayToMap(elements);
|
const duplicateAndOffsetElement = (element: ExcalidrawElement) => {
|
||||||
|
const newElement = duplicateElement(
|
||||||
const duplicateAndOffsetElement = <
|
appState.editingGroupId,
|
||||||
T extends ExcalidrawElement | ExcalidrawElement[],
|
groupIdMap,
|
||||||
>(
|
element,
|
||||||
element: T,
|
{
|
||||||
): T extends ExcalidrawElement[]
|
x: element.x + DEFAULT_GRID_SIZE / 2,
|
||||||
? ExcalidrawElement[]
|
y: element.y + DEFAULT_GRID_SIZE / 2,
|
||||||
: ExcalidrawElement | null => {
|
|
||||||
const elements = castArray(element);
|
|
||||||
|
|
||||||
const _newElements = elements.reduce(
|
|
||||||
(acc: ExcalidrawElement[], element) => {
|
|
||||||
if (processedIds.has(element.id)) {
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
|
|
||||||
processedIds.set(element.id, true);
|
|
||||||
|
|
||||||
const newElement = duplicateElement(
|
|
||||||
appState.editingGroupId,
|
|
||||||
groupIdMap,
|
|
||||||
element,
|
|
||||||
{
|
|
||||||
x: element.x + DEFAULT_GRID_SIZE / 2,
|
|
||||||
y: element.y + DEFAULT_GRID_SIZE / 2,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
processedIds.set(newElement.id, true);
|
|
||||||
|
|
||||||
duplicatedElementsMap.set(newElement.id, newElement);
|
|
||||||
oldIdToDuplicatedId.set(element.id, newElement.id);
|
|
||||||
|
|
||||||
oldElements.push(element);
|
|
||||||
newElements.push(newElement);
|
|
||||||
|
|
||||||
acc.push(newElement);
|
|
||||||
return acc;
|
|
||||||
},
|
},
|
||||||
[],
|
|
||||||
);
|
);
|
||||||
|
duplicatedElementsMap.set(newElement.id, newElement);
|
||||||
return (
|
oldIdToDuplicatedId.set(element.id, newElement.id);
|
||||||
Array.isArray(element) ? _newElements : _newElements[0] || null
|
oldElements.push(element);
|
||||||
) as T extends ExcalidrawElement[]
|
newElements.push(newElement);
|
||||||
? ExcalidrawElement[]
|
return newElement;
|
||||||
: ExcalidrawElement | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
elements = normalizeElementOrder(elements);
|
|
||||||
|
|
||||||
const idsOfElementsToDuplicate = arrayToMap(
|
const idsOfElementsToDuplicate = arrayToMap(
|
||||||
getSelectedElements(elements, appState, {
|
getSelectedElements(sortedElements, appState, {
|
||||||
includeBoundTextElement: true,
|
includeBoundTextElement: true,
|
||||||
includeElementsInFrames: true,
|
includeElementsInFrames: true,
|
||||||
}),
|
}),
|
||||||
@@ -184,134 +130,123 @@ const duplicateElements = (
|
|||||||
// loop over them.
|
// loop over them.
|
||||||
const processedIds = new Map<ExcalidrawElement["id"], true>();
|
const processedIds = new Map<ExcalidrawElement["id"], true>();
|
||||||
|
|
||||||
const elementsWithClones: ExcalidrawElement[] = elements.slice();
|
const markAsProcessed = (elements: ExcalidrawElement[]) => {
|
||||||
|
for (const element of elements) {
|
||||||
const insertAfterIndex = (
|
processedIds.set(element.id, true);
|
||||||
index: number,
|
|
||||||
elements: ExcalidrawElement | null | ExcalidrawElement[],
|
|
||||||
) => {
|
|
||||||
invariant(index !== -1, "targetIndex === -1 ");
|
|
||||||
|
|
||||||
if (!Array.isArray(elements) && !elements) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
return elements;
|
||||||
elementsWithClones.splice(index + 1, 0, ...castArray(elements));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const frameIdsToDuplicate = new Set(
|
const elementsWithClones: ExcalidrawElement[] = [];
|
||||||
elements
|
|
||||||
.filter(
|
|
||||||
(el) => idsOfElementsToDuplicate.has(el.id) && isFrameLikeElement(el),
|
|
||||||
)
|
|
||||||
.map((el) => el.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const element of elements) {
|
let index = -1;
|
||||||
if (processedIds.has(element.id)) {
|
|
||||||
|
while (++index < sortedElements.length) {
|
||||||
|
const element = sortedElements[index];
|
||||||
|
|
||||||
|
if (processedIds.get(element.id)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!idsOfElementsToDuplicate.has(element.id)) {
|
const boundTextElement = getBoundTextElement(element, arrayToMap(elements));
|
||||||
continue;
|
const isElementAFrameLike = isFrameLikeElement(element);
|
||||||
}
|
|
||||||
|
|
||||||
// groups
|
if (idsOfElementsToDuplicate.get(element.id)) {
|
||||||
// -------------------------------------------------------------------------
|
// if a group or a container/bound-text or frame, duplicate atomically
|
||||||
|
if (element.groupIds.length || boundTextElement || isElementAFrameLike) {
|
||||||
|
const groupId = getSelectedGroupForElement(appState, element);
|
||||||
|
if (groupId) {
|
||||||
|
// TODO:
|
||||||
|
// remove `.flatMap...`
|
||||||
|
// if the elements in a frame are grouped when the frame is grouped
|
||||||
|
const groupElements = getElementsInGroup(
|
||||||
|
sortedElements,
|
||||||
|
groupId,
|
||||||
|
).flatMap((element) =>
|
||||||
|
isFrameLikeElement(element)
|
||||||
|
? [...getFrameChildren(elements, element.id), element]
|
||||||
|
: [element],
|
||||||
|
);
|
||||||
|
|
||||||
const groupId = getSelectedGroupForElement(appState, element);
|
elementsWithClones.push(
|
||||||
if (groupId) {
|
...markAsProcessed([
|
||||||
const groupElements = getElementsInGroup(elements, groupId).flatMap(
|
...groupElements,
|
||||||
(element) =>
|
...groupElements.map((element) =>
|
||||||
isFrameLikeElement(element)
|
duplicateAndOffsetElement(element),
|
||||||
? [...getFrameChildren(elements, element.id), element]
|
),
|
||||||
: [element],
|
]),
|
||||||
);
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (boundTextElement) {
|
||||||
|
elementsWithClones.push(
|
||||||
|
...markAsProcessed([
|
||||||
|
element,
|
||||||
|
boundTextElement,
|
||||||
|
duplicateAndOffsetElement(element),
|
||||||
|
duplicateAndOffsetElement(boundTextElement),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isElementAFrameLike) {
|
||||||
|
const elementsInFrame = getFrameChildren(sortedElements, element.id);
|
||||||
|
|
||||||
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
elementsWithClones.push(
|
||||||
return el.groupIds?.includes(groupId);
|
...markAsProcessed([
|
||||||
});
|
...elementsInFrame,
|
||||||
|
element,
|
||||||
|
...elementsInFrame.map((e) => duplicateAndOffsetElement(e)),
|
||||||
|
duplicateAndOffsetElement(element),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
insertAfterIndex(targetIndex, duplicateAndOffsetElement(groupElements));
|
continue;
|
||||||
continue;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// frame duplication
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
if (element.frameId && frameIdsToDuplicate.has(element.frameId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFrameLikeElement(element)) {
|
|
||||||
const frameId = element.id;
|
|
||||||
|
|
||||||
const frameChildren = getFrameChildren(elements, frameId);
|
|
||||||
|
|
||||||
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
|
||||||
return el.frameId === frameId || el.id === frameId;
|
|
||||||
});
|
|
||||||
|
|
||||||
insertAfterIndex(
|
|
||||||
targetIndex,
|
|
||||||
duplicateAndOffsetElement([...frameChildren, element]),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// text container
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
if (hasBoundTextElement(element)) {
|
|
||||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
|
||||||
|
|
||||||
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
|
||||||
return (
|
|
||||||
el.id === element.id ||
|
|
||||||
("containerId" in el && el.containerId === element.id)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (boundTextElement) {
|
|
||||||
insertAfterIndex(
|
|
||||||
targetIndex,
|
|
||||||
duplicateAndOffsetElement([element, boundTextElement]),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
insertAfterIndex(targetIndex, duplicateAndOffsetElement(element));
|
|
||||||
}
|
}
|
||||||
|
// since elements in frames have a lower z-index than the frame itself,
|
||||||
continue;
|
// they will be looped first and if their frames are selected as well,
|
||||||
}
|
// they will have been copied along with the frame atomically in the
|
||||||
|
// above branch, so we must skip those elements here
|
||||||
if (isBoundToContainer(element)) {
|
//
|
||||||
const container = getContainerElement(element, elementsMap);
|
// now, for elements do not belong any frames or elements whose frames
|
||||||
|
// are selected (or elements that are left out from the above
|
||||||
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
// steps for whatever reason) we (should at least) duplicate them here
|
||||||
return el.id === element.id || el.id === container?.id;
|
if (!element.frameId || !idsOfElementsToDuplicate.has(element.frameId)) {
|
||||||
});
|
elementsWithClones.push(
|
||||||
|
...markAsProcessed([element, duplicateAndOffsetElement(element)]),
|
||||||
if (container) {
|
|
||||||
insertAfterIndex(
|
|
||||||
targetIndex,
|
|
||||||
duplicateAndOffsetElement([container, element]),
|
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
insertAfterIndex(targetIndex, duplicateAndOffsetElement(element));
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
continue;
|
elementsWithClones.push(...markAsProcessed([element]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// default duplication (regular elements)
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
insertAfterIndex(
|
|
||||||
findLastIndex(elementsWithClones, (el) => el.id === element.id),
|
|
||||||
duplicateAndOffsetElement(element),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// step (2)
|
||||||
|
|
||||||
|
// second pass to remove duplicates. We loop from the end as it's likelier
|
||||||
|
// that the last elements are in the correct order (contiguous or otherwise).
|
||||||
|
// Thus we need to reverse as the last step (3).
|
||||||
|
|
||||||
|
const finalElementsReversed: ExcalidrawElement[] = [];
|
||||||
|
|
||||||
|
const finalElementIds = new Map<ExcalidrawElement["id"], true>();
|
||||||
|
index = elementsWithClones.length;
|
||||||
|
|
||||||
|
while (--index >= 0) {
|
||||||
|
const element = elementsWithClones[index];
|
||||||
|
if (!finalElementIds.get(element.id)) {
|
||||||
|
finalElementIds.set(element.id, true);
|
||||||
|
finalElementsReversed.push(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// step (3)
|
||||||
|
const finalElements = syncMovedIndices(
|
||||||
|
finalElementsReversed.reverse(),
|
||||||
|
arrayToMap(newElements),
|
||||||
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
bindTextToShapeAfterDuplication(
|
bindTextToShapeAfterDuplication(
|
||||||
@@ -325,7 +260,7 @@ const duplicateElements = (
|
|||||||
oldIdToDuplicatedId,
|
oldIdToDuplicatedId,
|
||||||
);
|
);
|
||||||
bindElementsToFramesAfterDuplication(
|
bindElementsToFramesAfterDuplication(
|
||||||
elementsWithClones,
|
finalElements,
|
||||||
oldElements,
|
oldElements,
|
||||||
oldIdToDuplicatedId,
|
oldIdToDuplicatedId,
|
||||||
);
|
);
|
||||||
@@ -334,7 +269,7 @@ const duplicateElements = (
|
|||||||
excludeElementsInFramesFromSelection(newElements);
|
excludeElementsInFramesFromSelection(newElements);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements: elementsWithClones,
|
elements: finalElements,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
...selectGroupsForSelectedElements(
|
...selectGroupsForSelectedElements(
|
||||||
@@ -350,7 +285,7 @@ const duplicateElements = (
|
|||||||
{},
|
{},
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
getNonDeletedElements(elementsWithClones),
|
getNonDeletedElements(finalElements),
|
||||||
appState,
|
appState,
|
||||||
null,
|
null,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -49,13 +49,12 @@ 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, -35),
|
pointFrom(-90.9, -35),
|
||||||
pointFrom(-90, 204),
|
pointFrom(-90.9, 204.9),
|
||||||
pointFrom(66, 204),
|
pointFrom(65.1, 204.9),
|
||||||
],
|
],
|
||||||
elbowed: true,
|
elbowed: true,
|
||||||
}),
|
}),
|
||||||
@@ -71,13 +70,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, 0);
|
expect(rec1?.x).toBeCloseTo(100);
|
||||||
expect(rec1.y).toBeCloseTo(100, 0);
|
expect(rec1?.y).toBeCloseTo(100);
|
||||||
|
|
||||||
const rec2 = h.elements.find((el) => el.id === "rec2")!;
|
const rec2 = h.elements.find((el) => el.id === "rec2");
|
||||||
expect(rec2.x).toBeCloseTo(220, 0);
|
expect(rec2?.x).toBeCloseTo(220);
|
||||||
expect(rec2.y).toBeCloseTo(250, 0);
|
expect(rec2?.y).toBeCloseTo(250);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,24 +134,12 @@ const flipElements = (
|
|||||||
|
|
||||||
const { midX, midY } = getCommonBoundingBox(selectedElements);
|
const { midX, midY } = getCommonBoundingBox(selectedElements);
|
||||||
|
|
||||||
resizeMultipleElements(
|
resizeMultipleElements(selectedElements, elementsMap, "nw", app.scene, {
|
||||||
selectedElements,
|
flipByX: flipDirection === "horizontal",
|
||||||
elementsMap,
|
flipByY: flipDirection === "vertical",
|
||||||
"nw",
|
shouldResizeFromCenter: true,
|
||||||
app.scene,
|
shouldMaintainAspectRatio: true,
|
||||||
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),
|
||||||
@@ -193,10 +181,16 @@ const flipElements = (
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
elbowArrows.forEach((element) =>
|
elbowArrows.forEach((element) =>
|
||||||
mutateElement(element, {
|
mutateElbowArrow(
|
||||||
x: element.x + diffX,
|
element,
|
||||||
y: element.y + diffY,
|
elementsMap,
|
||||||
}),
|
element.points,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
informMutation: false,
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import type { ExcalidrawElement } from "../element/types";
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
import { addElementsToFrame, removeAllElementsFromFrame } from "../frame";
|
import { 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,10 +10,6 @@ import { register } from "./register";
|
|||||||
import { isFrameLikeElement } from "../element/typeChecks";
|
import { isFrameLikeElement } from "../element/typeChecks";
|
||||||
import { frameToolIcon } from "../components/icons";
|
import { frameToolIcon } from "../components/icons";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
import { getSelectedElements } from "../scene";
|
|
||||||
import { newFrameElement } from "../element/newElement";
|
|
||||||
import { getElementsInGroup } from "../groups";
|
|
||||||
import { mutateElement } from "../element/mutateElement";
|
|
||||||
|
|
||||||
const isSingleFrameSelected = (
|
const isSingleFrameSelected = (
|
||||||
appState: UIAppState,
|
appState: UIAppState,
|
||||||
@@ -148,67 +144,3 @@ export const actionSetFrameAsActiveTool = register({
|
|||||||
!event.altKey &&
|
!event.altKey &&
|
||||||
event.key.toLocaleLowerCase() === KEYS.F,
|
event.key.toLocaleLowerCase() === KEYS.F,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionWrapSelectionInFrame = register({
|
|
||||||
name: "wrapSelectionInFrame",
|
|
||||||
label: "labels.wrapSelectionInFrame",
|
|
||||||
trackEvent: { category: "element" },
|
|
||||||
predicate: (elements, appState, _, app) => {
|
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
|
||||||
|
|
||||||
return (
|
|
||||||
selectedElements.length > 0 &&
|
|
||||||
!selectedElements.some((element) => isFrameLikeElement(element))
|
|
||||||
);
|
|
||||||
},
|
|
||||||
perform: (elements, appState, _, app) => {
|
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
|
||||||
|
|
||||||
const [x1, y1, x2, y2] = getCommonBounds(
|
|
||||||
selectedElements,
|
|
||||||
app.scene.getNonDeletedElementsMap(),
|
|
||||||
);
|
|
||||||
const PADDING = 16;
|
|
||||||
const frame = newFrameElement({
|
|
||||||
x: x1 - PADDING,
|
|
||||||
y: y1 - PADDING,
|
|
||||||
width: x2 - x1 + PADDING * 2,
|
|
||||||
height: y2 - y1 + PADDING * 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
// for a selected partial group, we want to remove it from the remainder of the group
|
|
||||||
if (appState.editingGroupId) {
|
|
||||||
const elementsInGroup = getElementsInGroup(
|
|
||||||
selectedElements,
|
|
||||||
appState.editingGroupId,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const elementInGroup of elementsInGroup) {
|
|
||||||
const index = elementInGroup.groupIds.indexOf(appState.editingGroupId);
|
|
||||||
|
|
||||||
mutateElement(
|
|
||||||
elementInGroup,
|
|
||||||
{
|
|
||||||
groupIds: elementInGroup.groupIds.slice(0, index),
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextElements = addElementsToFrame(
|
|
||||||
[...app.scene.getElementsIncludingDeleted(), frame],
|
|
||||||
selectedElements,
|
|
||||||
frame,
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
elements: nextElements,
|
|
||||||
appState: {
|
|
||||||
selectedElementIds: { [frame.id]: true },
|
|
||||||
},
|
|
||||||
storeAction: StoreAction.CAPTURE,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -25,10 +25,8 @@ import type {
|
|||||||
import type { AppClassProperties, AppState } from "../types";
|
import type { AppClassProperties, AppState } from "../types";
|
||||||
import { isBoundToContainer } from "../element/typeChecks";
|
import { isBoundToContainer } from "../element/typeChecks";
|
||||||
import {
|
import {
|
||||||
frameAndChildrenSelectedTogether,
|
|
||||||
getElementsInResizingFrame,
|
getElementsInResizingFrame,
|
||||||
getFrameLikeElements,
|
getFrameLikeElements,
|
||||||
getRootElements,
|
|
||||||
groupByFrameLikes,
|
groupByFrameLikes,
|
||||||
removeElementsFromFrame,
|
removeElementsFromFrame,
|
||||||
replaceAllElementsInFrame,
|
replaceAllElementsInFrame,
|
||||||
@@ -62,11 +60,8 @@ const enableActionGroup = (
|
|||||||
selectedElementIds: appState.selectedElementIds,
|
selectedElementIds: appState.selectedElementIds,
|
||||||
includeBoundTextElement: true,
|
includeBoundTextElement: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
selectedElements.length >= 2 &&
|
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
|
||||||
!allElementsInSameGroup(selectedElements) &&
|
|
||||||
!frameAndChildrenSelectedTogether(selectedElements)
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -76,12 +71,10 @@ export const actionGroup = register({
|
|||||||
icon: (appState) => <GroupIcon theme={appState.theme} />,
|
icon: (appState) => <GroupIcon theme={appState.theme} />,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElements = getRootElements(
|
const selectedElements = app.scene.getSelectedElements({
|
||||||
app.scene.getSelectedElements({
|
selectedElementIds: appState.selectedElementIds,
|
||||||
selectedElementIds: appState.selectedElementIds,
|
includeBoundTextElement: true,
|
||||||
includeBoundTextElement: true,
|
});
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (selectedElements.length < 2) {
|
if (selectedElements.length < 2) {
|
||||||
// nothing to group
|
// nothing to group
|
||||||
return { appState, elements, storeAction: StoreAction.NONE };
|
return { appState, elements, storeAction: StoreAction.NONE };
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ import type {
|
|||||||
FontFamilyValues,
|
FontFamilyValues,
|
||||||
TextAlign,
|
TextAlign,
|
||||||
VerticalAlign,
|
VerticalAlign,
|
||||||
NonDeletedSceneElementsMap,
|
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { getLanguage, t } from "../i18n";
|
import { getLanguage, t } from "../i18n";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
@@ -116,12 +115,11 @@ import {
|
|||||||
bindPointToSnapToElementOutline,
|
bindPointToSnapToElementOutline,
|
||||||
calculateFixedPointForElbowArrowBinding,
|
calculateFixedPointForElbowArrowBinding,
|
||||||
getHoveredElementForBinding,
|
getHoveredElementForBinding,
|
||||||
updateBoundElements,
|
|
||||||
} from "../element/binding";
|
} from "../element/binding";
|
||||||
|
import { mutateElbowArrow } from "../element/routing";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import type { LocalPoint } from "../../math";
|
import type { LocalPoint } from "../../math";
|
||||||
import { pointFrom } from "../../math";
|
import { pointFrom, vector } from "../../math";
|
||||||
import { Range } from "../components/Range";
|
|
||||||
|
|
||||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||||
|
|
||||||
@@ -221,47 +219,33 @@ const changeFontSize = (
|
|||||||
) => {
|
) => {
|
||||||
const newFontSizes = new Set<number>();
|
const newFontSizes = new Set<number>();
|
||||||
|
|
||||||
const updatedElements = changeProperty(
|
|
||||||
elements,
|
|
||||||
appState,
|
|
||||||
(oldElement) => {
|
|
||||||
if (isTextElement(oldElement)) {
|
|
||||||
const newFontSize = getNewFontSize(oldElement);
|
|
||||||
newFontSizes.add(newFontSize);
|
|
||||||
|
|
||||||
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
|
|
||||||
fontSize: newFontSize,
|
|
||||||
});
|
|
||||||
redrawTextBoundingBox(
|
|
||||||
newElement,
|
|
||||||
app.scene.getContainerElement(oldElement),
|
|
||||||
app.scene.getNonDeletedElementsMap(),
|
|
||||||
);
|
|
||||||
|
|
||||||
newElement = offsetElementAfterFontResize(oldElement, newElement);
|
|
||||||
|
|
||||||
return newElement;
|
|
||||||
}
|
|
||||||
return oldElement;
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update arrow elements after text elements have been updated
|
|
||||||
const updatedElementsMap = arrayToMap(updatedElements);
|
|
||||||
getSelectedElements(elements, appState, {
|
|
||||||
includeBoundTextElement: true,
|
|
||||||
}).forEach((element) => {
|
|
||||||
if (isTextElement(element)) {
|
|
||||||
updateBoundElements(
|
|
||||||
element,
|
|
||||||
updatedElementsMap as NonDeletedSceneElementsMap,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements: updatedElements,
|
elements: changeProperty(
|
||||||
|
elements,
|
||||||
|
appState,
|
||||||
|
(oldElement) => {
|
||||||
|
if (isTextElement(oldElement)) {
|
||||||
|
const newFontSize = getNewFontSize(oldElement);
|
||||||
|
newFontSizes.add(newFontSize);
|
||||||
|
|
||||||
|
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
|
||||||
|
fontSize: newFontSize,
|
||||||
|
});
|
||||||
|
redrawTextBoundingBox(
|
||||||
|
newElement,
|
||||||
|
app.scene.getContainerElement(oldElement),
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
newElement = offsetElementAfterFontResize(oldElement, newElement);
|
||||||
|
|
||||||
|
return newElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return oldElement;
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
// update state only if we've set all select text elements to
|
// update state only if we've set all select text elements to
|
||||||
@@ -631,12 +615,25 @@ export const actionChangeOpacity = register({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<Range
|
<label className="control-label">
|
||||||
updateData={updateData}
|
{t("labels.opacity")}
|
||||||
elements={elements}
|
<input
|
||||||
appState={appState}
|
type="range"
|
||||||
testId="opacity"
|
min="0"
|
||||||
/>
|
max="100"
|
||||||
|
step="10"
|
||||||
|
onChange={(event) => updateData(+event.target.value)}
|
||||||
|
value={
|
||||||
|
getFormValue(
|
||||||
|
elements,
|
||||||
|
appState,
|
||||||
|
(element) => element.opacity,
|
||||||
|
true,
|
||||||
|
appState.currentItemOpacity,
|
||||||
|
) ?? undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1563,166 +1560,152 @@ 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) => {
|
||||||
const newElements = changeProperty(elements, appState, (el) => {
|
return {
|
||||||
if (!isArrowElement(el)) {
|
elements: changeProperty(elements, appState, (el) => {
|
||||||
return el;
|
if (!isArrowElement(el)) {
|
||||||
}
|
return el;
|
||||||
const newElement = newElementWith(el, {
|
}
|
||||||
roundness:
|
const newElement = newElementWith(el, {
|
||||||
value === ARROW_TYPE.round
|
roundness:
|
||||||
? {
|
value === ARROW_TYPE.round
|
||||||
type: ROUNDNESS.PROPORTIONAL_RADIUS,
|
? {
|
||||||
}
|
type: ROUNDNESS.PROPORTIONAL_RADIUS,
|
||||||
: null,
|
}
|
||||||
elbowed: value === ARROW_TYPE.elbow,
|
: null,
|
||||||
points:
|
elbowed: value === ARROW_TYPE.elbow,
|
||||||
value === ARROW_TYPE.elbow || el.elbowed
|
points:
|
||||||
? [el.points[0], el.points[el.points.length - 1]]
|
value === ARROW_TYPE.elbow || el.elbowed
|
||||||
: el.points,
|
? [el.points[0], el.points[el.points.length - 1]]
|
||||||
});
|
: el.points,
|
||||||
|
|
||||||
if (isElbowArrow(newElement)) {
|
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
|
||||||
|
|
||||||
app.dismissLinearEditor();
|
|
||||||
|
|
||||||
const startGlobalPoint =
|
|
||||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
|
||||||
newElement,
|
|
||||||
0,
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
const endGlobalPoint =
|
|
||||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
|
||||||
newElement,
|
|
||||||
-1,
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
const startHoveredElement =
|
|
||||||
!newElement.startBinding &&
|
|
||||||
getHoveredElementForBinding(
|
|
||||||
tupleToCoors(startGlobalPoint),
|
|
||||||
elements,
|
|
||||||
elementsMap,
|
|
||||||
appState.zoom,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const endHoveredElement =
|
|
||||||
!newElement.endBinding &&
|
|
||||||
getHoveredElementForBinding(
|
|
||||||
tupleToCoors(endGlobalPoint),
|
|
||||||
elements,
|
|
||||||
elementsMap,
|
|
||||||
appState.zoom,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const startElement = startHoveredElement
|
|
||||||
? startHoveredElement
|
|
||||||
: newElement.startBinding &&
|
|
||||||
(elementsMap.get(
|
|
||||||
newElement.startBinding.elementId,
|
|
||||||
) as ExcalidrawBindableElement);
|
|
||||||
const endElement = endHoveredElement
|
|
||||||
? endHoveredElement
|
|
||||||
: newElement.endBinding &&
|
|
||||||
(elementsMap.get(
|
|
||||||
newElement.endBinding.elementId,
|
|
||||||
) as ExcalidrawBindableElement);
|
|
||||||
|
|
||||||
const finalStartPoint = startHoveredElement
|
|
||||||
? bindPointToSnapToElementOutline(
|
|
||||||
startGlobalPoint,
|
|
||||||
endGlobalPoint,
|
|
||||||
startHoveredElement,
|
|
||||||
elementsMap,
|
|
||||||
)
|
|
||||||
: startGlobalPoint;
|
|
||||||
const finalEndPoint = endHoveredElement
|
|
||||||
? bindPointToSnapToElementOutline(
|
|
||||||
endGlobalPoint,
|
|
||||||
startGlobalPoint,
|
|
||||||
endHoveredElement,
|
|
||||||
elementsMap,
|
|
||||||
)
|
|
||||||
: endGlobalPoint;
|
|
||||||
|
|
||||||
startHoveredElement &&
|
|
||||||
bindLinearElement(
|
|
||||||
newElement,
|
|
||||||
startHoveredElement,
|
|
||||||
"start",
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
endHoveredElement &&
|
|
||||||
bindLinearElement(newElement, endHoveredElement, "end", elementsMap);
|
|
||||||
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
LinearElementEditor.updateEditorMidPointsCache(
|
if (isElbowArrow(newElement)) {
|
||||||
newElement,
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
elementsMap,
|
|
||||||
app.state,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return newElement;
|
app.dismissLinearEditor();
|
||||||
});
|
|
||||||
|
|
||||||
const newState = {
|
const startGlobalPoint =
|
||||||
...appState,
|
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||||
currentItemArrowType: value,
|
newElement,
|
||||||
};
|
0,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
const endGlobalPoint =
|
||||||
|
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||||
|
newElement,
|
||||||
|
-1,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
const startHoveredElement =
|
||||||
|
!newElement.startBinding &&
|
||||||
|
getHoveredElementForBinding(
|
||||||
|
tupleToCoors(startGlobalPoint),
|
||||||
|
elements,
|
||||||
|
elementsMap,
|
||||||
|
appState.zoom,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const endHoveredElement =
|
||||||
|
!newElement.endBinding &&
|
||||||
|
getHoveredElementForBinding(
|
||||||
|
tupleToCoors(endGlobalPoint),
|
||||||
|
elements,
|
||||||
|
elementsMap,
|
||||||
|
appState.zoom,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const startElement = startHoveredElement
|
||||||
|
? startHoveredElement
|
||||||
|
: newElement.startBinding &&
|
||||||
|
(elementsMap.get(
|
||||||
|
newElement.startBinding.elementId,
|
||||||
|
) as ExcalidrawBindableElement);
|
||||||
|
const endElement = endHoveredElement
|
||||||
|
? endHoveredElement
|
||||||
|
: newElement.endBinding &&
|
||||||
|
(elementsMap.get(
|
||||||
|
newElement.endBinding.elementId,
|
||||||
|
) as ExcalidrawBindableElement);
|
||||||
|
|
||||||
// Change the arrow type and update any other state settings for
|
const finalStartPoint = startHoveredElement
|
||||||
// the arrow.
|
? bindPointToSnapToElementOutline(
|
||||||
const selectedId = appState.selectedLinearElement?.elementId;
|
startGlobalPoint,
|
||||||
if (selectedId) {
|
endGlobalPoint,
|
||||||
const selected = newElements.find((el) => el.id === selectedId);
|
startHoveredElement,
|
||||||
if (selected) {
|
elementsMap,
|
||||||
newState.selectedLinearElement = new LinearElementEditor(
|
)
|
||||||
selected as ExcalidrawLinearElement,
|
: startGlobalPoint;
|
||||||
);
|
const finalEndPoint = endHoveredElement
|
||||||
}
|
? bindPointToSnapToElementOutline(
|
||||||
}
|
endGlobalPoint,
|
||||||
|
startGlobalPoint,
|
||||||
|
endHoveredElement,
|
||||||
|
elementsMap,
|
||||||
|
)
|
||||||
|
: endGlobalPoint;
|
||||||
|
|
||||||
return {
|
startHoveredElement &&
|
||||||
elements: newElements,
|
bindLinearElement(
|
||||||
appState: newState,
|
newElement,
|
||||||
|
startHoveredElement,
|
||||||
|
"start",
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
endHoveredElement &&
|
||||||
|
bindLinearElement(
|
||||||
|
newElement,
|
||||||
|
endHoveredElement,
|
||||||
|
"end",
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
mutateElbowArrow(
|
||||||
|
newElement,
|
||||||
|
elementsMap,
|
||||||
|
[finalStartPoint, finalEndPoint].map(
|
||||||
|
(p): LocalPoint =>
|
||||||
|
pointFrom(p[0] - newElement.x, p[1] - newElement.y),
|
||||||
|
),
|
||||||
|
vector(0, 0),
|
||||||
|
{
|
||||||
|
...(startElement && newElement.startBinding
|
||||||
|
? {
|
||||||
|
startBinding: {
|
||||||
|
// @ts-ignore TS cannot discern check above
|
||||||
|
...newElement.startBinding!,
|
||||||
|
...calculateFixedPointForElbowArrowBinding(
|
||||||
|
newElement,
|
||||||
|
startElement,
|
||||||
|
"start",
|
||||||
|
elementsMap,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(endElement && newElement.endBinding
|
||||||
|
? {
|
||||||
|
endBinding: {
|
||||||
|
// @ts-ignore TS cannot discern check above
|
||||||
|
...newElement.endBinding,
|
||||||
|
...calculateFixedPointForElbowArrowBinding(
|
||||||
|
newElement,
|
||||||
|
endElement,
|
||||||
|
"end",
|
||||||
|
elementsMap,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newElement;
|
||||||
|
}),
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
currentItemArrowType: value,
|
||||||
|
},
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getNonDeletedElements, isTextElement } from "../element";
|
|||||||
import type { ExcalidrawElement } from "../element/types";
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
import { isLinearElement } from "../element/typeChecks";
|
import { isLinearElement } from "../element/typeChecks";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
|
import { excludeElementsInFramesFromSelection } from "../scene/selection";
|
||||||
import { selectAllIcon } from "../components/icons";
|
import { selectAllIcon } from "../components/icons";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
@@ -19,17 +20,17 @@ export const actionSelectAll = register({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedElementIds = elements
|
const selectedElementIds = excludeElementsInFramesFromSelection(
|
||||||
.filter(
|
elements.filter(
|
||||||
(element) =>
|
(element) =>
|
||||||
!element.isDeleted &&
|
!element.isDeleted &&
|
||||||
!(isTextElement(element) && element.containerId) &&
|
!(isTextElement(element) && element.containerId) &&
|
||||||
!element.locked,
|
!element.locked,
|
||||||
)
|
),
|
||||||
.reduce((map: Record<ExcalidrawElement["id"], true>, element) => {
|
).reduce((map: Record<ExcalidrawElement["id"], true>, element) => {
|
||||||
map[element.id] = true;
|
map[element.id] = true;
|
||||||
return map;
|
return map;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { isTextElement } from "../element";
|
import { isTextElement } from "../element";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import { measureText } from "../element/textMeasurements";
|
import { measureText } from "../element/textElement";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
import type { AppClassProperties } from "../types";
|
import type { AppClassProperties } from "../types";
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ export type ShortcutName =
|
|||||||
| "saveFileToDisk"
|
| "saveFileToDisk"
|
||||||
| "saveToActiveFile"
|
| "saveToActiveFile"
|
||||||
| "toggleShortcuts"
|
| "toggleShortcuts"
|
||||||
| "wrapSelectionInFrame"
|
|
||||||
>
|
>
|
||||||
| "saveScene"
|
| "saveScene"
|
||||||
| "imageExport"
|
| "imageExport"
|
||||||
@@ -113,7 +112,6 @@ 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,8 +137,7 @@ 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,10 +1,8 @@
|
|||||||
import type { ElementsMap, ExcalidrawElement } from "./element/types";
|
import type { ElementsMap, ExcalidrawElement } from "./element/types";
|
||||||
import { mutateElement } from "./element/mutateElement";
|
import { newElementWith } from "./element/mutateElement";
|
||||||
import type { BoundingBox } from "./element/bounds";
|
import type { BoundingBox } from "./element/bounds";
|
||||||
import { getCommonBoundingBox } from "./element/bounds";
|
import { getCommonBoundingBox } from "./element/bounds";
|
||||||
import { getMaximumGroups } from "./groups";
|
import { getMaximumGroups } from "./groups";
|
||||||
import { updateBoundElements } from "./element/binding";
|
|
||||||
import type Scene from "./scene/Scene";
|
|
||||||
|
|
||||||
export interface Alignment {
|
export interface Alignment {
|
||||||
position: "start" | "center" | "end";
|
position: "start" | "center" | "end";
|
||||||
@@ -15,7 +13,6 @@ export const alignElements = (
|
|||||||
selectedElements: ExcalidrawElement[],
|
selectedElements: ExcalidrawElement[],
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
alignment: Alignment,
|
alignment: Alignment,
|
||||||
scene: Scene,
|
|
||||||
): ExcalidrawElement[] => {
|
): ExcalidrawElement[] => {
|
||||||
const groups: ExcalidrawElement[][] = getMaximumGroups(
|
const groups: ExcalidrawElement[][] = getMaximumGroups(
|
||||||
selectedElements,
|
selectedElements,
|
||||||
@@ -29,18 +26,12 @@ export const alignElements = (
|
|||||||
selectionBoundingBox,
|
selectionBoundingBox,
|
||||||
alignment,
|
alignment,
|
||||||
);
|
);
|
||||||
return group.map((element) => {
|
return group.map((element) =>
|
||||||
// update element
|
newElementWith(element, {
|
||||||
const updatedEle = mutateElement(element, {
|
|
||||||
x: element.x + translation.x,
|
x: element.x + translation.x,
|
||||||
y: element.y + translation.y,
|
y: element.y + translation.y,
|
||||||
});
|
}),
|
||||||
// update bound elements
|
);
|
||||||
updateBoundElements(element, scene.getNonDeletedElementsMap(), {
|
|
||||||
simultaneouslyUpdated: group,
|
|
||||||
});
|
|
||||||
return updatedEle;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ import {
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { useTunnels } from "../context/tunnels";
|
import { useTunnels } from "../context/tunnels";
|
||||||
import { CLASSES } from "../constants";
|
import { CLASSES } from "../constants";
|
||||||
import { alignActionsPredicate } from "../actions/actionAlign";
|
|
||||||
|
|
||||||
export const canChangeStrokeColor = (
|
export const canChangeStrokeColor = (
|
||||||
appState: UIAppState,
|
appState: UIAppState,
|
||||||
@@ -91,12 +90,10 @@ export const SelectedShapeActions = ({
|
|||||||
appState,
|
appState,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
renderAction,
|
renderAction,
|
||||||
app,
|
|
||||||
}: {
|
}: {
|
||||||
appState: UIAppState;
|
appState: UIAppState;
|
||||||
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
|
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
|
||||||
renderAction: ActionManager["renderAction"];
|
renderAction: ActionManager["renderAction"];
|
||||||
app: AppClassProperties;
|
|
||||||
}) => {
|
}) => {
|
||||||
const targetElements = getTargetElements(elementsMap, appState);
|
const targetElements = getTargetElements(elementsMap, appState);
|
||||||
|
|
||||||
@@ -136,9 +133,6 @@ export const SelectedShapeActions = ({
|
|||||||
targetElements.length === 1 &&
|
targetElements.length === 1 &&
|
||||||
isImageElement(targetElements[0]);
|
isImageElement(targetElements[0]);
|
||||||
|
|
||||||
const showAlignActions =
|
|
||||||
!isSingleElementBoundContainer && alignActionsPredicate(appState, app);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="panelColumn">
|
<div className="panelColumn">
|
||||||
<div>
|
<div>
|
||||||
@@ -206,7 +200,7 @@ export const SelectedShapeActions = ({
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
{showAlignActions && !isSingleElementBoundContainer && (
|
{targetElements.length > 1 && !isSingleElementBoundContainer && (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.align")}</legend>
|
<legend>{t("labels.align")}</legend>
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { atom, useAtom } from "jotai";
|
||||||
import { actionClearCanvas } from "../actions";
|
import { actionClearCanvas } from "../actions";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { atom, useAtom } from "../editor-jotai";
|
import { jotaiScope } from "../jotai";
|
||||||
import { useExcalidrawActionManager } from "./App";
|
import { useExcalidrawActionManager } from "./App";
|
||||||
import ConfirmDialog from "./ConfirmDialog";
|
import ConfirmDialog from "./ConfirmDialog";
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ 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,7 +165,6 @@ import {
|
|||||||
isTextBindableContainer,
|
isTextBindableContainer,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
isFlowchartNodeElement,
|
isFlowchartNodeElement,
|
||||||
isBindableElement,
|
|
||||||
} from "../element/typeChecks";
|
} from "../element/typeChecks";
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawBindableElement,
|
ExcalidrawBindableElement,
|
||||||
@@ -190,6 +189,7 @@ 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 {
|
||||||
@@ -233,7 +233,7 @@ import {
|
|||||||
findShapeByKey,
|
findShapeByKey,
|
||||||
getBoundTextShape,
|
getBoundTextShape,
|
||||||
getCornerRadius,
|
getCornerRadius,
|
||||||
getElementShape,
|
getElementShapes,
|
||||||
isPathALoop,
|
isPathALoop,
|
||||||
} from "../shapes";
|
} from "../shapes";
|
||||||
import { getSelectionBoxShape } from "../../utils/geometry/shape";
|
import { getSelectionBoxShape } from "../../utils/geometry/shape";
|
||||||
@@ -292,6 +292,7 @@ import {
|
|||||||
getDateTime,
|
getDateTime,
|
||||||
isShallowEqual,
|
isShallowEqual,
|
||||||
arrayToMap,
|
arrayToMap,
|
||||||
|
toBrandedType,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
import {
|
import {
|
||||||
createSrcDoc,
|
createSrcDoc,
|
||||||
@@ -331,10 +332,17 @@ import type { FileSystemHandle } from "../data/filesystem";
|
|||||||
import { fileOpen } from "../data/filesystem";
|
import { fileOpen } from "../data/filesystem";
|
||||||
import {
|
import {
|
||||||
bindTextToShapeAfterDuplication,
|
bindTextToShapeAfterDuplication,
|
||||||
|
getApproxMinLineHeight,
|
||||||
|
getApproxMinLineWidth,
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
getContainerCenter,
|
getContainerCenter,
|
||||||
getContainerElement,
|
getContainerElement,
|
||||||
|
getLineHeightInPx,
|
||||||
|
getMinTextElementWidth,
|
||||||
|
isMeasureTextSupported,
|
||||||
isValidTextContainer,
|
isValidTextContainer,
|
||||||
|
measureText,
|
||||||
|
normalizeText,
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
import {
|
import {
|
||||||
showHyperlinkTooltip,
|
showHyperlinkTooltip,
|
||||||
@@ -370,10 +378,9 @@ 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 { editorJotaiStore } from "../editor-jotai";
|
import { jotaiStore } from "../jotai";
|
||||||
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
||||||
import { ImageSceneDataError } from "../errors";
|
import { ImageSceneDataError } from "../errors";
|
||||||
import {
|
import {
|
||||||
@@ -435,6 +442,7 @@ 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,
|
||||||
@@ -458,15 +466,6 @@ import { cropElement } from "../element/cropElement";
|
|||||||
import { wrapText } from "../element/textWrapping";
|
import { wrapText } from "../element/textWrapping";
|
||||||
import { actionCopyElementLink } from "../actions/actionElementLink";
|
import { actionCopyElementLink } from "../actions/actionElementLink";
|
||||||
import { isElementLink, parseElementLinkFromURL } from "../element/elementLink";
|
import { isElementLink, parseElementLinkFromURL } from "../element/elementLink";
|
||||||
import {
|
|
||||||
isMeasureTextSupported,
|
|
||||||
normalizeText,
|
|
||||||
measureText,
|
|
||||||
getLineHeightInPx,
|
|
||||||
getApproxMinLineWidth,
|
|
||||||
getApproxMinLineHeight,
|
|
||||||
getMinTextElementWidth,
|
|
||||||
} from "../element/textMeasurements";
|
|
||||||
|
|
||||||
const AppContext = React.createContext<AppClassProperties>(null!);
|
const AppContext = React.createContext<AppClassProperties>(null!);
|
||||||
const AppPropsContext = React.createContext<AppProps>(null!);
|
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||||
@@ -1524,17 +1523,13 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
const allElementsMap = this.scene.getNonDeletedElementsMap();
|
const allElementsMap = this.scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
const shouldBlockPointerEvents =
|
const shouldBlockPointerEvents =
|
||||||
// default back to `--ui-pointerEvents` flow if setPointerCapture
|
this.state.selectionElement ||
|
||||||
// not supported
|
this.state.newElement ||
|
||||||
"setPointerCapture" in HTMLElement.prototype
|
this.state.selectedElementsAreBeingDragged ||
|
||||||
? false
|
this.state.resizingElement ||
|
||||||
: this.state.selectionElement ||
|
(this.state.activeTool.type === "laser" &&
|
||||||
this.state.newElement ||
|
// technically we can just test on this once we make it more safe
|
||||||
this.state.selectedElementsAreBeingDragged ||
|
this.state.cursorButton === "down");
|
||||||
this.state.resizingElement ||
|
|
||||||
(this.state.activeTool.type === "laser" &&
|
|
||||||
// technically we can just test on this once we make it more safe
|
|
||||||
this.state.cursorButton === "down");
|
|
||||||
|
|
||||||
const firstSelectedElement = selectedElements[0];
|
const firstSelectedElement = selectedElements[0];
|
||||||
|
|
||||||
@@ -2081,7 +2076,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private openEyeDropper = ({ type }: { type: "stroke" | "background" }) => {
|
private openEyeDropper = ({ type }: { type: "stroke" | "background" }) => {
|
||||||
editorJotaiStore.set(activeEyeDropperAtom, {
|
jotaiStore.set(activeEyeDropperAtom, {
|
||||||
swapPreviewOnAlt: true,
|
swapPreviewOnAlt: true,
|
||||||
colorPickerType:
|
colorPickerType:
|
||||||
type === "stroke" ? "elementStroke" : "elementBackground",
|
type === "stroke" ? "elementStroke" : "elementBackground",
|
||||||
@@ -3188,7 +3183,49 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
retainSeed?: boolean;
|
retainSeed?: boolean;
|
||||||
fitToContent?: boolean;
|
fitToContent?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const elements = restoreElements(opts.elements, null, undefined);
|
let elements = opts.elements.map((el, _, elements) => {
|
||||||
|
if (isElbowArrow(el)) {
|
||||||
|
const startEndElements = [
|
||||||
|
el.startBinding &&
|
||||||
|
elements.find((l) => l.id === el.startBinding?.elementId),
|
||||||
|
el.endBinding &&
|
||||||
|
elements.find((l) => l.id === el.endBinding?.elementId),
|
||||||
|
];
|
||||||
|
const startBinding = startEndElements[0] ? el.startBinding : null;
|
||||||
|
const endBinding = startEndElements[1] ? el.endBinding : null;
|
||||||
|
return {
|
||||||
|
...el,
|
||||||
|
...updateElbowArrow(
|
||||||
|
{
|
||||||
|
...el,
|
||||||
|
startBinding,
|
||||||
|
endBinding,
|
||||||
|
},
|
||||||
|
toBrandedType<NonDeletedSceneElementsMap>(
|
||||||
|
new Map(
|
||||||
|
startEndElements
|
||||||
|
.filter((x) => x != null)
|
||||||
|
.map(
|
||||||
|
(el) =>
|
||||||
|
[el!.id, el] as [
|
||||||
|
string,
|
||||||
|
Ordered<NonDeletedExcalidrawElement>,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
[el.points[0], el.points[el.points.length - 1]],
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
zoom: this.state.zoom,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return el;
|
||||||
|
});
|
||||||
|
elements = restoreElements(elements, null, undefined);
|
||||||
const [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;
|
||||||
@@ -3230,14 +3267,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const prevElements = this.scene.getElementsIncludingDeleted();
|
const prevElements = this.scene.getElementsIncludingDeleted();
|
||||||
let nextElements = [...prevElements, ...newElements];
|
const nextElements = [...prevElements, ...newElements];
|
||||||
|
|
||||||
const mappedNewSceneElements = this.props.onDuplicate?.(
|
|
||||||
nextElements,
|
|
||||||
prevElements,
|
|
||||||
);
|
|
||||||
|
|
||||||
nextElements = mappedNewSceneElements || nextElements;
|
|
||||||
|
|
||||||
syncMovedIndices(nextElements, arrayToMap(newElements));
|
syncMovedIndices(nextElements, arrayToMap(newElements));
|
||||||
|
|
||||||
@@ -3248,12 +3278,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
newElements,
|
newElements,
|
||||||
topLayerFrame,
|
topLayerFrame,
|
||||||
);
|
);
|
||||||
addElementsToFrame(
|
addElementsToFrame(nextElements, eligibleElements, topLayerFrame);
|
||||||
nextElements,
|
|
||||||
eligibleElements,
|
|
||||||
topLayerFrame,
|
|
||||||
this.state,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.scene.replaceAllElements(nextElements);
|
this.scene.replaceAllElements(nextElements);
|
||||||
@@ -3299,7 +3324,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
openSidebar:
|
openSidebar:
|
||||||
this.state.openSidebar &&
|
this.state.openSidebar &&
|
||||||
this.device.editor.canFitSidebar &&
|
this.device.editor.canFitSidebar &&
|
||||||
editorJotaiStore.get(isSidebarDockedAtom)
|
jotaiStore.get(isSidebarDockedAtom)
|
||||||
? this.state.openSidebar
|
? this.state.openSidebar
|
||||||
: null,
|
: null,
|
||||||
...selectGroupsForSelectedElements(
|
...selectGroupsForSelectedElements(
|
||||||
@@ -4344,17 +4369,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
selectedElements.forEach((element) => {
|
selectedElements.forEach((element) => {
|
||||||
mutateElement(
|
mutateElement(element, {
|
||||||
element,
|
x: element.x + offsetX,
|
||||||
{
|
y: element.y + offsetY,
|
||||||
x: element.x + offsetX,
|
});
|
||||||
y: element.y + offsetY,
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
updateBoundElements(element, this.scene.getNonDeletedElementsMap(), {
|
updateBoundElements(element, this.scene.getNonDeletedElementsMap(), {
|
||||||
simultaneouslyUpdated: selectedElements,
|
simultaneouslyUpdated: selectedElements,
|
||||||
|
zoom: this.state.zoom,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -4368,8 +4390,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.scene.triggerUpdate();
|
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
} else if (event.key === KEYS.ENTER) {
|
} else if (event.key === KEYS.ENTER) {
|
||||||
const selectedElements = this.scene.getSelectedElements(this.state);
|
const selectedElements = this.scene.getSelectedElements(this.state);
|
||||||
@@ -4532,7 +4552,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)
|
||||||
) {
|
) {
|
||||||
editorJotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
|
jotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
|
||||||
}
|
}
|
||||||
|
|
||||||
// eye dropper
|
// eye dropper
|
||||||
@@ -4675,10 +4695,7 @@ 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, {
|
setCursorForShape(this.interactiveCanvas, this.state);
|
||||||
...this.state,
|
|
||||||
activeTool: nextActiveTool,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (isToolIcon(document.activeElement)) {
|
if (isToolIcon(document.activeElement)) {
|
||||||
this.focusContainer();
|
this.focusContainer();
|
||||||
@@ -4992,7 +5009,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
element: elementWithHighestZIndex,
|
element: elementWithHighestZIndex,
|
||||||
shape: getElementShape(
|
shapes: getElementShapes(
|
||||||
elementWithHighestZIndex,
|
elementWithHighestZIndex,
|
||||||
this.scene.getNonDeletedElementsMap(),
|
this.scene.getNonDeletedElementsMap(),
|
||||||
),
|
),
|
||||||
@@ -5104,7 +5121,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
element,
|
element,
|
||||||
shape: getElementShape(element, this.scene.getNonDeletedElementsMap()),
|
shapes: getElementShapes(element, this.scene.getNonDeletedElementsMap()),
|
||||||
threshold: this.getElementHitThreshold(),
|
threshold: this.getElementHitThreshold(),
|
||||||
frameNameBound: isFrameLikeElement(element)
|
frameNameBound: isFrameLikeElement(element)
|
||||||
? this.frameNameBoundsCache.get(element)
|
? this.frameNameBoundsCache.get(element)
|
||||||
@@ -5136,7 +5153,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
element: elements[index],
|
element: elements[index],
|
||||||
shape: getElementShape(
|
shapes: getElementShapes(
|
||||||
elements[index],
|
elements[index],
|
||||||
this.scene.getNonDeletedElementsMap(),
|
this.scene.getNonDeletedElementsMap(),
|
||||||
),
|
),
|
||||||
@@ -5344,11 +5361,6 @@ 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] &&
|
||||||
@@ -5362,64 +5374,6 @@ 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5430,6 +5384,11 @@ 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) {
|
||||||
@@ -5478,7 +5437,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
x: sceneX,
|
x: sceneX,
|
||||||
y: sceneY,
|
y: sceneY,
|
||||||
element: container,
|
element: container,
|
||||||
shape: getElementShape(
|
shapes: getElementShapes(
|
||||||
container,
|
container,
|
||||||
this.scene.getNonDeletedElementsMap(),
|
this.scene.getNonDeletedElementsMap(),
|
||||||
),
|
),
|
||||||
@@ -5783,10 +5742,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (editingLinearElement?.lastUncommittedPoint != null) {
|
if (editingLinearElement?.lastUncommittedPoint != null) {
|
||||||
this.maybeSuggestBindingAtCursor(
|
this.maybeSuggestBindingAtCursor(scenePointer);
|
||||||
scenePointer,
|
|
||||||
editingLinearElement.elbowed,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
// causes stack overflow if not sync
|
// causes stack overflow if not sync
|
||||||
flushSync(() => {
|
flushSync(() => {
|
||||||
@@ -5806,7 +5762,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.state.startBoundElement,
|
this.state.startBoundElement,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.maybeSuggestBindingAtCursor(scenePointer, false);
|
this.maybeSuggestBindingAtCursor(scenePointer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5889,23 +5845,41 @@ 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);
|
||||||
}
|
}
|
||||||
// update last uncommitted point
|
if (isElbowArrow(multiElement)) {
|
||||||
mutateElement(
|
mutateElbowArrow(
|
||||||
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,
|
||||||
false,
|
undefined,
|
||||||
{
|
{
|
||||||
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
|
||||||
@@ -6071,7 +6045,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.setState({
|
this.setState({
|
||||||
activeEmbeddable: { element: hitElement, state: "hover" },
|
activeEmbeddable: { element: hitElement, state: "hover" },
|
||||||
});
|
});
|
||||||
} else if (!hitElement || !isElbowArrow(hitElement)) {
|
} else {
|
||||||
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 });
|
||||||
@@ -6237,7 +6211,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
x: scenePointerX,
|
x: scenePointerX,
|
||||||
y: scenePointerY,
|
y: scenePointerY,
|
||||||
element,
|
element,
|
||||||
shape: getElementShape(
|
shapes: getElementShapes(
|
||||||
element,
|
element,
|
||||||
this.scene.getNonDeletedElementsMap(),
|
this.scene.getNonDeletedElementsMap(),
|
||||||
),
|
),
|
||||||
@@ -6257,18 +6231,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.state,
|
this.state,
|
||||||
this.scene.getNonDeletedElementsMap(),
|
this.scene.getNonDeletedElementsMap(),
|
||||||
);
|
);
|
||||||
const isHoveringAPointHandle = isElbowArrow(element)
|
|
||||||
? hoverPointIndex === 0 ||
|
if (hoverPointIndex >= 0 || segmentMidPointHoveredCoords) {
|
||||||
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)
|
||||||
) {
|
) {
|
||||||
@@ -6308,13 +6278,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
private handleCanvasPointerDown = (
|
private handleCanvasPointerDown = (
|
||||||
event: React.PointerEvent<HTMLElement>,
|
event: React.PointerEvent<HTMLElement>,
|
||||||
) => {
|
) => {
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
// capture subsequent pointer events to the canvas
|
|
||||||
// this makes other elements non-interactive until pointer up
|
|
||||||
if (target.setPointerCapture) {
|
|
||||||
target.setPointerCapture(event.pointerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.maybeCleanupAfterMissingPointerUp(event.nativeEvent);
|
this.maybeCleanupAfterMissingPointerUp(event.nativeEvent);
|
||||||
this.maybeUnfollowRemoteUser();
|
this.maybeUnfollowRemoteUser();
|
||||||
|
|
||||||
@@ -6325,7 +6288,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
focus: false,
|
focus: false,
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
editorJotaiStore.set(searchItemInFocusAtom, null);
|
jotaiStore.set(searchItemInFocusAtom, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// since contextMenu options are potentially evaluated on each render,
|
// since contextMenu options are potentially evaluated on each render,
|
||||||
@@ -7005,7 +6968,6 @@ 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
|
||||||
@@ -7707,10 +7669,6 @@ 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,
|
||||||
@@ -7751,7 +7709,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.scene.getNonDeletedElementsMap(),
|
this.scene.getNonDeletedElementsMap(),
|
||||||
this.state.zoom,
|
this.state.zoom,
|
||||||
isElbowArrow(element),
|
isElbowArrow(element),
|
||||||
isElbowArrow(element),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
this.scene.insertElement(element);
|
this.scene.insertElement(element);
|
||||||
@@ -7952,63 +7909,6 @@ 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;
|
||||||
@@ -8355,11 +8255,13 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.scene.getNonDeletedElementsMap(),
|
this.scene.getNonDeletedElementsMap(),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.setState({ snapLines });
|
flushSync(() => {
|
||||||
|
this.setState({ snapLines });
|
||||||
|
});
|
||||||
|
|
||||||
// when we're editing the name of a frame, we want the user to be
|
// when we're editing the name of a frame, we want the user to be
|
||||||
// able to select and interact with the text input
|
// able to select and interact with the text input
|
||||||
if (!this.state.editingFrame) {
|
!this.state.editingFrame &&
|
||||||
dragSelectedElements(
|
dragSelectedElements(
|
||||||
pointerDownState,
|
pointerDownState,
|
||||||
selectedElements,
|
selectedElements,
|
||||||
@@ -8368,7 +8270,6 @@ 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,
|
||||||
@@ -8451,17 +8352,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextSceneElements: ExcalidrawElement[] = [
|
const nextSceneElements = [...nextElements, ...elementsToAppend];
|
||||||
...nextElements,
|
|
||||||
...elementsToAppend,
|
|
||||||
];
|
|
||||||
|
|
||||||
const mappedNewSceneElements = this.props.onDuplicate?.(
|
|
||||||
nextSceneElements,
|
|
||||||
elements,
|
|
||||||
);
|
|
||||||
|
|
||||||
nextSceneElements = mappedNewSceneElements || nextSceneElements;
|
|
||||||
|
|
||||||
syncMovedIndices(nextSceneElements, arrayToMap(elementsToAppend));
|
syncMovedIndices(nextSceneElements, arrayToMap(elementsToAppend));
|
||||||
|
|
||||||
@@ -8554,17 +8445,26 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
} else if (
|
} else if (points.length > 1 && isElbowArrow(newElement)) {
|
||||||
points.length === 2 ||
|
mutateElbowArrow(
|
||||||
(points.length > 1 && isElbowArrow(newElement))
|
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 },
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8631,7 +8531,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
elements,
|
elements,
|
||||||
this.state.selectionElement,
|
this.state.selectionElement,
|
||||||
this.scene.getNonDeletedElementsMap(),
|
this.scene.getNonDeletedElementsMap(),
|
||||||
false,
|
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
@@ -8760,24 +8659,6 @@ 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) {
|
||||||
@@ -8802,17 +8683,6 @@ 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
|
||||||
@@ -9060,7 +8930,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.scene.getElementsMapIncludingDeleted(),
|
this.scene.getElementsMapIncludingDeleted(),
|
||||||
elementsInsideFrame,
|
elementsInsideFrame,
|
||||||
newElement,
|
newElement,
|
||||||
this.state,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -9178,7 +9047,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
nextElements,
|
nextElements,
|
||||||
elementsToAdd,
|
elementsToAdd,
|
||||||
topLayerFrame,
|
topLayerFrame,
|
||||||
this.state,
|
|
||||||
);
|
);
|
||||||
} else if (!topLayerFrame) {
|
} else if (!topLayerFrame) {
|
||||||
if (this.state.editingGroupId) {
|
if (this.state.editingGroupId) {
|
||||||
@@ -9254,10 +9122,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),
|
||||||
});
|
});
|
||||||
@@ -9465,8 +9333,6 @@ 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
|
||||||
@@ -9478,7 +9344,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
x: pointerDownState.origin.x,
|
x: pointerDownState.origin.x,
|
||||||
y: pointerDownState.origin.y,
|
y: pointerDownState.origin.y,
|
||||||
element: hitElement,
|
element: hitElement,
|
||||||
shape: getElementShape(
|
shapes: getElementShapes(
|
||||||
hitElement,
|
hitElement,
|
||||||
this.scene.getNonDeletedElementsMap(),
|
this.scene.getNonDeletedElementsMap(),
|
||||||
),
|
),
|
||||||
@@ -10039,20 +9905,15 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private maybeSuggestBindingAtCursor = (
|
private maybeSuggestBindingAtCursor = (pointerCoords: {
|
||||||
pointerCoords: {
|
x: number;
|
||||||
x: number;
|
y: number;
|
||||||
y: number;
|
}): void => {
|
||||||
},
|
|
||||||
considerAll: boolean,
|
|
||||||
): void => {
|
|
||||||
const hoveredBindableElement = getHoveredElementForBinding(
|
const hoveredBindableElement = getHoveredElementForBinding(
|
||||||
pointerCoords,
|
pointerCoords,
|
||||||
this.scene.getNonDeletedElements(),
|
this.scene.getNonDeletedElements(),
|
||||||
this.scene.getNonDeletedElementsMap(),
|
this.scene.getNonDeletedElementsMap(),
|
||||||
this.state.zoom,
|
this.state.zoom,
|
||||||
false,
|
|
||||||
considerAll,
|
|
||||||
);
|
);
|
||||||
this.setState({
|
this.setState({
|
||||||
suggestedBindings:
|
suggestedBindings:
|
||||||
@@ -10082,8 +9943,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.scene.getNonDeletedElements(),
|
this.scene.getNonDeletedElements(),
|
||||||
this.scene.getNonDeletedElementsMap(),
|
this.scene.getNonDeletedElementsMap(),
|
||||||
this.state.zoom,
|
this.state.zoom,
|
||||||
isElbowArrow(linearElement),
|
isArrowElement(linearElement) && isElbowArrow(linearElement),
|
||||||
isElbowArrow(linearElement),
|
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
hoveredBindableElement != null &&
|
hoveredBindableElement != null &&
|
||||||
@@ -10801,10 +10661,8 @@ 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,9 +1,10 @@
|
|||||||
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 { useAtom } from "../../editor-jotai";
|
import { jotaiScope } from "../../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";
|
||||||
@@ -56,7 +57,10 @@ export const ColorInput = ({
|
|||||||
}
|
}
|
||||||
}, [activeSection]);
|
}, [activeSection]);
|
||||||
|
|
||||||
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
|
const [eyeDropperState, setEyeDropperState] = useAtom(
|
||||||
|
activeEyeDropperAtom,
|
||||||
|
jotaiScope,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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";
|
||||||
@@ -14,7 +15,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 { useAtom } from "../../editor-jotai";
|
import { jotaiScope } from "../../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";
|
||||||
@@ -75,7 +76,10 @@ const ColorPickerPopupContent = ({
|
|||||||
const { container } = useExcalidrawContainer();
|
const { container } = useExcalidrawContainer();
|
||||||
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
|
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
|
||||||
|
|
||||||
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
|
const [eyeDropperState, setEyeDropperState] = useAtom(
|
||||||
|
activeEyeDropperAtom,
|
||||||
|
jotaiScope,
|
||||||
|
);
|
||||||
|
|
||||||
const colorInputJSX = (
|
const colorInputJSX = (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useAtom } from "../../editor-jotai";
|
import { useAtom } from "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 "../../editor-jotai";
|
import { useAtom } from "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 "../../editor-jotai";
|
import { useAtom } from "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 "../../editor-jotai";
|
import { useAtom } from "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, editorJotaiStore } from "../../editor-jotai";
|
import { atom, useAtom } from "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,6 +48,7 @@ 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";
|
||||||
@@ -262,7 +263,6 @@ 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: () => {
|
||||||
editorJotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
|
jotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ 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 { useSetAtom } from "../editor-jotai";
|
import { jotaiScope } from "../jotai";
|
||||||
|
|
||||||
interface Props extends Omit<DialogProps, "onCloseRequest"> {
|
interface Props extends Omit<DialogProps, "onCloseRequest"> {
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
@@ -26,7 +27,7 @@ const ConfirmDialog = (props: Props) => {
|
|||||||
...rest
|
...rest
|
||||||
} = props;
|
} = props;
|
||||||
const setAppState = useExcalidrawSetAppState();
|
const setAppState = useExcalidrawSetAppState();
|
||||||
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
|
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
|
||||||
const { container } = useExcalidrawContainer();
|
const { container } = useExcalidrawContainer();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ 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 { useSetAtom } from "../editor-jotai";
|
import { jotaiScope } from "../jotai";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { CloseIcon } from "./icons";
|
import { CloseIcon } from "./icons";
|
||||||
|
|
||||||
@@ -91,7 +92,7 @@ export const Dialog = (props: DialogProps) => {
|
|||||||
}, [islandNode, props.autofocus]);
|
}, [islandNode, props.autofocus]);
|
||||||
|
|
||||||
const setAppState = useExcalidrawSetAppState();
|
const setAppState = useExcalidrawSetAppState();
|
||||||
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
|
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
|
||||||
|
|
||||||
const onClose = () => {
|
const onClose = () => {
|
||||||
setAppState({ openMenu: null });
|
setAppState({ openMenu: null });
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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";
|
||||||
@@ -13,7 +14,6 @@ import { useStable } from "../hooks/useStable";
|
|||||||
import "./EyeDropper.scss";
|
import "./EyeDropper.scss";
|
||||||
import type { ColorPickerType } from "./ColorPicker/colorPickerUtils";
|
import type { ColorPickerType } from "./ColorPicker/colorPickerUtils";
|
||||||
import type { ExcalidrawElement } from "../element/types";
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
import { atom } from "../editor-jotai";
|
|
||||||
|
|
||||||
export type EyeDropperProperties = {
|
export type EyeDropperProperties = {
|
||||||
keepOpenOnAlt: boolean;
|
keepOpenOnAlt: boolean;
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import * as Popover from "@radix-ui/react-popover";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
|
|
||||||
|
import "./IconPicker.scss";
|
||||||
import { isArrowKey, KEYS } from "../keys";
|
import { isArrowKey, KEYS } from "../keys";
|
||||||
import { getLanguage, t } from "../i18n";
|
import { getLanguage, t } from "../i18n";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import Collapsible from "./Stats/Collapsible";
|
import Collapsible from "./Stats/Collapsible";
|
||||||
import { atom, useAtom } from "../editor-jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { useDevice } from "./App";
|
import { jotaiScope } from "../jotai";
|
||||||
|
import { useDevice } from "..";
|
||||||
import "./IconPicker.scss";
|
|
||||||
|
|
||||||
const moreOptionsAtom = atom(false);
|
const moreOptionsAtom = atom(false);
|
||||||
|
|
||||||
@@ -93,7 +94,10 @@ function Picker<T>({
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
const [showMoreOptions, setShowMoreOptions] = useAtom(moreOptionsAtom);
|
const [showMoreOptions, setShowMoreOptions] = useAtom(
|
||||||
|
moreOptionsAtom,
|
||||||
|
jotaiScope,
|
||||||
|
);
|
||||||
|
|
||||||
const alwaysVisibleOptions = React.useMemo(
|
const alwaysVisibleOptions = React.useMemo(
|
||||||
() => options.slice(0, numberOfOptionsToAlwaysShow),
|
() => options.slice(0, numberOfOptionsToAlwaysShow),
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ 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 { useAtom, useAtomValue } from "../editor-jotai";
|
import { jotaiScope } from "../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";
|
||||||
@@ -147,9 +148,10 @@ const LayerUI = ({
|
|||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
const tunnels = useInitializeTunnels();
|
const tunnels = useInitializeTunnels();
|
||||||
|
|
||||||
const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider;
|
const [eyeDropperState, setEyeDropperState] = useAtom(
|
||||||
|
activeEyeDropperAtom,
|
||||||
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
|
jotaiScope,
|
||||||
|
);
|
||||||
|
|
||||||
const renderJSONExportDialog = () => {
|
const renderJSONExportDialog = () => {
|
||||||
if (!UIOptions.canvasActions.export) {
|
if (!UIOptions.canvasActions.export) {
|
||||||
@@ -219,7 +221,6 @@ const LayerUI = ({
|
|||||||
appState={appState}
|
appState={appState}
|
||||||
elementsMap={app.scene.getNonDeletedElementsMap()}
|
elementsMap={app.scene.getNonDeletedElementsMap()}
|
||||||
renderAction={actionManager.renderAction}
|
renderAction={actionManager.renderAction}
|
||||||
app={app}
|
|
||||||
/>
|
/>
|
||||||
</Island>
|
</Island>
|
||||||
</Section>
|
</Section>
|
||||||
@@ -381,7 +382,7 @@ const LayerUI = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSidebarDocked = useAtomValue(isSidebarDockedAtom);
|
const isSidebarDocked = useAtomValue(isSidebarDockedAtom, jotaiScope);
|
||||||
|
|
||||||
const layerUIJSX = (
|
const layerUIJSX = (
|
||||||
<>
|
<>
|
||||||
@@ -565,11 +566,11 @@ const LayerUI = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<UIAppStateContext.Provider value={appState}>
|
<UIAppStateContext.Provider value={appState}>
|
||||||
<TunnelsJotaiProvider>
|
<Provider scope={tunnels.jotaiScope}>
|
||||||
<TunnelsContext.Provider value={tunnels}>
|
<TunnelsContext.Provider value={tunnels}>
|
||||||
{layerUIJSX}
|
{layerUIJSX}
|
||||||
</TunnelsContext.Provider>
|
</TunnelsContext.Provider>
|
||||||
</TunnelsJotaiProvider>
|
</Provider>
|
||||||
</UIAppStateContext.Provider>
|
</UIAppStateContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import React, {
|
import React, { useState, useCallback, useMemo, useRef } from "react";
|
||||||
useState,
|
|
||||||
useCallback,
|
|
||||||
useMemo,
|
|
||||||
useEffect,
|
|
||||||
memo,
|
|
||||||
useRef,
|
|
||||||
} from "react";
|
|
||||||
import type Library from "../data/library";
|
import type Library from "../data/library";
|
||||||
import {
|
import {
|
||||||
distributeLibraryItemsOnSquareGrid,
|
distributeLibraryItemsOnSquareGrid,
|
||||||
@@ -18,11 +11,11 @@ import type {
|
|||||||
LibraryItem,
|
LibraryItem,
|
||||||
ExcalidrawProps,
|
ExcalidrawProps,
|
||||||
UIAppState,
|
UIAppState,
|
||||||
AppClassProperties,
|
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import LibraryMenuItems from "./LibraryMenuItems";
|
import LibraryMenuItems from "./LibraryMenuItems";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { atom, useAtom } from "../editor-jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
|
import { jotaiScope } from "../jotai";
|
||||||
import Spinner from "./Spinner";
|
import Spinner from "./Spinner";
|
||||||
import {
|
import {
|
||||||
useApp,
|
useApp,
|
||||||
@@ -35,12 +28,9 @@ import { useUIAppState } from "../context/ui-appState";
|
|||||||
|
|
||||||
import "./LibraryMenu.scss";
|
import "./LibraryMenu.scss";
|
||||||
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
|
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
|
||||||
import type {
|
|
||||||
ExcalidrawElement,
|
|
||||||
NonDeletedExcalidrawElement,
|
|
||||||
} from "../element/types";
|
|
||||||
import { LIBRARY_DISABLED_TYPES } from "../constants";
|
|
||||||
import { isShallowEqual } from "../utils";
|
import { isShallowEqual } from "../utils";
|
||||||
|
import type { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
|
import { LIBRARY_DISABLED_TYPES } from "../constants";
|
||||||
|
|
||||||
export const isLibraryMenuOpenAtom = atom(false);
|
export const isLibraryMenuOpenAtom = atom(false);
|
||||||
|
|
||||||
@@ -48,215 +38,156 @@ const LibraryMenuWrapper = ({ children }: { children: React.ReactNode }) => {
|
|||||||
return <div className="layer-ui__library">{children}</div>;
|
return <div className="layer-ui__library">{children}</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LibraryMenuContent = memo(
|
export const LibraryMenuContent = ({
|
||||||
({
|
onInsertLibraryItems,
|
||||||
onInsertLibraryItems,
|
pendingElements,
|
||||||
pendingElements,
|
onAddToLibrary,
|
||||||
onAddToLibrary,
|
setAppState,
|
||||||
setAppState,
|
libraryReturnUrl,
|
||||||
libraryReturnUrl,
|
library,
|
||||||
library,
|
id,
|
||||||
id,
|
theme,
|
||||||
theme,
|
selectedItems,
|
||||||
selectedItems,
|
onSelectItems,
|
||||||
onSelectItems,
|
}: {
|
||||||
}: {
|
pendingElements: LibraryItem["elements"];
|
||||||
pendingElements: LibraryItem["elements"];
|
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
||||||
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
onAddToLibrary: () => void;
|
||||||
onAddToLibrary: () => void;
|
setAppState: React.Component<any, UIAppState>["setState"];
|
||||||
setAppState: React.Component<any, UIAppState>["setState"];
|
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
library: Library;
|
||||||
library: Library;
|
id: string;
|
||||||
id: string;
|
theme: UIAppState["theme"];
|
||||||
theme: UIAppState["theme"];
|
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"]) => {
|
||||||
const addToLibrary = async (
|
const addToLibrary = async (
|
||||||
processedElements: LibraryItem["elements"],
|
processedElements: LibraryItem["elements"],
|
||||||
libraryItems: LibraryItems,
|
libraryItems: LibraryItems,
|
||||||
) => {
|
) => {
|
||||||
trackEvent("element", "addToLibrary", "ui");
|
trackEvent("element", "addToLibrary", "ui");
|
||||||
for (const type of LIBRARY_DISABLED_TYPES) {
|
for (const type of LIBRARY_DISABLED_TYPES) {
|
||||||
if (processedElements.some((element) => element.type === type)) {
|
if (processedElements.some((element) => element.type === type)) {
|
||||||
return setAppState({
|
return setAppState({
|
||||||
errorMessage: t(`errors.libraryElementTypeError.${type}`),
|
errorMessage: t(`errors.libraryElementTypeError.${type}`),
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const nextItems: LibraryItems = [
|
}
|
||||||
{
|
const nextItems: LibraryItems = [
|
||||||
status: "unpublished",
|
{
|
||||||
elements: processedElements,
|
status: "unpublished",
|
||||||
id: randomId(),
|
elements: processedElements,
|
||||||
created: Date.now(),
|
id: randomId(),
|
||||||
},
|
created: Date.now(),
|
||||||
...libraryItems,
|
},
|
||||||
];
|
...libraryItems,
|
||||||
onAddToLibrary();
|
];
|
||||||
library.setLibrary(nextItems).catch(() => {
|
onAddToLibrary();
|
||||||
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
library.setLibrary(nextItems).catch(() => {
|
||||||
});
|
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
||||||
};
|
});
|
||||||
addToLibrary(elements, libraryItemsData.libraryItems);
|
};
|
||||||
},
|
addToLibrary(elements, libraryItemsData.libraryItems);
|
||||||
[onAddToLibrary, library, setAppState, libraryItemsData.libraryItems],
|
},
|
||||||
);
|
[onAddToLibrary, library, setAppState, libraryItemsData.libraryItems],
|
||||||
|
);
|
||||||
|
|
||||||
const libraryItems = useMemo(
|
const libraryItems = useMemo(
|
||||||
() => libraryItemsData.libraryItems,
|
() => libraryItemsData.libraryItems,
|
||||||
[libraryItemsData],
|
[libraryItemsData],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
|
||||||
libraryItemsData.status === "loading" &&
|
|
||||||
!libraryItemsData.isInitialized
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<LibraryMenuWrapper>
|
|
||||||
<div className="layer-ui__library-message">
|
|
||||||
<div>
|
|
||||||
<Spinner size="2em" />
|
|
||||||
<span>{t("labels.libraryLoadingMessage")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</LibraryMenuWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const showBtn =
|
|
||||||
libraryItemsData.libraryItems.length > 0 || pendingElements.length > 0;
|
|
||||||
|
|
||||||
|
if (
|
||||||
|
libraryItemsData.status === "loading" &&
|
||||||
|
!libraryItemsData.isInitialized
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<LibraryMenuWrapper>
|
<LibraryMenuWrapper>
|
||||||
<LibraryMenuItems
|
<div className="layer-ui__library-message">
|
||||||
isLoading={libraryItemsData.status === "loading"}
|
<div>
|
||||||
libraryItems={libraryItems}
|
<Spinner size="2em" />
|
||||||
onAddToLibrary={_onAddToLibrary}
|
<span>{t("labels.libraryLoadingMessage")}</span>
|
||||||
onInsertLibraryItems={onInsertLibraryItems}
|
</div>
|
||||||
pendingElements={pendingElements}
|
</div>
|
||||||
|
</LibraryMenuWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const showBtn =
|
||||||
|
libraryItemsData.libraryItems.length > 0 || pendingElements.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LibraryMenuWrapper>
|
||||||
|
<LibraryMenuItems
|
||||||
|
isLoading={libraryItemsData.status === "loading"}
|
||||||
|
libraryItems={libraryItems}
|
||||||
|
onAddToLibrary={_onAddToLibrary}
|
||||||
|
onInsertLibraryItems={onInsertLibraryItems}
|
||||||
|
pendingElements={pendingElements}
|
||||||
|
id={id}
|
||||||
|
libraryReturnUrl={libraryReturnUrl}
|
||||||
|
theme={theme}
|
||||||
|
onSelectItems={onSelectItems}
|
||||||
|
selectedItems={selectedItems}
|
||||||
|
/>
|
||||||
|
{showBtn && (
|
||||||
|
<LibraryMenuControlButtons
|
||||||
|
className="library-menu-control-buttons--at-bottom"
|
||||||
|
style={{ padding: "16px 12px 0 12px" }}
|
||||||
id={id}
|
id={id}
|
||||||
libraryReturnUrl={libraryReturnUrl}
|
libraryReturnUrl={libraryReturnUrl}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onSelectItems={onSelectItems}
|
|
||||||
selectedItems={selectedItems}
|
|
||||||
/>
|
/>
|
||||||
{showBtn && (
|
)}
|
||||||
<LibraryMenuControlButtons
|
</LibraryMenuWrapper>
|
||||||
className="library-menu-control-buttons--at-bottom"
|
);
|
||||||
style={{ padding: "16px 12px 0 12px" }}
|
};
|
||||||
id={id}
|
|
||||||
libraryReturnUrl={libraryReturnUrl}
|
|
||||||
theme={theme}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</LibraryMenuWrapper>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const getPendingElements = (
|
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
|
||||||
selectedElementIds: UIAppState["selectedElementIds"],
|
|
||||||
) => ({
|
|
||||||
elements,
|
|
||||||
pending: getSelectedElements(
|
|
||||||
elements,
|
|
||||||
{ selectedElementIds },
|
|
||||||
{
|
|
||||||
includeBoundTextElement: true,
|
|
||||||
includeElementsInFrames: true,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
selectedElementIds,
|
|
||||||
});
|
|
||||||
|
|
||||||
const usePendingElementsMemo = (
|
const usePendingElementsMemo = (
|
||||||
appState: UIAppState,
|
appState: UIAppState,
|
||||||
app: AppClassProperties,
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
) => {
|
) => {
|
||||||
const elements = useExcalidrawElements();
|
const create = () =>
|
||||||
const [state, setState] = useState(() =>
|
getSelectedElements(elements, appState, {
|
||||||
getPendingElements(elements, appState.selectedElementIds),
|
includeBoundTextElement: true,
|
||||||
);
|
includeElementsInFrames: true,
|
||||||
|
});
|
||||||
|
const val = useRef(create());
|
||||||
|
const prevAppState = useRef<UIAppState>(appState);
|
||||||
|
const prevElements = useRef(elements);
|
||||||
|
|
||||||
const selectedElementVersions = useRef(
|
if (
|
||||||
new Map<ExcalidrawElement["id"], ExcalidrawElement["version"]>(),
|
!isShallowEqual(
|
||||||
);
|
appState.selectedElementIds,
|
||||||
|
prevAppState.current.selectedElementIds,
|
||||||
useEffect(() => {
|
) ||
|
||||||
for (const element of state.pending) {
|
!isShallowEqual(elements, prevElements.current)
|
||||||
selectedElementVersions.current.set(element.id, element.version);
|
) {
|
||||||
}
|
val.current = create();
|
||||||
}, [state.pending]);
|
prevAppState.current = appState;
|
||||||
|
prevElements.current = elements;
|
||||||
useEffect(() => {
|
}
|
||||||
if (
|
return val.current;
|
||||||
// Only update once pointer is released.
|
|
||||||
// Reading directly from app.state to make it clear it's not reactive
|
|
||||||
// (hence, there's potential for stale state)
|
|
||||||
app.state.cursorButton === "up" &&
|
|
||||||
app.state.activeTool.type === "selection"
|
|
||||||
) {
|
|
||||||
setState((prev) => {
|
|
||||||
// if selectedElementIds changed, we don't have to compare versions
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
if (
|
|
||||||
!isShallowEqual(prev.selectedElementIds, appState.selectedElementIds)
|
|
||||||
) {
|
|
||||||
selectedElementVersions.current.clear();
|
|
||||||
return getPendingElements(elements, appState.selectedElementIds);
|
|
||||||
}
|
|
||||||
// otherwise we need to check whether selected elements changed
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
|
||||||
for (const id of Object.keys(appState.selectedElementIds)) {
|
|
||||||
const currVersion = elementsMap.get(id)?.version;
|
|
||||||
if (
|
|
||||||
currVersion &&
|
|
||||||
currVersion !== selectedElementVersions.current.get(id)
|
|
||||||
) {
|
|
||||||
// we can't update the selectedElementVersions in here
|
|
||||||
// because of double render in StrictMode which would overwrite
|
|
||||||
// the state in the second pass with the old `prev` state.
|
|
||||||
// Thus, we update versions in a separate effect. May create
|
|
||||||
// a race condition since current effect is not fully reactive.
|
|
||||||
return getPendingElements(elements, appState.selectedElementIds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// nothing changed
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
app,
|
|
||||||
app.state.cursorButton,
|
|
||||||
app.state.activeTool.type,
|
|
||||||
appState.selectedElementIds,
|
|
||||||
elements,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return state.pending;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This component is meant to be rendered inside <Sidebar.Tab/> inside our
|
* This component is meant to be rendered inside <Sidebar.Tab/> inside our
|
||||||
* <DefaultSidebar/> or host apps Sidebar components.
|
* <DefaultSidebar/> or host apps Sidebar components.
|
||||||
*/
|
*/
|
||||||
export const LibraryMenu = memo(() => {
|
export const LibraryMenu = () => {
|
||||||
const app = useApp();
|
const { library, id, onInsertElements } = useApp();
|
||||||
const { onInsertElements } = app;
|
|
||||||
const appProps = useAppProps();
|
const appProps = useAppProps();
|
||||||
const appState = useUIAppState();
|
const appState = useUIAppState();
|
||||||
const setAppState = useExcalidrawSetAppState();
|
const setAppState = useExcalidrawSetAppState();
|
||||||
|
const elements = useExcalidrawElements();
|
||||||
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
||||||
const memoizedLibrary = useMemo(() => app.library, [app.library]);
|
const memoizedLibrary = useMemo(() => library, [library]);
|
||||||
const pendingElements = usePendingElementsMemo(appState, app);
|
// BUG: pendingElements are still causing some unnecessary rerenders because clicking into canvas returns some ids even when no element is selected.
|
||||||
|
const pendingElements = usePendingElementsMemo(appState, elements);
|
||||||
|
|
||||||
const onInsertLibraryItems = useCallback(
|
const onInsertLibraryItems = useCallback(
|
||||||
(libraryItems: LibraryItems) => {
|
(libraryItems: LibraryItems) => {
|
||||||
@@ -281,10 +212,10 @@ export const LibraryMenu = memo(() => {
|
|||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
libraryReturnUrl={appProps.libraryReturnUrl}
|
libraryReturnUrl={appProps.libraryReturnUrl}
|
||||||
library={memoizedLibrary}
|
library={memoizedLibrary}
|
||||||
id={app.id}
|
id={id}
|
||||||
theme={appState.theme}
|
theme={appState.theme}
|
||||||
selectedItems={selectedItems}
|
selectedItems={selectedItems}
|
||||||
onSelectItems={setSelectedItems}
|
onSelectItems={setSelectedItems}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|||||||
@@ -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 { useAtom } from "../editor-jotai";
|
import { jotaiScope } from "../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,6 +17,7 @@ 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";
|
||||||
@@ -50,9 +51,10 @@ export const LibraryDropdownMenuButton: React.FC<{
|
|||||||
appState,
|
appState,
|
||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
const [libraryItemsData] = useAtom(libraryItemsAtom);
|
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
||||||
const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom(
|
const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom(
|
||||||
isLibraryMenuOpenAtom,
|
isLibraryMenuOpenAtom,
|
||||||
|
jotaiScope,
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderRemoveLibAlert = () => {
|
const renderRemoveLibAlert = () => {
|
||||||
@@ -284,7 +286,7 @@ export const LibraryDropdownMenu = ({
|
|||||||
const appState = useUIAppState();
|
const appState = useUIAppState();
|
||||||
const setAppState = useExcalidrawSetAppState();
|
const setAppState = useExcalidrawSetAppState();
|
||||||
|
|
||||||
const [libraryItemsData] = useAtom(libraryItemsAtom);
|
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
||||||
|
|
||||||
const removeFromLibrary = async (libraryItems: LibraryItems) => {
|
const removeFromLibrary = async (libraryItems: LibraryItems) => {
|
||||||
const nextItems = libraryItems.filter(
|
const nextItems = libraryItems.filter(
|
||||||
|
|||||||
@@ -179,7 +179,6 @@ export const MobileMenu = ({
|
|||||||
appState={appState}
|
appState={appState}
|
||||||
elementsMap={app.scene.getNonDeletedElementsMap()}
|
elementsMap={app.scene.getNonDeletedElementsMap()}
|
||||||
renderAction={actionManager.renderAction}
|
renderAction={actionManager.renderAction}
|
||||||
app={app}
|
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
|
||||||
import { useTunnels } from "../../context/tunnels";
|
import { useTunnels } from "../../context/tunnels";
|
||||||
import { useAtom } from "../../editor-jotai";
|
import { jotaiScope } from "../../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";
|
||||||
@@ -22,6 +23,7 @@ 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,4 +1,5 @@
|
|||||||
import { atom, editorJotaiStore } from "../../editor-jotai";
|
import { atom } from "jotai";
|
||||||
|
import { jotaiStore } from "../../jotai";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
|
||||||
export type OverwriteConfirmState =
|
export type OverwriteConfirmState =
|
||||||
@@ -31,7 +32,7 @@ export async function openConfirmModal({
|
|||||||
color: "danger" | "warning";
|
color: "danger" | "warning";
|
||||||
}) {
|
}) {
|
||||||
return new Promise<boolean>((resolve) => {
|
return new Promise<boolean>((resolve) => {
|
||||||
editorJotaiStore.set(overwriteConfirmStateAtom, {
|
jotaiStore.set(overwriteConfirmStateAtom, {
|
||||||
active: true,
|
active: true,
|
||||||
onConfirm: () => resolve(true),
|
onConfirm: () => resolve(true),
|
||||||
onClose: () => resolve(false),
|
onClose: () => resolve(false),
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
@import "../css/variables.module.scss";
|
|
||||||
|
|
||||||
.excalidraw {
|
|
||||||
--slider-thumb-size: 16px;
|
|
||||||
|
|
||||||
.range-wrapper {
|
|
||||||
position: relative;
|
|
||||||
padding-top: 10px;
|
|
||||||
padding-bottom: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.range-input {
|
|
||||||
width: 100%;
|
|
||||||
height: 4px;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
background: var(--color-slider-track);
|
|
||||||
border-radius: 2px;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.range-input::-webkit-slider-thumb {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
width: var(--slider-thumb-size);
|
|
||||||
height: var(--slider-thumb-size);
|
|
||||||
background: var(--color-slider-thumb);
|
|
||||||
border-radius: 50%;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.range-input::-moz-range-thumb {
|
|
||||||
width: var(--slider-thumb-size);
|
|
||||||
height: var(--slider-thumb-size);
|
|
||||||
background: var(--color-slider-thumb);
|
|
||||||
border-radius: 50%;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value-bubble {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.zero-label {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-primary-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import React, { useEffect } from "react";
|
|
||||||
import { getFormValue } from "../actions/actionProperties";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
import "./Range.scss";
|
|
||||||
|
|
||||||
export type RangeProps = {
|
|
||||||
updateData: (value: number) => void;
|
|
||||||
appState: any;
|
|
||||||
elements: any;
|
|
||||||
testId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Range = ({
|
|
||||||
updateData,
|
|
||||||
appState,
|
|
||||||
elements,
|
|
||||||
testId,
|
|
||||||
}: RangeProps) => {
|
|
||||||
const rangeRef = React.useRef<HTMLInputElement>(null);
|
|
||||||
const valueRef = React.useRef<HTMLDivElement>(null);
|
|
||||||
const value = getFormValue(
|
|
||||||
elements,
|
|
||||||
appState,
|
|
||||||
(element) => element.opacity,
|
|
||||||
true,
|
|
||||||
appState.currentItemOpacity,
|
|
||||||
);
|
|
||||||
useEffect(() => {
|
|
||||||
if (rangeRef.current && valueRef.current) {
|
|
||||||
const rangeElement = rangeRef.current;
|
|
||||||
const valueElement = valueRef.current;
|
|
||||||
const inputWidth = rangeElement.offsetWidth;
|
|
||||||
const thumbWidth = 15; // 15 is the width of the thumb
|
|
||||||
const position =
|
|
||||||
(value / 100) * (inputWidth - thumbWidth) + thumbWidth / 2;
|
|
||||||
valueElement.style.left = `${position}px`;
|
|
||||||
rangeElement.style.background = `linear-gradient(to right, var(--color-slider-track) 0%, var(--color-slider-track) ${value}%, var(--button-bg) ${value}%, var(--button-bg) 100%)`;
|
|
||||||
}
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label className="control-label">
|
|
||||||
{t("labels.opacity")}
|
|
||||||
<div className="range-wrapper">
|
|
||||||
<input
|
|
||||||
ref={rangeRef}
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
step="10"
|
|
||||||
onChange={(event) => {
|
|
||||||
updateData(+event.target.value);
|
|
||||||
}}
|
|
||||||
value={value}
|
|
||||||
className="range-input"
|
|
||||||
data-testid={testId}
|
|
||||||
/>
|
|
||||||
<div className="value-bubble" ref={valueRef}>
|
|
||||||
{value !== 0 ? value : null}
|
|
||||||
</div>
|
|
||||||
<div className="zero-label">0</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -7,10 +7,12 @@ import { debounce } from "lodash";
|
|||||||
import type { AppClassProperties } from "../types";
|
import type { AppClassProperties } from "../types";
|
||||||
import { isTextElement, newTextElement } from "../element";
|
import { isTextElement, newTextElement } from "../element";
|
||||||
import type { ExcalidrawTextElement } from "../element/types";
|
import type { ExcalidrawTextElement } from "../element/types";
|
||||||
|
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 "../editor-jotai";
|
import { atom, useAtom } from "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";
|
||||||
@@ -19,7 +21,6 @@ import { useStable } from "../hooks/useStable";
|
|||||||
|
|
||||||
import "./SearchMenu.scss";
|
import "./SearchMenu.scss";
|
||||||
import { round } from "../../math";
|
import { round } from "../../math";
|
||||||
import { measureText } from "../element/textMeasurements";
|
|
||||||
|
|
||||||
const searchQueryAtom = atom<string>("");
|
const searchQueryAtom = atom<string>("");
|
||||||
export const searchItemInFocusAtom = atom<number | null>(null);
|
export const searchItemInFocusAtom = atom<number | null>(null);
|
||||||
@@ -57,7 +58,7 @@ export const SearchMenu = () => {
|
|||||||
|
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [inputValue, setInputValue] = useAtom(searchQueryAtom);
|
const [inputValue, setInputValue] = useAtom(searchQueryAtom, jotaiScope);
|
||||||
const searchQuery = inputValue.trim() as SearchQuery;
|
const searchQuery = inputValue.trim() as SearchQuery;
|
||||||
|
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
@@ -69,7 +70,10 @@ 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(searchItemInFocusAtom);
|
const [focusIndex, setFocusIndex] = useAtom(
|
||||||
|
searchItemInFocusAtom,
|
||||||
|
jotaiScope,
|
||||||
|
);
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -607,6 +611,7 @@ const getMatchedLines = (
|
|||||||
textToStart,
|
textToStart,
|
||||||
getFontString(textElement),
|
getFontString(textElement),
|
||||||
textElement.lineHeight,
|
textElement.lineHeight,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
// measureText returns a non-zero width for the empty string
|
// measureText returns a non-zero width for the empty string
|
||||||
@@ -620,6 +625,7 @@ const getMatchedLines = (
|
|||||||
lineIndexRange.line,
|
lineIndexRange.line,
|
||||||
getFontString(textElement),
|
getFontString(textElement),
|
||||||
textElement.lineHeight,
|
textElement.lineHeight,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
const spaceToStart =
|
const spaceToStart =
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import React, {
|
|||||||
useCallback,
|
useCallback,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Island } from "../Island";
|
import { Island } from "../Island";
|
||||||
import { atom, useSetAtom } from "../../editor-jotai";
|
import { atom, useSetAtom } from "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";
|
||||||
@@ -57,7 +58,7 @@ export const SidebarInner = forwardRef(
|
|||||||
|
|
||||||
const setAppState = useExcalidrawSetAppState();
|
const setAppState = useExcalidrawSetAppState();
|
||||||
|
|
||||||
const setIsSidebarDockedAtom = useSetAtom(isSidebarDockedAtom);
|
const setIsSidebarDockedAtom = useSetAtom(isSidebarDockedAtom, jotaiScope);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
setIsSidebarDockedAtom(!!docked);
|
setIsSidebarDockedAtom(!!docked);
|
||||||
|
|||||||
@@ -237,7 +237,6 @@ const MultiPosition = ({
|
|||||||
const [x1, y1] = getCommonBounds(elementsInUnit);
|
const [x1, y1] = getCommonBounds(elementsInUnit);
|
||||||
return Math.round((property === "x" ? x1 : y1) * 100) / 100;
|
return Math.round((property === "x" ? x1 : y1) * 100) / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [el] = elementsInUnit;
|
const [el] = elementsInUnit;
|
||||||
const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
|
const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import "./Stats.scss";
|
|||||||
import { isGridModeEnabled } from "../../snapping";
|
import { isGridModeEnabled } from "../../snapping";
|
||||||
import { getUncroppedWidthAndHeight } from "../../element/cropElement";
|
import { getUncroppedWidthAndHeight } from "../../element/cropElement";
|
||||||
import { round } from "../../../math";
|
import { round } from "../../../math";
|
||||||
import { frameAndChildrenSelectedTogether } from "../../frame";
|
|
||||||
|
|
||||||
interface StatsProps {
|
interface StatsProps {
|
||||||
app: AppClassProperties;
|
app: AppClassProperties;
|
||||||
@@ -171,10 +170,6 @@ export const StatsInner = memo(
|
|||||||
return getAtomicUnits(selectedElements, appState);
|
return getAtomicUnits(selectedElements, appState);
|
||||||
}, [selectedElements, appState]);
|
}, [selectedElements, appState]);
|
||||||
|
|
||||||
const _frameAndChildrenSelectedTogether = useMemo(() => {
|
|
||||||
return frameAndChildrenSelectedTogether(selectedElements);
|
|
||||||
}, [selectedElements]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="exc-stats">
|
<div className="exc-stats">
|
||||||
<Island padding={3}>
|
<Island padding={3}>
|
||||||
@@ -231,7 +226,7 @@ export const StatsInner = memo(
|
|||||||
{renderCustomStats?.(elements, appState)}
|
{renderCustomStats?.(elements, appState)}
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
{!_frameAndChildrenSelectedTogether && selectedElements.length > 0 && (
|
{selectedElements.length > 0 && (
|
||||||
<div
|
<div
|
||||||
id="elementStats"
|
id="elementStats"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import type { BinaryFiles } from "../../types";
|
|||||||
import { ArrowRightIcon } from "../icons";
|
import { ArrowRightIcon } from "../icons";
|
||||||
|
|
||||||
import "./TTDDialog.scss";
|
import "./TTDDialog.scss";
|
||||||
import { atom, useAtom } from "../../editor-jotai";
|
import { atom, useAtom } from "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";
|
||||||
|
|||||||
@@ -55,152 +55,146 @@ type ToolButtonProps =
|
|||||||
onPointerDown?(data: { pointerType: PointerType }): void;
|
onPointerDown?(data: { pointerType: PointerType }): void;
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ToolButton = React.forwardRef(
|
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||||
(
|
const { id: excalId } = useExcalidrawContainer();
|
||||||
{
|
const innerRef = React.useRef(null);
|
||||||
size = "medium",
|
React.useImperativeHandle(ref, () => innerRef.current);
|
||||||
visible = true,
|
const sizeCn = `ToolIcon_size_${props.size}`;
|
||||||
className = "",
|
|
||||||
...props
|
|
||||||
}: ToolButtonProps,
|
|
||||||
ref,
|
|
||||||
) => {
|
|
||||||
const { id: excalId } = useExcalidrawContainer();
|
|
||||||
const innerRef = React.useRef(null);
|
|
||||||
React.useImperativeHandle(ref, () => innerRef.current);
|
|
||||||
const sizeCn = `ToolIcon_size_${size}`;
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const isMountedRef = useRef(true);
|
const isMountedRef = useRef(true);
|
||||||
|
|
||||||
const onClick = async (event: React.MouseEvent) => {
|
const onClick = async (event: React.MouseEvent) => {
|
||||||
const ret = "onClick" in props && props.onClick?.(event);
|
const ret = "onClick" in props && props.onClick?.(event);
|
||||||
|
|
||||||
if (isPromiseLike(ret)) {
|
if (isPromiseLike(ret)) {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await ret;
|
await ret;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (!(error instanceof AbortError)) {
|
if (!(error instanceof AbortError)) {
|
||||||
throw error;
|
throw error;
|
||||||
} else {
|
} else {
|
||||||
console.warn(error);
|
console.warn(error);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (isMountedRef.current) {
|
if (isMountedRef.current) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
isMountedRef.current = true;
|
|
||||||
return () => {
|
|
||||||
isMountedRef.current = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const lastPointerTypeRef = useRef<PointerType | null>(null);
|
|
||||||
|
|
||||||
if (
|
|
||||||
props.type === "button" ||
|
|
||||||
props.type === "icon" ||
|
|
||||||
props.type === "submit"
|
|
||||||
) {
|
|
||||||
const type = (props.type === "icon" ? "button" : props.type) as
|
|
||||||
| "button"
|
|
||||||
| "submit";
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={clsx(
|
|
||||||
"ToolIcon_type_button",
|
|
||||||
sizeCn,
|
|
||||||
className,
|
|
||||||
visible && !props.hidden
|
|
||||||
? "ToolIcon_type_button--show"
|
|
||||||
: "ToolIcon_type_button--hide",
|
|
||||||
{
|
|
||||||
ToolIcon: !props.hidden,
|
|
||||||
"ToolIcon--selected": props.selected,
|
|
||||||
"ToolIcon--plain": props.type === "icon",
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
style={props.style}
|
|
||||||
data-testid={props["data-testid"]}
|
|
||||||
hidden={props.hidden}
|
|
||||||
title={props.title}
|
|
||||||
aria-label={props["aria-label"]}
|
|
||||||
type={type}
|
|
||||||
onClick={onClick}
|
|
||||||
ref={innerRef}
|
|
||||||
disabled={isLoading || props.isLoading || !!props.disabled}
|
|
||||||
>
|
|
||||||
{(props.icon || props.label) && (
|
|
||||||
<div
|
|
||||||
className="ToolIcon__icon"
|
|
||||||
aria-hidden="true"
|
|
||||||
aria-disabled={!!props.disabled}
|
|
||||||
>
|
|
||||||
{props.icon || props.label}
|
|
||||||
{props.keyBindingLabel && (
|
|
||||||
<span className="ToolIcon__keybinding">
|
|
||||||
{props.keyBindingLabel}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{props.isLoading && <Spinner />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{props.showAriaLabel && (
|
|
||||||
<div className="ToolIcon__label">
|
|
||||||
{props["aria-label"]} {isLoading && <Spinner />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{props.children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isMountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const lastPointerTypeRef = useRef<PointerType | null>(null);
|
||||||
|
|
||||||
|
if (
|
||||||
|
props.type === "button" ||
|
||||||
|
props.type === "icon" ||
|
||||||
|
props.type === "submit"
|
||||||
|
) {
|
||||||
|
const type = (props.type === "icon" ? "button" : props.type) as
|
||||||
|
| "button"
|
||||||
|
| "submit";
|
||||||
return (
|
return (
|
||||||
<label
|
<button
|
||||||
className={clsx("ToolIcon", className)}
|
className={clsx(
|
||||||
|
"ToolIcon_type_button",
|
||||||
|
sizeCn,
|
||||||
|
props.className,
|
||||||
|
props.visible && !props.hidden
|
||||||
|
? "ToolIcon_type_button--show"
|
||||||
|
: "ToolIcon_type_button--hide",
|
||||||
|
{
|
||||||
|
ToolIcon: !props.hidden,
|
||||||
|
"ToolIcon--selected": props.selected,
|
||||||
|
"ToolIcon--plain": props.type === "icon",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
style={props.style}
|
||||||
|
data-testid={props["data-testid"]}
|
||||||
|
hidden={props.hidden}
|
||||||
title={props.title}
|
title={props.title}
|
||||||
onPointerDown={(event) => {
|
aria-label={props["aria-label"]}
|
||||||
lastPointerTypeRef.current = event.pointerType || null;
|
type={type}
|
||||||
props.onPointerDown?.({ pointerType: event.pointerType || null });
|
onClick={onClick}
|
||||||
}}
|
ref={innerRef}
|
||||||
onPointerUp={() => {
|
disabled={isLoading || props.isLoading || !!props.disabled}
|
||||||
requestAnimationFrame(() => {
|
|
||||||
lastPointerTypeRef.current = null;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<input
|
{(props.icon || props.label) && (
|
||||||
className={`ToolIcon_type_radio ${sizeCn}`}
|
<div
|
||||||
type="radio"
|
className="ToolIcon__icon"
|
||||||
name={props.name}
|
aria-hidden="true"
|
||||||
aria-label={props["aria-label"]}
|
aria-disabled={!!props.disabled}
|
||||||
aria-keyshortcuts={props["aria-keyshortcuts"]}
|
>
|
||||||
data-testid={props["data-testid"]}
|
{props.icon || props.label}
|
||||||
id={`${excalId}-${props.id}`}
|
{props.keyBindingLabel && (
|
||||||
onChange={() => {
|
<span className="ToolIcon__keybinding">
|
||||||
props.onChange?.({ pointerType: lastPointerTypeRef.current });
|
{props.keyBindingLabel}
|
||||||
}}
|
</span>
|
||||||
checked={props.checked}
|
)}
|
||||||
ref={innerRef}
|
{props.isLoading && <Spinner />}
|
||||||
/>
|
</div>
|
||||||
<div className="ToolIcon__icon">
|
)}
|
||||||
{props.icon}
|
{props.showAriaLabel && (
|
||||||
{props.keyBindingLabel && (
|
<div className="ToolIcon__label">
|
||||||
<span className="ToolIcon__keybinding">
|
{props["aria-label"]} {isLoading && <Spinner />}
|
||||||
{props.keyBindingLabel}
|
</div>
|
||||||
</span>
|
)}
|
||||||
)}
|
{props.children}
|
||||||
</div>
|
</button>
|
||||||
</label>
|
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
);
|
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className={clsx("ToolIcon", props.className)}
|
||||||
|
title={props.title}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
lastPointerTypeRef.current = event.pointerType || null;
|
||||||
|
props.onPointerDown?.({ pointerType: event.pointerType || null });
|
||||||
|
}}
|
||||||
|
onPointerUp={() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
lastPointerTypeRef.current = null;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className={`ToolIcon_type_radio ${sizeCn}`}
|
||||||
|
type="radio"
|
||||||
|
name={props.name}
|
||||||
|
aria-label={props["aria-label"]}
|
||||||
|
aria-keyshortcuts={props["aria-keyshortcuts"]}
|
||||||
|
data-testid={props["data-testid"]}
|
||||||
|
id={`${excalId}-${props.id}`}
|
||||||
|
onChange={() => {
|
||||||
|
props.onChange?.({ pointerType: lastPointerTypeRef.current });
|
||||||
|
}}
|
||||||
|
checked={props.checked}
|
||||||
|
ref={innerRef}
|
||||||
|
/>
|
||||||
|
<div className="ToolIcon__icon">
|
||||||
|
{props.icon}
|
||||||
|
{props.keyBindingLabel && (
|
||||||
|
<span className="ToolIcon__keybinding">{props.keyBindingLabel}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ToolButton.defaultProps = {
|
||||||
|
visible: true,
|
||||||
|
className: "",
|
||||||
|
size: "medium",
|
||||||
|
};
|
||||||
|
|
||||||
ToolButton.displayName = "ToolButton";
|
ToolButton.displayName = "ToolButton";
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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", () => {
|
||||||
@@ -18,7 +17,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}
|
||||||
@@ -52,7 +51,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,11 +13,9 @@ export const withInternalFallback = <P,>(
|
|||||||
__fallback?: boolean;
|
__fallback?: boolean;
|
||||||
}
|
}
|
||||||
> = (props) => {
|
> = (props) => {
|
||||||
const {
|
const { jotaiScope } = useTunnels();
|
||||||
tunnelsJotai: { useAtom },
|
|
||||||
} = useTunnels();
|
|
||||||
// for rerenders
|
// for rerenders
|
||||||
const [, setCounter] = useAtom(renderAtom);
|
const [, setCounter] = useAtom(renderAtom, jotaiScope);
|
||||||
// for initial & subsequent renders. Tracked as component state
|
// for initial & subsequent renders. Tracked as component state
|
||||||
// due to excalidraw multi-instance scanerios.
|
// due to excalidraw multi-instance scanerios.
|
||||||
const metaRef = useRef({
|
const metaRef = useRef({
|
||||||
|
|||||||
@@ -171,17 +171,15 @@ export const Hyperlink = ({
|
|||||||
}, [handleSubmit]);
|
}, [handleSubmit]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let timeoutId: number | null = null;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isEditing &&
|
inputRef &&
|
||||||
inputRef?.current &&
|
inputRef.current &&
|
||||||
!(device.viewport.isMobile || device.isTouchScreen)
|
!(device.viewport.isMobile || device.isTouchScreen)
|
||||||
) {
|
) {
|
||||||
inputRef.current.select();
|
inputRef.current.select();
|
||||||
}
|
}
|
||||||
}, [isEditing, device.viewport.isMobile, device.isTouchScreen]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let timeoutId: number | null = null;
|
|
||||||
|
|
||||||
const handlePointerMove = (event: PointerEvent) => {
|
const handlePointerMove = (event: PointerEvent) => {
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
@@ -209,7 +207,15 @@ export const Hyperlink = ({
|
|||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [appState, element, isEditing, setAppState, elementsMap]);
|
}, [
|
||||||
|
appState,
|
||||||
|
element,
|
||||||
|
isEditing,
|
||||||
|
setAppState,
|
||||||
|
elementsMap,
|
||||||
|
device.viewport.isMobile,
|
||||||
|
device.isTouchScreen,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleRemove = useCallback(() => {
|
const handleRemove = useCallback(() => {
|
||||||
trackEvent("hyperlink", "delete");
|
trackEvent("hyperlink", "delete");
|
||||||
|
|||||||
@@ -1216,12 +1216,11 @@ export const EdgeRoundIcon = createIcon(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const ArrowheadNoneIcon = createIcon(
|
export const ArrowheadNoneIcon = createIcon(
|
||||||
<g stroke="currentColor" opacity={0.3} strokeWidth={2}>
|
<path d="M6 10H34" stroke="currentColor" strokeWidth={2} fill="none" />,
|
||||||
<path d="M12 12l9 0" />
|
{
|
||||||
<path d="M3 9l6 6" />
|
width: 40,
|
||||||
<path d="M3 15l6 -6" />
|
height: 20,
|
||||||
</g>,
|
},
|
||||||
tablerIconProps,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ArrowheadArrowIcon = React.memo(
|
export const ArrowheadArrowIcon = React.memo(
|
||||||
|
|||||||
@@ -32,8 +32,9 @@ 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 { useSetAtom } from "../../editor-jotai";
|
import { jotaiScope } from "../../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";
|
||||||
@@ -188,7 +189,10 @@ Help.displayName = "Help";
|
|||||||
export const ClearCanvas = () => {
|
export const ClearCanvas = () => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const setActiveConfirmDialog = useSetAtom(activeConfirmDialogAtom);
|
const setActiveConfirmDialog = useSetAtom(
|
||||||
|
activeConfirmDialogAtom,
|
||||||
|
jotaiScope,
|
||||||
|
);
|
||||||
const actionManager = useExcalidrawActionManager();
|
const actionManager = useExcalidrawActionManager();
|
||||||
|
|
||||||
if (!actionManager.isActionEnabled(actionClearCanvas)) {
|
if (!actionManager.isActionEnabled(actionClearCanvas)) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.excalidraw {
|
.excalidraw {
|
||||||
.excalifont {
|
.excalifont {
|
||||||
font-family: "Excalifont", "Xiaolai";
|
font-family: "Excalifont";
|
||||||
}
|
}
|
||||||
|
|
||||||
// WelcomeSreen common
|
// WelcomeSreen common
|
||||||
|
|||||||
@@ -458,6 +458,3 @@ export const ARROW_TYPE: { [T in AppState["currentItemArrowType"]]: T } = {
|
|||||||
|
|
||||||
export const DEFAULT_REDUCED_GLOBAL_ALPHA = 0.3;
|
export const DEFAULT_REDUCED_GLOBAL_ALPHA = 0.3;
|
||||||
export const ELEMENT_LINK_KEY = "element";
|
export const ELEMENT_LINK_KEY = "element";
|
||||||
|
|
||||||
/** used in tests */
|
|
||||||
export const ORIG_ID = Symbol.for("__test__originalId__");
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
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>;
|
||||||
|
|
||||||
@@ -15,17 +14,13 @@ type TunnelsContextValue = {
|
|||||||
DefaultSidebarTabTriggersTunnel: Tunnel;
|
DefaultSidebarTabTriggersTunnel: Tunnel;
|
||||||
OverwriteConfirmDialogTunnel: Tunnel;
|
OverwriteConfirmDialogTunnel: Tunnel;
|
||||||
TTDDialogTriggerTunnel: Tunnel;
|
TTDDialogTriggerTunnel: Tunnel;
|
||||||
// this can be removed once we create jotai stores per each editor
|
jotaiScope: symbol;
|
||||||
// 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 {
|
||||||
@@ -39,7 +34,7 @@ export const useInitializeTunnels = () => {
|
|||||||
DefaultSidebarTabTriggersTunnel: tunnel(),
|
DefaultSidebarTabTriggersTunnel: tunnel(),
|
||||||
OverwriteConfirmDialogTunnel: tunnel(),
|
OverwriteConfirmDialogTunnel: tunnel(),
|
||||||
TTDDialogTriggerTunnel: tunnel(),
|
TTDDialogTriggerTunnel: tunnel(),
|
||||||
tunnelsJotai,
|
jotaiScope: Symbol(),
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -649,21 +649,15 @@ body.excalidraw-cursor-resize * {
|
|||||||
@include filledButtonOnCanvas;
|
@include filledButtonOnCanvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
.App-mobile-menu,
|
|
||||||
.App-menu__left {
|
.App-menu__left {
|
||||||
--button-border: transparent;
|
--button-border: transparent;
|
||||||
--button-bg: var(--color-surface-mid);
|
--button-bg: var(--color-surface-mid);
|
||||||
}
|
|
||||||
|
|
||||||
@at-root .excalidraw.theme--dark#{&} {
|
@at-root .excalidraw.theme--dark#{&} {
|
||||||
.App-mobile-menu,
|
|
||||||
.App-menu__left {
|
|
||||||
--button-hover-bg: #363541;
|
--button-hover-bg: #363541;
|
||||||
--button-bg: var(--color-surface-high);
|
--button-bg: var(--color-surface-high);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.App-menu__left {
|
|
||||||
.buttonList {
|
.buttonList {
|
||||||
padding: 0.25rem 0;
|
padding: 0.25rem 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,9 +53,6 @@
|
|||||||
--scrollbar-thumb: var(--button-gray-2);
|
--scrollbar-thumb: var(--button-gray-2);
|
||||||
--scrollbar-thumb-hover: var(--button-gray-3);
|
--scrollbar-thumb-hover: var(--button-gray-3);
|
||||||
|
|
||||||
--color-slider-track: hsl(240, 100%, 90%);
|
|
||||||
--color-slider-thumb: var(--color-gray-80);
|
|
||||||
|
|
||||||
--modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07),
|
--modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07),
|
||||||
0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198),
|
0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198),
|
||||||
0px 22.3363px 17.869px rgba(0, 0, 0, 0.0417275),
|
0px 22.3363px 17.869px rgba(0, 0, 0, 0.0417275),
|
||||||
@@ -210,8 +207,6 @@
|
|||||||
--scrollbar-thumb: #{$oc-gray-8};
|
--scrollbar-thumb: #{$oc-gray-8};
|
||||||
--scrollbar-thumb-hover: #{$oc-gray-7};
|
--scrollbar-thumb-hover: #{$oc-gray-7};
|
||||||
|
|
||||||
--color-slider-track: hsl(244, 23%, 39%);
|
|
||||||
|
|
||||||
// will be inverted to a lighter color.
|
// will be inverted to a lighter color.
|
||||||
--color-selection: #3530c4;
|
--color-selection: #3530c4;
|
||||||
|
|
||||||
|
|||||||
@@ -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": 35,
|
"height": 33.519031369643244,
|
||||||
"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,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
394.5,
|
382.47606040672997,
|
||||||
34.5,
|
34.019031369643244,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
"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": 4,
|
"version": 7,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 395,
|
"width": 381.97606040672997,
|
||||||
"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,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
399.5,
|
389.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": 4,
|
"version": 6,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 400,
|
"width": 390,
|
||||||
"x": 227,
|
"x": 237,
|
||||||
"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": 226.5,
|
"y": 236.95454545454544,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -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": 0,
|
"focus": 1.625925925925924,
|
||||||
"gap": 14,
|
"gap": 14,
|
||||||
},
|
},
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
"height": 0,
|
"height": 18.278619528619487,
|
||||||
"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,
|
-0.5,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
99.5,
|
357.2037037037038,
|
||||||
0,
|
-17.778619528619487,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
"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": 4,
|
"version": 6,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 100,
|
"width": 357.7037037037038,
|
||||||
"x": 255,
|
"x": 171,
|
||||||
"y": 239,
|
"y": 249.45454545454544,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -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": 4,
|
"version": 6,
|
||||||
"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": 4,
|
"version": 6,
|
||||||
"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,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
272.485,
|
270.98528125,
|
||||||
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": 4,
|
"version": 7,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 272.985,
|
"width": 270.48528125,
|
||||||
"x": 111.262,
|
"x": 112.76171875,
|
||||||
"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": 4,
|
"version": 6,
|
||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"width": 0,
|
"width": 0,
|
||||||
"x": 77.017,
|
"x": 83.015625,
|
||||||
"y": 79,
|
"y": 81.5,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
import { validateLibraryUrl } from "./library";
|
|
||||||
|
|
||||||
describe("validateLibraryUrl", () => {
|
|
||||||
it("should validate hostname & pathname", () => {
|
|
||||||
// valid hostnames
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
expect(
|
|
||||||
validateLibraryUrl("https://www.excalidraw.com", ["excalidraw.com"]),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
validateLibraryUrl("https://excalidraw.com", ["excalidraw.com"]),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
validateLibraryUrl("https://library.excalidraw.com", ["excalidraw.com"]),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
validateLibraryUrl("https://library.excalidraw.com", [
|
|
||||||
"library.excalidraw.com",
|
|
||||||
]),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
validateLibraryUrl("https://excalidraw.com/", ["excalidraw.com/"]),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
validateLibraryUrl("https://excalidraw.com", ["excalidraw.com/"]),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
validateLibraryUrl("https://excalidraw.com/", ["excalidraw.com"]),
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
// valid pathnames
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
expect(
|
|
||||||
validateLibraryUrl("https://excalidraw.com/path", ["excalidraw.com"]),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
validateLibraryUrl("https://excalidraw.com/path/", ["excalidraw.com"]),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
validateLibraryUrl("https://excalidraw.com/specific/path", [
|
|
||||||
"excalidraw.com/specific/path",
|
|
||||||
]),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
validateLibraryUrl("https://excalidraw.com/specific/path/", [
|
|
||||||
"excalidraw.com/specific/path",
|
|
||||||
]),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
validateLibraryUrl("https://excalidraw.com/specific/path", [
|
|
||||||
"excalidraw.com/specific/path/",
|
|
||||||
]),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
validateLibraryUrl("https://excalidraw.com/specific/path/other", [
|
|
||||||
"excalidraw.com/specific/path",
|
|
||||||
]),
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
// invalid hostnames
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
expect(() =>
|
|
||||||
validateLibraryUrl("https://xexcalidraw.com", ["excalidraw.com"]),
|
|
||||||
).toThrow();
|
|
||||||
expect(() =>
|
|
||||||
validateLibraryUrl("https://x-excalidraw.com", ["excalidraw.com"]),
|
|
||||||
).toThrow();
|
|
||||||
expect(() =>
|
|
||||||
validateLibraryUrl("https://excalidraw.comx", ["excalidraw.com"]),
|
|
||||||
).toThrow();
|
|
||||||
expect(() =>
|
|
||||||
validateLibraryUrl("https://excalidraw.comx", ["excalidraw.com"]),
|
|
||||||
).toThrow();
|
|
||||||
expect(() =>
|
|
||||||
validateLibraryUrl("https://excalidraw.com.mx", ["excalidraw.com"]),
|
|
||||||
).toThrow();
|
|
||||||
// protocol must be https
|
|
||||||
expect(() =>
|
|
||||||
validateLibraryUrl("http://excalidraw.com.mx", ["excalidraw.com"]),
|
|
||||||
).toThrow();
|
|
||||||
|
|
||||||
// invalid pathnames
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
expect(() =>
|
|
||||||
validateLibraryUrl("https://excalidraw.com/specific/other/path", [
|
|
||||||
"excalidraw.com/specific/path",
|
|
||||||
]),
|
|
||||||
).toThrow();
|
|
||||||
expect(() =>
|
|
||||||
validateLibraryUrl("https://excalidraw.com/specific/paths", [
|
|
||||||
"excalidraw.com/specific/path",
|
|
||||||
]),
|
|
||||||
).toThrow();
|
|
||||||
expect(() =>
|
|
||||||
validateLibraryUrl("https://excalidraw.com/specific/path-s", [
|
|
||||||
"excalidraw.com/specific/path",
|
|
||||||
]),
|
|
||||||
).toThrow();
|
|
||||||
expect(() =>
|
|
||||||
validateLibraryUrl("https://excalidraw.com/some/specific/path", [
|
|
||||||
"excalidraw.com/specific/path",
|
|
||||||
]),
|
|
||||||
).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -8,7 +8,8 @@ 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, editorJotaiStore } from "../editor-jotai";
|
import { atom } from "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";
|
||||||
@@ -36,18 +37,7 @@ import { Queue } from "../queue";
|
|||||||
import { hashElementsVersion, hashString } from "../element";
|
import { hashElementsVersion, hashString } from "../element";
|
||||||
import { toValidURL } from "./url";
|
import { toValidURL } from "./url";
|
||||||
|
|
||||||
/**
|
const ALLOWED_LIBRARY_HOSTNAMES = ["excalidraw.com"];
|
||||||
* format: hostname or hostname/pathname
|
|
||||||
*
|
|
||||||
* Both hostname and pathname are matched partially,
|
|
||||||
* hostname from the end, pathname from the start, with subdomain/path
|
|
||||||
* boundaries
|
|
||||||
**/
|
|
||||||
const ALLOWED_LIBRARY_URLS = [
|
|
||||||
"excalidraw.com",
|
|
||||||
// when installing from github PRs
|
|
||||||
"raw.githubusercontent.com/excalidraw/excalidraw-libraries",
|
|
||||||
];
|
|
||||||
|
|
||||||
type LibraryUpdate = {
|
type LibraryUpdate = {
|
||||||
/** deleted library items since last onLibraryChange event */
|
/** deleted library items since last onLibraryChange event */
|
||||||
@@ -201,13 +191,13 @@ class Library {
|
|||||||
|
|
||||||
private notifyListeners = () => {
|
private notifyListeners = () => {
|
||||||
if (this.updateQueue.length > 0) {
|
if (this.updateQueue.length > 0) {
|
||||||
editorJotaiStore.set(libraryItemsAtom, (s) => ({
|
jotaiStore.set(libraryItemsAtom, (s) => ({
|
||||||
status: "loading",
|
status: "loading",
|
||||||
libraryItems: this.currLibraryItems,
|
libraryItems: this.currLibraryItems,
|
||||||
isInitialized: s.isInitialized,
|
isInitialized: s.isInitialized,
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
editorJotaiStore.set(libraryItemsAtom, {
|
jotaiStore.set(libraryItemsAtom, {
|
||||||
status: "loaded",
|
status: "loaded",
|
||||||
libraryItems: this.currLibraryItems,
|
libraryItems: this.currLibraryItems,
|
||||||
isInitialized: true,
|
isInitialized: true,
|
||||||
@@ -235,7 +225,7 @@ class Library {
|
|||||||
destroy = () => {
|
destroy = () => {
|
||||||
this.updateQueue = [];
|
this.updateQueue = [];
|
||||||
this.currLibraryItems = [];
|
this.currLibraryItems = [];
|
||||||
editorJotaiStore.set(libraryItemSvgsCache, new Map());
|
jotaiStore.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",
|
||||||
@@ -480,37 +470,26 @@ export const distributeLibraryItemsOnSquareGrid = (
|
|||||||
return resElements;
|
return resElements;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const validateLibraryUrl = (
|
const validateLibraryUrl = (
|
||||||
libraryUrl: string,
|
libraryUrl: string,
|
||||||
/**
|
/**
|
||||||
* @returns `true` if the URL is valid, throws otherwise.
|
* If supplied, takes precedence over the default whitelist.
|
||||||
|
* Return `true` if the URL is valid.
|
||||||
*/
|
*/
|
||||||
validator:
|
validator?: (libraryUrl: string) => boolean,
|
||||||
| ((libraryUrl: string) => boolean)
|
): boolean => {
|
||||||
| string[] = ALLOWED_LIBRARY_URLS,
|
|
||||||
): true => {
|
|
||||||
if (
|
if (
|
||||||
typeof validator === "function"
|
validator
|
||||||
? validator(libraryUrl)
|
? validator(libraryUrl)
|
||||||
: validator.some((allowedUrlDef) => {
|
: ALLOWED_LIBRARY_HOSTNAMES.includes(
|
||||||
const allowedUrl = new URL(
|
new URL(libraryUrl).hostname.split(".").slice(-2).join("."),
|
||||||
`https://${allowedUrlDef.replace(/^https?:\/\//, "")}`,
|
)
|
||||||
);
|
|
||||||
|
|
||||||
const { hostname, pathname } = new URL(libraryUrl);
|
|
||||||
|
|
||||||
return (
|
|
||||||
new RegExp(`(^|\\.)${allowedUrl.hostname}$`).test(hostname) &&
|
|
||||||
new RegExp(
|
|
||||||
`^${allowedUrl.pathname.replace(/\/+$/, "")}(/+|$)`,
|
|
||||||
).test(pathname)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Invalid or disallowed library URL: "${libraryUrl}"`);
|
console.error(`Invalid or disallowed library URL: "${libraryUrl}"`);
|
||||||
|
throw new Error("Invalid or disallowed library URL");
|
||||||
};
|
};
|
||||||
|
|
||||||
export const parseLibraryTokensFromUrl = () => {
|
export const parseLibraryTokensFromUrl = () => {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
ExcalidrawElbowArrowElement,
|
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawElementType,
|
ExcalidrawElementType,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
@@ -46,7 +45,7 @@ import { bumpVersion } from "../element/mutateElement";
|
|||||||
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
|
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import type { MarkOptional, Mutable } from "../utility-types";
|
import type { MarkOptional, Mutable } from "../utility-types";
|
||||||
import { getContainerElement } from "../element/textElement";
|
import { detectLineHeight, getContainerElement } from "../element/textElement";
|
||||||
import { normalizeLink } from "./url";
|
import { normalizeLink } from "./url";
|
||||||
import { syncInvalidIndices } from "../fractionalIndex";
|
import { syncInvalidIndices } from "../fractionalIndex";
|
||||||
import { getSizeFromPoints } from "../points";
|
import { getSizeFromPoints } from "../points";
|
||||||
@@ -59,7 +58,6 @@ import {
|
|||||||
} from "../scene";
|
} from "../scene";
|
||||||
import type { LocalPoint, Radians } from "../../math";
|
import type { LocalPoint, Radians } from "../../math";
|
||||||
import { isFiniteNumber, pointFrom } from "../../math";
|
import { isFiniteNumber, pointFrom } from "../../math";
|
||||||
import { detectLineHeight } from "../element/textMeasurements";
|
|
||||||
|
|
||||||
type RestoredAppState = Omit<
|
type RestoredAppState = Omit<
|
||||||
AppState,
|
AppState,
|
||||||
@@ -103,38 +101,23 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
|
|||||||
return DEFAULT_FONT_FAMILY;
|
return DEFAULT_FONT_FAMILY;
|
||||||
};
|
};
|
||||||
|
|
||||||
const repairBinding = <T extends ExcalidrawLinearElement>(
|
const repairBinding = (
|
||||||
element: T,
|
element: ExcalidrawLinearElement,
|
||||||
binding: PointBinding | FixedPointBinding | null,
|
binding: PointBinding | FixedPointBinding | null,
|
||||||
): T extends ExcalidrawElbowArrowElement
|
): PointBinding | FixedPointBinding | null => {
|
||||||
? FixedPointBinding | null
|
|
||||||
: PointBinding | FixedPointBinding | null => {
|
|
||||||
if (!binding) {
|
if (!binding) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const focus = binding.focus || 0;
|
|
||||||
|
|
||||||
if (isElbowArrow(element)) {
|
|
||||||
const fixedPointBinding:
|
|
||||||
| ExcalidrawElbowArrowElement["startBinding"]
|
|
||||||
| ExcalidrawElbowArrowElement["endBinding"] = isFixedPointBinding(binding)
|
|
||||||
? {
|
|
||||||
...binding,
|
|
||||||
focus,
|
|
||||||
fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]),
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return fixedPointBinding;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...binding,
|
...binding,
|
||||||
focus,
|
focus: binding.focus || 0,
|
||||||
} as T extends ExcalidrawElbowArrowElement
|
...(isElbowArrow(element) && isFixedPointBinding(binding)
|
||||||
? FixedPointBinding | null
|
? {
|
||||||
: PointBinding | FixedPointBinding | null;
|
fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const restoreElementWithProperties = <
|
const restoreElementWithProperties = <
|
||||||
@@ -206,24 +189,6 @@ const restoreElementWithProperties = <
|
|||||||
"customData" in extra ? extra.customData : element.customData;
|
"customData" in extra ? extra.customData : element.customData;
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE (mtolmacs): This is a temporary check to detect extremely large
|
|
||||||
// element position or sizing
|
|
||||||
if (
|
|
||||||
element.x < -1e6 ||
|
|
||||||
element.x > 1e6 ||
|
|
||||||
element.y < -1e6 ||
|
|
||||||
element.y > 1e6 ||
|
|
||||||
element.width < -1e6 ||
|
|
||||||
element.width > 1e6 ||
|
|
||||||
element.height < -1e6 ||
|
|
||||||
element.height > 1e6
|
|
||||||
) {
|
|
||||||
console.error(
|
|
||||||
"Restore element with properties size or position is too large",
|
|
||||||
{ element },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// spread the original element properties to not lose unknown ones
|
// spread the original element properties to not lose unknown ones
|
||||||
// for forward-compatibility
|
// for forward-compatibility
|
||||||
@@ -238,21 +203,6 @@ const restoreElementWithProperties = <
|
|||||||
const restoreElement = (
|
const restoreElement = (
|
||||||
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||||
): typeof element | null => {
|
): typeof element | null => {
|
||||||
// NOTE (mtolmacs): This is a temporary check to detect extremely large
|
|
||||||
// element position or sizing
|
|
||||||
if (
|
|
||||||
element.x < -1e6 ||
|
|
||||||
element.x > 1e6 ||
|
|
||||||
element.y < -1e6 ||
|
|
||||||
element.y > 1e6 ||
|
|
||||||
element.width < -1e6 ||
|
|
||||||
element.width > 1e6 ||
|
|
||||||
element.height < -1e6 ||
|
|
||||||
element.height > 1e6
|
|
||||||
) {
|
|
||||||
console.error("Restore element size or position is too large", { element });
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case "text":
|
case "text":
|
||||||
let fontSize = element.fontSize;
|
let fontSize = element.fontSize;
|
||||||
@@ -358,7 +308,8 @@ const restoreElement = (
|
|||||||
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
|
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
|
||||||
}
|
}
|
||||||
|
|
||||||
const base = {
|
// TODO: Separate arrow from linear element
|
||||||
|
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),
|
||||||
@@ -370,20 +321,7 @@ 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
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
newMagicFrameElement,
|
newMagicFrameElement,
|
||||||
newTextElement,
|
newTextElement,
|
||||||
} from "../element/newElement";
|
} from "../element/newElement";
|
||||||
|
import { measureText, normalizeText } from "../element/textElement";
|
||||||
import type {
|
import type {
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
@@ -54,7 +55,6 @@ import { syncInvalidIndices } from "../fractionalIndex";
|
|||||||
import { getLineHeight } from "../fonts";
|
import { getLineHeight } from "../fonts";
|
||||||
import { isArrowElement } from "../element/typeChecks";
|
import { isArrowElement } from "../element/typeChecks";
|
||||||
import { pointFrom, type LocalPoint } from "../../math";
|
import { pointFrom, type LocalPoint } from "../../math";
|
||||||
import { measureText, normalizeText } from "../element/textMeasurements";
|
|
||||||
|
|
||||||
export type ValidLinearElement = {
|
export type ValidLinearElement = {
|
||||||
type: "arrow" | "line";
|
type: "arrow" | "line";
|
||||||
|
|||||||
@@ -25,7 +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&");
|
expect(normalizeLink("test&")).toBe("test&");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { sanitizeUrl } from "@braintree/sanitize-url";
|
import { sanitizeUrl } from "@braintree/sanitize-url";
|
||||||
import { escapeDoubleQuotes } from "../utils";
|
import { sanitizeHTMLAttribute } from "../utils";
|
||||||
|
|
||||||
export const normalizeLink = (link: string) => {
|
export const normalizeLink = (link: string) => {
|
||||||
link = link.trim();
|
link = link.trim();
|
||||||
if (!link) {
|
if (!link) {
|
||||||
return link;
|
return link;
|
||||||
}
|
}
|
||||||
return sanitizeUrl(escapeDoubleQuotes(link));
|
return sanitizeUrl(sanitizeHTMLAttribute(link));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isLocalLink = (link: string | null) => {
|
export const isLocalLink = (link: string | null) => {
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
// 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();
|
|
||||||
@@ -32,6 +32,7 @@ import type { Bounds } from "./bounds";
|
|||||||
import { getCenterForBounds, getElementAbsoluteCoords } from "./bounds";
|
import { getCenterForBounds, getElementAbsoluteCoords } from "./bounds";
|
||||||
import type { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { isPointOnShape } from "../../utils/collision";
|
import { isPointOnShape } from "../../utils/collision";
|
||||||
|
import { getElementAtPosition } from "../scene";
|
||||||
import {
|
import {
|
||||||
isArrowElement,
|
isArrowElement,
|
||||||
isBindableElement,
|
isBindableElement,
|
||||||
@@ -48,14 +49,10 @@ import type { ElementUpdate } from "./mutateElement";
|
|||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
import type Scene from "../scene/Scene";
|
import type Scene from "../scene/Scene";
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import {
|
import { arrayToMap, tupleToCoors } from "../utils";
|
||||||
arrayToMap,
|
|
||||||
isBindingFallthroughEnabled,
|
|
||||||
tupleToCoors,
|
|
||||||
} from "../utils";
|
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
||||||
import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes";
|
import { aabbForElement, getElementShapes, pointInsideBounds } from "../shapes";
|
||||||
import {
|
import {
|
||||||
compareHeading,
|
compareHeading,
|
||||||
HEADING_DOWN,
|
HEADING_DOWN,
|
||||||
@@ -428,8 +425,7 @@ export const maybeBindLinearElement = (
|
|||||||
elements,
|
elements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
appState.zoom,
|
appState.zoom,
|
||||||
isElbowArrow(linearElement),
|
isElbowArrow(linearElement) && isElbowArrow(linearElement),
|
||||||
isElbowArrow(linearElement),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hoveredElement !== null) {
|
if (hoveredElement !== null) {
|
||||||
@@ -508,6 +504,12 @@ 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
|
||||||
@@ -562,65 +564,8 @@ export const getHoveredElementForBinding = (
|
|||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
zoom?: AppState["zoom"],
|
zoom?: AppState["zoom"],
|
||||||
fullShape?: boolean,
|
fullShape?: boolean,
|
||||||
considerAllElements?: boolean,
|
|
||||||
): NonDeleted<ExcalidrawBindableElement> | null => {
|
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||||
if (considerAllElements) {
|
const hoveredElement = getElementAtPosition(
|
||||||
let cullRest = false;
|
|
||||||
const candidateElements = getAllElementsAtPositionForBinding(
|
|
||||||
elements,
|
|
||||||
(element) =>
|
|
||||||
isBindableElement(element, false) &&
|
|
||||||
bindingBorderTest(
|
|
||||||
element,
|
|
||||||
pointerCoords,
|
|
||||||
elementsMap,
|
|
||||||
zoom,
|
|
||||||
(fullShape ||
|
|
||||||
!isBindingFallthroughEnabled(
|
|
||||||
element as ExcalidrawBindableElement,
|
|
||||||
)) &&
|
|
||||||
// disable fullshape snapping for frame elements so we
|
|
||||||
// can bind to frame children
|
|
||||||
!isFrameLikeElement(element),
|
|
||||||
),
|
|
||||||
).filter((element) => {
|
|
||||||
if (cullRest) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isBindingFallthroughEnabled(element as ExcalidrawBindableElement)) {
|
|
||||||
cullRest = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}) as NonDeleted<ExcalidrawBindableElement>[] | null;
|
|
||||||
|
|
||||||
// Return early if there are no candidates or just one candidate
|
|
||||||
if (!candidateElements || candidateElements.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (candidateElements.length === 1) {
|
|
||||||
return candidateElements[0] as NonDeleted<ExcalidrawBindableElement>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefer the shape with the border being tested (if any)
|
|
||||||
const borderTestElements = candidateElements.filter((element) =>
|
|
||||||
bindingBorderTest(element, pointerCoords, elementsMap, zoom, false),
|
|
||||||
);
|
|
||||||
if (borderTestElements.length === 1) {
|
|
||||||
return borderTestElements[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefer smaller shapes
|
|
||||||
return candidateElements
|
|
||||||
.sort(
|
|
||||||
(a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2),
|
|
||||||
)
|
|
||||||
.pop() as NonDeleted<ExcalidrawBindableElement>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hoveredElement = getElementAtPositionForBinding(
|
|
||||||
elements,
|
elements,
|
||||||
(element) =>
|
(element) =>
|
||||||
isBindableElement(element, false) &&
|
isBindableElement(element, false) &&
|
||||||
@@ -631,58 +576,13 @@ 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 || !isBindingFallthroughEnabled(element)) &&
|
fullShape && !isFrameLikeElement(element),
|
||||||
!isFrameLikeElement(element),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
|
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getElementAtPositionForBinding = (
|
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
|
||||||
isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean,
|
|
||||||
) => {
|
|
||||||
let hitElement = null;
|
|
||||||
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
|
||||||
// because array is ordered from lower z-index to highest and we want element z-index
|
|
||||||
// with higher z-index
|
|
||||||
for (let index = elements.length - 1; index >= 0; --index) {
|
|
||||||
const element = elements[index];
|
|
||||||
if (element.isDeleted) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (isAtPositionFn(element)) {
|
|
||||||
hitElement = element;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return hitElement;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAllElementsAtPositionForBinding = (
|
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
|
||||||
isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean,
|
|
||||||
) => {
|
|
||||||
const elementsAtPosition: NonDeletedExcalidrawElement[] = [];
|
|
||||||
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
|
||||||
// because array is ordered from lower z-index to highest and we want element z-index
|
|
||||||
// with higher z-index
|
|
||||||
for (let index = elements.length - 1; index >= 0; --index) {
|
|
||||||
const element = elements[index];
|
|
||||||
if (element.isDeleted) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAtPositionFn(element)) {
|
|
||||||
elementsAtPosition.push(element);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return elementsAtPosition;
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateFocusAndGap = (
|
const calculateFocusAndGap = (
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
hoveredElement: ExcalidrawBindableElement,
|
hoveredElement: ExcalidrawBindableElement,
|
||||||
@@ -729,9 +629,11 @@ 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 } = options ?? {};
|
const { newSize, simultaneouslyUpdated, changedElements, zoom } =
|
||||||
|
options ?? {};
|
||||||
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
|
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
|
||||||
simultaneouslyUpdated,
|
simultaneouslyUpdated,
|
||||||
);
|
);
|
||||||
@@ -765,7 +667,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, true);
|
mutateElement(element, bindings);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -807,14 +709,23 @@ export const updateBoundElements = (
|
|||||||
}> => update !== null,
|
}> => update !== null,
|
||||||
);
|
);
|
||||||
|
|
||||||
LinearElementEditor.movePoints(element, updates, {
|
LinearElementEditor.movePoints(
|
||||||
...(changedElement.id === element.startBinding?.elementId
|
element,
|
||||||
? { startBinding: bindings.startBinding }
|
updates,
|
||||||
: {}),
|
elementsMap,
|
||||||
...(changedElement.id === element.endBinding?.elementId
|
{
|
||||||
? { endBinding: bindings.endBinding }
|
...(changedElement.id === element.startBinding?.elementId
|
||||||
: {}),
|
? { 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) {
|
||||||
@@ -873,7 +784,9 @@ export const getHeadingForElbowArrowSnap = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return headingForPointFromElement(bindableElement, aabb, p);
|
const pointHeading = headingForPointFromElement(bindableElement, aabb, p);
|
||||||
|
|
||||||
|
return pointHeading;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDistanceForBinding = (
|
const getDistanceForBinding = (
|
||||||
@@ -1326,8 +1239,6 @@ const getElligibleElementForBindingElement = (
|
|||||||
elements,
|
elements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
zoom,
|
zoom,
|
||||||
isElbowArrow(linearElement),
|
|
||||||
isElbowArrow(linearElement),
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1495,9 +1406,9 @@ export const bindingBorderTest = (
|
|||||||
): boolean => {
|
): boolean => {
|
||||||
const threshold = maxBindingGap(element, element.width, element.height, zoom);
|
const threshold = maxBindingGap(element, element.width, element.height, zoom);
|
||||||
|
|
||||||
const shape = getElementShape(element, elementsMap);
|
const shapes = getElementShapes(element, elementsMap);
|
||||||
return (
|
return (
|
||||||
isPointOnShape(pointFrom(x, y), shape, threshold) ||
|
shapes.some((shape) => isPointOnShape(pointFrom(x, y), shape, threshold)) ||
|
||||||
(fullShape === true &&
|
(fullShape === true &&
|
||||||
pointInsideBounds(pointFrom(x, y), aabbForElement(element)))
|
pointInsideBounds(pointFrom(x, y), aabbForElement(element)))
|
||||||
);
|
);
|
||||||
@@ -2378,7 +2289,7 @@ export const getGlobalFixedPointForBindableElement = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getGlobalFixedPoints = (
|
const getGlobalFixedPoints = (
|
||||||
arrow: ExcalidrawElbowArrowElement,
|
arrow: ExcalidrawElbowArrowElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
): [GlobalPoint, GlobalPoint] => {
|
): [GlobalPoint, GlobalPoint] => {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import type {
|
|||||||
ElementsMap,
|
ElementsMap,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import rough from "roughjs/bin/rough";
|
import rough from "roughjs/bin/rough";
|
||||||
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
|
||||||
import type { Drawable, Op } from "roughjs/bin/core";
|
import type { Drawable, Op } from "roughjs/bin/core";
|
||||||
import type { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { generateRoughOptions } from "../scene/Shape";
|
import { generateRoughOptions } from "../scene/Shape";
|
||||||
@@ -24,13 +23,7 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
|
|||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import { ShapeCache } from "../scene/ShapeCache";
|
import { ShapeCache } from "../scene/ShapeCache";
|
||||||
import { arrayToMap, invariant } from "../utils";
|
import { arrayToMap, invariant } from "../utils";
|
||||||
import type {
|
import type { Degrees, GlobalPoint, LineSegment, Radians } from "../../math";
|
||||||
Degrees,
|
|
||||||
GlobalPoint,
|
|
||||||
LineSegment,
|
|
||||||
LocalPoint,
|
|
||||||
Radians,
|
|
||||||
} from "../../math";
|
|
||||||
import {
|
import {
|
||||||
degreesToRadians,
|
degreesToRadians,
|
||||||
lineSegment,
|
lineSegment,
|
||||||
@@ -39,7 +32,6 @@ import {
|
|||||||
pointFromArray,
|
pointFromArray,
|
||||||
pointRotateRads,
|
pointRotateRads,
|
||||||
} from "../../math";
|
} from "../../math";
|
||||||
import type { Mutable } from "../utility-types";
|
|
||||||
|
|
||||||
export type RectangleBox = {
|
export type RectangleBox = {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -732,36 +724,12 @@ export const getArrowheadPoints = (
|
|||||||
return [x2, y2, x3, y3, x4, y4];
|
return [x2, y2, x3, y3, x4, y4];
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateLinearElementShape = (
|
|
||||||
element: ExcalidrawLinearElement,
|
|
||||||
): Drawable => {
|
|
||||||
const generator = rough.generator();
|
|
||||||
const options = generateRoughOptions(element);
|
|
||||||
|
|
||||||
const method = (() => {
|
|
||||||
if (element.roundness) {
|
|
||||||
return "curve";
|
|
||||||
}
|
|
||||||
if (options.fill) {
|
|
||||||
return "polygon";
|
|
||||||
}
|
|
||||||
return "linearPath";
|
|
||||||
})();
|
|
||||||
|
|
||||||
return generator[method](
|
|
||||||
element.points as Mutable<LocalPoint>[] as RoughPoint[],
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getLinearElementRotatedBounds = (
|
const getLinearElementRotatedBounds = (
|
||||||
element: ExcalidrawLinearElement,
|
element: ExcalidrawLinearElement,
|
||||||
cx: number,
|
cx: number,
|
||||||
cy: number,
|
cy: number,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
): Bounds => {
|
): Bounds => {
|
||||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
|
||||||
|
|
||||||
if (element.points.length < 2) {
|
if (element.points.length < 2) {
|
||||||
const [pointX, pointY] = element.points[0];
|
const [pointX, pointY] = element.points[0];
|
||||||
const [x, y] = pointRotateRads(
|
const [x, y] = pointRotateRads(
|
||||||
@@ -771,6 +739,7 @@ const getLinearElementRotatedBounds = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
let coords: Bounds = [x, y, x, y];
|
let coords: Bounds = [x, y, x, y];
|
||||||
|
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||||
if (boundTextElement) {
|
if (boundTextElement) {
|
||||||
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
|
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
|
||||||
element,
|
element,
|
||||||
@@ -788,18 +757,48 @@ const getLinearElementRotatedBounds = (
|
|||||||
return coords;
|
return coords;
|
||||||
}
|
}
|
||||||
|
|
||||||
// first element is always the curve
|
const cachedShape =
|
||||||
const cachedShape = ShapeCache.get(element)?.[0];
|
ShapeCache.get(element) ?? ShapeCache.generateElementShape(element, null);
|
||||||
const shape = cachedShape ?? generateLinearElementShape(element);
|
|
||||||
const ops = getCurvePathOps(shape);
|
const [arrowCurve, ...arrowhead] = cachedShape;
|
||||||
|
|
||||||
const transformXY = ([x, y]: GlobalPoint) =>
|
const transformXY = ([x, y]: GlobalPoint) =>
|
||||||
pointRotateRads<GlobalPoint>(
|
pointRotateRads<GlobalPoint>(
|
||||||
pointFrom(element.x + x, element.y + y),
|
pointFrom(element.x + x, element.y + y),
|
||||||
pointFrom(cx, cy),
|
pointFrom(cx, cy),
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
|
|
||||||
let coords: Bounds = [res[0], res[1], res[2], res[3]];
|
let coords = getMinMaxXYFromCurvePathOps(
|
||||||
|
getCurvePathOps(arrowCurve),
|
||||||
|
transformXY,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const shape of arrowhead) {
|
||||||
|
let [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(
|
||||||
|
getCurvePathOps(shape),
|
||||||
|
);
|
||||||
|
|
||||||
|
[minX, minY] = pointRotateRads<GlobalPoint>(
|
||||||
|
pointFrom(minX + element.x, minY + element.y),
|
||||||
|
pointFrom(cx, cy),
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
[maxX, maxY] = pointRotateRads<GlobalPoint>(
|
||||||
|
pointFrom(maxX + element.x, maxY + element.y),
|
||||||
|
pointFrom(cx, cy),
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
coords = [
|
||||||
|
Math.min(minX, coords[0]),
|
||||||
|
Math.min(minY, coords[1]),
|
||||||
|
Math.max(maxX, coords[2]),
|
||||||
|
Math.max(maxY, coords[3]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||||
if (boundTextElement) {
|
if (boundTextElement) {
|
||||||
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
|
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
|
||||||
element,
|
element,
|
||||||
@@ -814,6 +813,7 @@ const getLinearElementRotatedBounds = (
|
|||||||
coordsWithBoundText[3],
|
coordsWithBoundText[3],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return coords;
|
return coords;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = {
|
|||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
element: ExcalidrawElement;
|
element: ExcalidrawElement;
|
||||||
shape: GeometricShape<Point>;
|
shapes: GeometricShape<Point>[];
|
||||||
threshold?: number;
|
threshold?: number;
|
||||||
frameNameBound?: FrameNameBounds | null;
|
frameNameBound?: FrameNameBounds | null;
|
||||||
};
|
};
|
||||||
@@ -54,16 +54,18 @@ export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
|
|||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
element,
|
element,
|
||||||
shape,
|
shapes,
|
||||||
threshold = 10,
|
threshold = 10,
|
||||||
frameNameBound = null,
|
frameNameBound = null,
|
||||||
}: HitTestArgs<Point>) => {
|
}: HitTestArgs<Point>) => {
|
||||||
let hit = shouldTestInside(element)
|
const testInside = shouldTestInside(element);
|
||||||
? // Since `inShape` tests STRICTLY againt the insides of a shape
|
|
||||||
// we would need `onShape` as well to include the "borders"
|
let hit = shapes.some((shape) =>
|
||||||
isPointInShape(pointFrom(x, y), shape) ||
|
testInside || shape.isClosed
|
||||||
isPointOnShape(pointFrom(x, y), shape, threshold)
|
? isPointInShape(pointFrom(x, y), shape) ||
|
||||||
: isPointOnShape(pointFrom(x, y), shape, threshold);
|
isPointOnShape(pointFrom(x, y), shape, threshold)
|
||||||
|
: isPointOnShape(pointFrom(x, y), shape, threshold),
|
||||||
|
);
|
||||||
|
|
||||||
// hit test against a frame's name
|
// hit test against a frame's name
|
||||||
if (!hit && frameNameBound) {
|
if (!hit && frameNameBound) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type {
|
|||||||
NullableGridSize,
|
NullableGridSize,
|
||||||
PointerDownState,
|
PointerDownState,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { getBoundTextElement } from "./textElement";
|
import { getBoundTextElement, getMinTextElementWidth } from "./textElement";
|
||||||
import type Scene from "../scene/Scene";
|
import type Scene from "../scene/Scene";
|
||||||
import {
|
import {
|
||||||
isArrowElement,
|
isArrowElement,
|
||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
import { getFontString } from "../utils";
|
import { getFontString } from "../utils";
|
||||||
import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
|
import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
|
||||||
import { getGridPoint } from "../snapping";
|
import { getGridPoint } from "../snapping";
|
||||||
import { getMinTextElementWidth } from "./textMeasurements";
|
|
||||||
|
|
||||||
export const dragSelectedElements = (
|
export const dragSelectedElements = (
|
||||||
pointerDownState: PointerDownState,
|
pointerDownState: PointerDownState,
|
||||||
@@ -43,20 +42,9 @@ export const dragSelectedElements = (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedElements = _selectedElements.filter((element) => {
|
const selectedElements = _selectedElements.filter(
|
||||||
if (isElbowArrow(element) && element.startBinding && element.endBinding) {
|
(el) => !(isElbowArrow(el) && el.startBinding && el.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
|
||||||
@@ -90,8 +78,10 @@ export const dragSelectedElements = (
|
|||||||
|
|
||||||
elementsToUpdate.forEach((element) => {
|
elementsToUpdate.forEach((element) => {
|
||||||
updateElementCoords(pointerDownState, element, adjustedOffset);
|
updateElementCoords(pointerDownState, element, adjustedOffset);
|
||||||
if (!isArrowElement(element)) {
|
if (
|
||||||
// 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(),
|
||||||
@@ -99,10 +89,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),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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 { escapeDoubleQuotes, 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";
|
||||||
@@ -208,7 +212,7 @@ export const getEmbedLink = (
|
|||||||
// Note that we don't attempt to parse the username as it can consist of
|
// Note that we don't attempt to parse the username as it can consist of
|
||||||
// non-latin1 characters, and the username in the url can be set to anything
|
// non-latin1 characters, and the username in the url can be set to anything
|
||||||
// without affecting the embed.
|
// without affecting the embed.
|
||||||
const safeURL = escapeDoubleQuotes(
|
const safeURL = sanitizeHTMLAttribute(
|
||||||
`https://twitter.com/x/status/${postId}`,
|
`https://twitter.com/x/status/${postId}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -227,7 +231,7 @@ export const getEmbedLink = (
|
|||||||
|
|
||||||
if (RE_REDDIT.test(link)) {
|
if (RE_REDDIT.test(link)) {
|
||||||
const [, page, postId, title] = link.match(RE_REDDIT)!;
|
const [, page, postId, title] = link.match(RE_REDDIT)!;
|
||||||
const safeURL = escapeDoubleQuotes(
|
const safeURL = sanitizeHTMLAttribute(
|
||||||
`https://reddit.com/r/${page}/comments/${postId}/${title}`,
|
`https://reddit.com/r/${page}/comments/${postId}/${title}`,
|
||||||
);
|
);
|
||||||
const ret: IframeDataWithSandbox = {
|
const ret: IframeDataWithSandbox = {
|
||||||
@@ -245,7 +249,7 @@ export const getEmbedLink = (
|
|||||||
|
|
||||||
if (RE_GH_GIST.test(link)) {
|
if (RE_GH_GIST.test(link)) {
|
||||||
const [, user, gistId] = link.match(RE_GH_GIST)!;
|
const [, user, gistId] = link.match(RE_GH_GIST)!;
|
||||||
const safeURL = escapeDoubleQuotes(
|
const safeURL = sanitizeHTMLAttribute(
|
||||||
`https://gist.github.com/${user}/${gistId}`,
|
`https://gist.github.com/${user}/${gistId}`,
|
||||||
);
|
);
|
||||||
const ret: IframeDataWithSandbox = {
|
const ret: IframeDataWithSandbox = {
|
||||||
|
|||||||
@@ -10,15 +10,13 @@ import {
|
|||||||
import { bindLinearElement } from "./binding";
|
import { bindLinearElement } from "./binding";
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import { newArrowElement, newElement } from "./newElement";
|
import { newArrowElement, newElement } from "./newElement";
|
||||||
import type { SceneElementsMap } from "./types";
|
import type {
|
||||||
import {
|
ElementsMap,
|
||||||
type ElementsMap,
|
ExcalidrawBindableElement,
|
||||||
type ExcalidrawBindableElement,
|
ExcalidrawElement,
|
||||||
type ExcalidrawElement,
|
ExcalidrawFlowchartNodeElement,
|
||||||
type ExcalidrawFlowchartNodeElement,
|
NonDeletedSceneElementsMap,
|
||||||
type NonDeletedSceneElementsMap,
|
OrderedExcalidrawElement,
|
||||||
type Ordered,
|
|
||||||
type OrderedExcalidrawElement,
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import type { AppState, PendingExcalidrawElements } from "../types";
|
import type { AppState, PendingExcalidrawElements } from "../types";
|
||||||
@@ -30,10 +28,9 @@ import {
|
|||||||
isFrameElement,
|
isFrameElement,
|
||||||
isFlowchartNodeElement,
|
isFlowchartNodeElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
import { invariant, toBrandedType } from "../utils";
|
import { invariant } from "../utils";
|
||||||
import { pointFrom, type LocalPoint } from "../../math";
|
import { pointFrom, type LocalPoint } from "../../math";
|
||||||
import { aabbForElement } from "../shapes";
|
import { aabbForElement } from "../shapes";
|
||||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
|
||||||
|
|
||||||
type LinkDirection = "up" | "right" | "down" | "left";
|
type LinkDirection = "up" | "right" | "down" | "left";
|
||||||
|
|
||||||
@@ -257,9 +254,6 @@ const addNewNode = (
|
|||||||
backgroundColor: element.backgroundColor,
|
backgroundColor: element.backgroundColor,
|
||||||
strokeColor: element.strokeColor,
|
strokeColor: element.strokeColor,
|
||||||
strokeWidth: element.strokeWidth,
|
strokeWidth: element.strokeWidth,
|
||||||
opacity: element.opacity,
|
|
||||||
fillStyle: element.fillStyle,
|
|
||||||
strokeStyle: element.strokeStyle,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
invariant(
|
invariant(
|
||||||
@@ -335,9 +329,6 @@ export const addNewNodes = (
|
|||||||
backgroundColor: startNode.backgroundColor,
|
backgroundColor: startNode.backgroundColor,
|
||||||
strokeColor: startNode.strokeColor,
|
strokeColor: startNode.strokeColor,
|
||||||
strokeWidth: startNode.strokeWidth,
|
strokeWidth: startNode.strokeWidth,
|
||||||
opacity: startNode.opacity,
|
|
||||||
fillStyle: startNode.fillStyle,
|
|
||||||
strokeStyle: startNode.strokeStyle,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
invariant(
|
invariant(
|
||||||
@@ -425,13 +416,11 @@ const createBindingArrow = (
|
|||||||
type: "arrow",
|
type: "arrow",
|
||||||
x: startX,
|
x: startX,
|
||||||
y: startY,
|
y: startY,
|
||||||
startArrowhead: null,
|
startArrowhead: appState.currentItemStartArrowhead,
|
||||||
endArrowhead: appState.currentItemEndArrowhead,
|
endArrowhead: appState.currentItemEndArrowhead,
|
||||||
strokeColor: startBindingElement.strokeColor,
|
strokeColor: appState.currentItemStrokeColor,
|
||||||
strokeStyle: startBindingElement.strokeStyle,
|
strokeStyle: appState.currentItemStrokeStyle,
|
||||||
strokeWidth: startBindingElement.strokeWidth,
|
strokeWidth: appState.currentItemStrokeWidth,
|
||||||
opacity: startBindingElement.opacity,
|
|
||||||
roughness: startBindingElement.roughness,
|
|
||||||
points: [pointFrom(0, 0), pointFrom(endX, endY)],
|
points: [pointFrom(0, 0), pointFrom(endX, endY)],
|
||||||
elbowed: true,
|
elbowed: true,
|
||||||
});
|
});
|
||||||
@@ -463,30 +452,22 @@ const createBindingArrow = (
|
|||||||
bindingArrow as OrderedExcalidrawElement,
|
bindingArrow as OrderedExcalidrawElement,
|
||||||
);
|
);
|
||||||
|
|
||||||
LinearElementEditor.movePoints(bindingArrow, [
|
LinearElementEditor.movePoints(
|
||||||
{
|
|
||||||
index: 1,
|
|
||||||
point: bindingArrow.points[1],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const update = updateElbowArrowPoints(
|
|
||||||
bindingArrow,
|
bindingArrow,
|
||||||
toBrandedType<SceneElementsMap>(
|
[
|
||||||
new Map([
|
{
|
||||||
...elementsMap.entries(),
|
index: 1,
|
||||||
[startBindingElement.id, startBindingElement],
|
point: bindingArrow.points[1],
|
||||||
[endBindingElement.id, endBindingElement],
|
},
|
||||||
[bindingArrow.id, bindingArrow],
|
],
|
||||||
] as [string, Ordered<ExcalidrawElement>][]),
|
elementsMap as NonDeletedSceneElementsMap,
|
||||||
),
|
undefined,
|
||||||
{ points: bindingArrow.points },
|
{
|
||||||
|
changedElements,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return bindingArrow;
|
||||||
...bindingArrow,
|
|
||||||
...update,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export class FlowChartNavigator {
|
export class FlowChartNavigator {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ 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";
|
||||||
@@ -53,24 +52,9 @@ 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.
|
||||||
@@ -79,7 +63,7 @@ export const headingForPointFromElement = <
|
|||||||
>(
|
>(
|
||||||
element: Readonly<ExcalidrawBindableElement>,
|
element: Readonly<ExcalidrawBindableElement>,
|
||||||
aabb: Readonly<Bounds>,
|
aabb: Readonly<Bounds>,
|
||||||
p: Readonly<Point>,
|
p: Readonly<LocalPoint | GlobalPoint>,
|
||||||
): Heading => {
|
): Heading => {
|
||||||
const SEARCH_CONE_MULTIPLIER = 2;
|
const SEARCH_CONE_MULTIPLIER = 2;
|
||||||
|
|
||||||
@@ -133,22 +117,14 @@ export const headingForPointFromElement = <
|
|||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (triangleIncludesPoint([top, right, midPoint] as Triangle<Point>, p)) {
|
||||||
triangleIncludesPoint<Point>([top, right, midPoint] as Triangle<Point>, p)
|
|
||||||
) {
|
|
||||||
return headingForDiamond(top, right);
|
return headingForDiamond(top, right);
|
||||||
} else if (
|
} else if (
|
||||||
triangleIncludesPoint<Point>(
|
triangleIncludesPoint([right, bottom, midPoint] as Triangle<Point>, p)
|
||||||
[right, bottom, midPoint] as Triangle<Point>,
|
|
||||||
p,
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
return headingForDiamond(right, bottom);
|
return headingForDiamond(right, bottom);
|
||||||
} else if (
|
} else if (
|
||||||
triangleIncludesPoint<Point>(
|
triangleIncludesPoint([bottom, left, midPoint] as Triangle<Point>, p)
|
||||||
[bottom, left, midPoint] as Triangle<Point>,
|
|
||||||
p,
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
return headingForDiamond(bottom, left);
|
return headingForDiamond(bottom, left);
|
||||||
}
|
}
|
||||||
@@ -177,17 +153,17 @@ export const headingForPointFromElement = <
|
|||||||
SEARCH_CONE_MULTIPLIER,
|
SEARCH_CONE_MULTIPLIER,
|
||||||
) as Point;
|
) as Point;
|
||||||
|
|
||||||
return triangleIncludesPoint<Point>(
|
return triangleIncludesPoint(
|
||||||
[topLeft, topRight, midPoint] as Triangle<Point>,
|
[topLeft, topRight, midPoint] as Triangle<Point>,
|
||||||
p,
|
p,
|
||||||
)
|
)
|
||||||
? HEADING_UP
|
? HEADING_UP
|
||||||
: triangleIncludesPoint<Point>(
|
: triangleIncludesPoint(
|
||||||
[topRight, bottomRight, midPoint] as Triangle<Point>,
|
[topRight, bottomRight, midPoint] as Triangle<Point>,
|
||||||
p,
|
p,
|
||||||
)
|
)
|
||||||
? HEADING_RIGHT
|
? HEADING_RIGHT
|
||||||
: triangleIncludesPoint<Point>(
|
: triangleIncludesPoint(
|
||||||
[bottomRight, bottomLeft, midPoint] as Triangle<Point>,
|
[bottomRight, bottomLeft, midPoint] as Triangle<Point>,
|
||||||
p,
|
p,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,10 +7,9 @@ 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";
|
||||||
@@ -25,7 +24,6 @@ import type {
|
|||||||
InteractiveCanvasAppState,
|
InteractiveCanvasAppState,
|
||||||
AppClassProperties,
|
AppClassProperties,
|
||||||
NullableGridSize,
|
NullableGridSize,
|
||||||
Zoom,
|
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
|
|
||||||
@@ -34,7 +32,7 @@ import {
|
|||||||
getHoveredElementForBinding,
|
getHoveredElementForBinding,
|
||||||
isBindingEnabled,
|
isBindingEnabled,
|
||||||
} from "./binding";
|
} from "./binding";
|
||||||
import { invariant, tupleToCoors } from "../utils";
|
import { invariant, toBrandedType, tupleToCoors } from "../utils";
|
||||||
import {
|
import {
|
||||||
isBindingElement,
|
isBindingElement,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
@@ -46,6 +44,7 @@ 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 {
|
||||||
@@ -57,8 +56,6 @@ import {
|
|||||||
type GlobalPoint,
|
type GlobalPoint,
|
||||||
type LocalPoint,
|
type LocalPoint,
|
||||||
pointDistance,
|
pointDistance,
|
||||||
pointTranslate,
|
|
||||||
vectorFromPoint,
|
|
||||||
} from "../../math";
|
} from "../../math";
|
||||||
import {
|
import {
|
||||||
getBezierCurveLength,
|
getBezierCurveLength,
|
||||||
@@ -68,7 +65,6 @@ 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;
|
||||||
@@ -148,13 +144,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<T extends ExcalidrawLinearElement>(
|
static getElement(
|
||||||
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<T>;
|
return element as NonDeleted<ExcalidrawLinearElement>;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -295,16 +291,20 @@ export class LinearElementEditor {
|
|||||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||||
);
|
);
|
||||||
|
|
||||||
LinearElementEditor.movePoints(element, [
|
LinearElementEditor.movePoints(
|
||||||
{
|
element,
|
||||||
index: selectedIndex,
|
[
|
||||||
point: pointFrom(
|
{
|
||||||
width + referencePoint[0],
|
index: selectedIndex,
|
||||||
height + referencePoint[1],
|
point: pointFrom(
|
||||||
),
|
width + referencePoint[0],
|
||||||
isDragging: selectedIndex === lastClickedPoint,
|
height + referencePoint[1],
|
||||||
},
|
),
|
||||||
]);
|
isDragging: selectedIndex === lastClickedPoint,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
const newDraggingPointPosition = LinearElementEditor.createPointAt(
|
const newDraggingPointPosition = LinearElementEditor.createPointAt(
|
||||||
element,
|
element,
|
||||||
@@ -339,6 +339,7 @@ export class LinearElementEditor {
|
|||||||
isDragging: pointIndex === lastClickedPoint,
|
isDragging: pointIndex === lastClickedPoint,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
elementsMap,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,15 +422,19 @@ 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(element, [
|
LinearElementEditor.movePoints(
|
||||||
{
|
element,
|
||||||
index: selectedPoint,
|
[
|
||||||
point:
|
{
|
||||||
selectedPoint === 0
|
index: selectedPoint,
|
||||||
? element.points[element.points.length - 1]
|
point:
|
||||||
: element.points[0],
|
selectedPoint === 0
|
||||||
},
|
? element.points[element.points.length - 1]
|
||||||
]);
|
: element.points[0],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const bindingElement = isBindingEnabled(appState)
|
const bindingElement = isBindingEnabled(appState)
|
||||||
@@ -444,8 +449,6 @@ export class LinearElementEditor {
|
|||||||
elements,
|
elements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
appState.zoom,
|
appState.zoom,
|
||||||
isElbowArrow(element),
|
|
||||||
isElbowArrow(element),
|
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -492,7 +495,6 @@ 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
|
||||||
@@ -531,7 +533,6 @@ export class LinearElementEditor {
|
|||||||
element,
|
element,
|
||||||
element.points[index],
|
element.points[index],
|
||||||
element.points[index + 1],
|
element.points[index + 1],
|
||||||
index,
|
|
||||||
appState.zoom,
|
appState.zoom,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
@@ -572,23 +573,19 @@ export class LinearElementEditor {
|
|||||||
scenePointer.x,
|
scenePointer.x,
|
||||||
scenePointer.y,
|
scenePointer.y,
|
||||||
);
|
);
|
||||||
if (!isElbowArrow(element) && clickedPointIndex >= 0) {
|
if (clickedPointIndex >= 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const points = LinearElementEditor.getPointsGlobalCoordinates(
|
const points = LinearElementEditor.getPointsGlobalCoordinates(
|
||||||
element,
|
element,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
if (
|
if (points.length >= 3 && !appState.editingLinearElement) {
|
||||||
points.length >= 3 &&
|
|
||||||
!appState.editingLinearElement &&
|
|
||||||
!isElbowArrow(element)
|
|
||||||
) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const threshold =
|
const threshold =
|
||||||
(LinearElementEditor.POINT_HANDLE_SIZE + 1) / appState.zoom.value;
|
LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value;
|
||||||
|
|
||||||
const existingSegmentMidpointHitCoords =
|
const existingSegmentMidpointHitCoords =
|
||||||
linearElementEditor.segmentMidPointHoveredCoords;
|
linearElementEditor.segmentMidPointHoveredCoords;
|
||||||
@@ -607,11 +604,10 @@ 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(
|
||||||
midPoints[index]!,
|
pointFrom(midPoints[index]![0], midPoints[index]![1]),
|
||||||
pointFrom(scenePointer.x, scenePointer.y),
|
pointFrom(scenePointer.x, scenePointer.y),
|
||||||
);
|
);
|
||||||
if (distance <= threshold) {
|
if (distance <= threshold) {
|
||||||
@@ -624,25 +620,16 @@ export class LinearElementEditor {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
static isSegmentTooShort<P extends GlobalPoint | LocalPoint>(
|
static isSegmentTooShort(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
startPoint: P,
|
startPoint: GlobalPoint | LocalPoint,
|
||||||
endPoint: P,
|
endPoint: GlobalPoint | LocalPoint,
|
||||||
index: number,
|
zoom: AppState["zoom"],
|
||||||
zoom: Zoom,
|
|
||||||
) {
|
) {
|
||||||
if (isElbowArrow(element)) {
|
let distance = pointDistance(
|
||||||
if (index >= 0 && index < element.points.length) {
|
pointFrom(startPoint[0], startPoint[1]),
|
||||||
return (
|
pointFrom(endPoint[0], endPoint[1]),
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -761,8 +748,12 @@ export class LinearElementEditor {
|
|||||||
segmentMidpoint,
|
segmentMidpoint,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
} else if (event.altKey && appState.editingLinearElement) {
|
}
|
||||||
if (linearElementEditor.lastUncommittedPoint == null) {
|
if (event.altKey && appState.editingLinearElement) {
|
||||||
|
if (
|
||||||
|
linearElementEditor.lastUncommittedPoint == null &&
|
||||||
|
!isElbowArrow(element)
|
||||||
|
) {
|
||||||
mutateElement(element, {
|
mutateElement(element, {
|
||||||
points: [
|
points: [
|
||||||
...element.points,
|
...element.points,
|
||||||
@@ -798,7 +789,6 @@ export class LinearElementEditor {
|
|||||||
elements,
|
elements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
app.state.zoom,
|
app.state.zoom,
|
||||||
linearElementEditor.elbowed,
|
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -919,7 +909,12 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
if (!event.altKey) {
|
if (!event.altKey) {
|
||||||
if (lastPoint === lastUncommittedPoint) {
|
if (lastPoint === lastUncommittedPoint) {
|
||||||
LinearElementEditor.deletePoints(element, [points.length - 1]);
|
LinearElementEditor.deletePoints(
|
||||||
|
element,
|
||||||
|
[points.length - 1],
|
||||||
|
elementsMap,
|
||||||
|
app.state.zoom,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...appState.editingLinearElement,
|
...appState.editingLinearElement,
|
||||||
@@ -957,14 +952,23 @@ export class LinearElementEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (lastPoint === lastUncommittedPoint) {
|
if (lastPoint === lastUncommittedPoint) {
|
||||||
LinearElementEditor.movePoints(element, [
|
LinearElementEditor.movePoints(
|
||||||
{
|
element,
|
||||||
index: element.points.length - 1,
|
[
|
||||||
point: newPoint,
|
{
|
||||||
},
|
index: element.points.length - 1,
|
||||||
]);
|
point: newPoint,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
LinearElementEditor.addPoints(element, [{ point: newPoint }]);
|
LinearElementEditor.addPoints(
|
||||||
|
element,
|
||||||
|
[{ point: newPoint }],
|
||||||
|
elementsMap,
|
||||||
|
app.state.zoom,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...appState.editingLinearElement,
|
...appState.editingLinearElement,
|
||||||
@@ -1193,12 +1197,16 @@ 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(element, [
|
LinearElementEditor.movePoints(
|
||||||
{
|
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 {
|
||||||
@@ -1213,6 +1221,8 @@ 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;
|
||||||
@@ -1242,27 +1252,47 @@ export class LinearElementEditor {
|
|||||||
return acc;
|
return acc;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
|
LinearElementEditor._updatePoints(
|
||||||
|
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(element, nextPoints, offsetX, offsetY);
|
LinearElementEditor._updatePoints(
|
||||||
|
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;
|
||||||
|
|
||||||
@@ -1305,6 +1335,7 @@ export class LinearElementEditor {
|
|||||||
nextPoints,
|
nextPoints,
|
||||||
offsetX,
|
offsetX,
|
||||||
offsetY,
|
offsetY,
|
||||||
|
elementsMap,
|
||||||
otherUpdates,
|
otherUpdates,
|
||||||
{
|
{
|
||||||
isDragging: targetPoints.reduce(
|
isDragging: targetPoints.reduce(
|
||||||
@@ -1312,6 +1343,8 @@ export class LinearElementEditor {
|
|||||||
dragging || targetPoint.isDragging === true,
|
dragging || targetPoint.isDragging === true,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
|
changedElements: options?.changedElements,
|
||||||
|
zoom: options?.zoom,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1418,49 +1451,54 @@ 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 updates: {
|
const bindings: {
|
||||||
startBinding?: FixedPointBinding | null;
|
startBinding?: FixedPointBinding | null;
|
||||||
endBinding?: FixedPointBinding | null;
|
endBinding?: FixedPointBinding | null;
|
||||||
points?: LocalPoint[];
|
|
||||||
} = {};
|
} = {};
|
||||||
if (otherUpdates?.startBinding !== undefined) {
|
if (otherUpdates?.startBinding !== undefined) {
|
||||||
updates.startBinding =
|
bindings.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) {
|
||||||
updates.endBinding =
|
bindings.endBinding =
|
||||||
otherUpdates.endBinding !== null &&
|
otherUpdates.endBinding !== null &&
|
||||||
isFixedPointBinding(otherUpdates.endBinding)
|
isFixedPointBinding(otherUpdates.endBinding)
|
||||||
? otherUpdates.endBinding
|
? otherUpdates.endBinding
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
updates.points = Array.from(nextPoints);
|
const mergedElementsMap = options?.changedElements
|
||||||
updates.points[0] = pointTranslate(
|
? toBrandedType<SceneElementsMap>(
|
||||||
updates.points[0],
|
new Map([...elementsMap, ...options.changedElements]),
|
||||||
vector(offsetX, offsetY),
|
)
|
||||||
);
|
: elementsMap;
|
||||||
updates.points[updates.points.length - 1] = pointTranslate(
|
|
||||||
updates.points[updates.points.length - 1],
|
|
||||||
vector(offsetX, offsetY),
|
|
||||||
);
|
|
||||||
|
|
||||||
mutateElement(element, updates, true, {
|
mutateElbowArrow(
|
||||||
isDragging: options?.isDragging,
|
element,
|
||||||
});
|
mergedElementsMap,
|
||||||
|
nextPoints,
|
||||||
|
vector(offsetX, offsetY),
|
||||||
|
bindings,
|
||||||
|
{
|
||||||
|
isDragging: options?.isDragging,
|
||||||
|
zoom: options?.zoom,
|
||||||
|
},
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
const nextCoords = getElementPointsCoords(element, nextPoints);
|
const nextCoords = getElementPointsCoords(element, nextPoints);
|
||||||
const prevCoords = getElementPointsCoords(element, element.points);
|
const prevCoords = getElementPointsCoords(element, element.points);
|
||||||
@@ -1682,10 +1720,10 @@ export class LinearElementEditor {
|
|||||||
includeBoundText: boolean = false,
|
includeBoundText: boolean = false,
|
||||||
): [number, number, number, number, number, number] => {
|
): [number, number, number, number, number, number] => {
|
||||||
let coords: [number, number, number, number, number, number];
|
let coords: [number, number, number, number, number, number];
|
||||||
let x1;
|
let x1 = Infinity;
|
||||||
let y1;
|
let y1 = Infinity;
|
||||||
let x2;
|
let x2 = -Infinity;
|
||||||
let y2;
|
let y2 = -Infinity;
|
||||||
if (element.points.length < 2 || !ShapeCache.get(element)) {
|
if (element.points.length < 2 || !ShapeCache.get(element)) {
|
||||||
// XXX this is just a poor estimate and not very useful
|
// XXX this is just a poor estimate and not very useful
|
||||||
const { minX, minY, maxX, maxY } = element.points.reduce(
|
const { minX, minY, maxX, maxY } = element.points.reduce(
|
||||||
@@ -1707,14 +1745,15 @@ export class LinearElementEditor {
|
|||||||
} else {
|
} else {
|
||||||
const shape = ShapeCache.generateElementShape(element, null);
|
const shape = ShapeCache.generateElementShape(element, null);
|
||||||
|
|
||||||
// first element is always the curve
|
for (const s of shape) {
|
||||||
const ops = getCurvePathOps(shape[0]);
|
const ops = getCurvePathOps(s);
|
||||||
|
|
||||||
const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
|
const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
|
||||||
x1 = minX + element.x;
|
x1 = Math.min(minX + element.x, x1);
|
||||||
y1 = minY + element.y;
|
y1 = Math.min(minY + element.y, y1);
|
||||||
x2 = maxX + element.x;
|
x2 = Math.max(maxX + element.x, x2);
|
||||||
y2 = maxY + element.y;
|
y2 = Math.max(maxY + element.y, y2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const cx = (x1 + x2) / 2;
|
const cx = (x1 + x2) / 2;
|
||||||
const cy = (y1 + y2) / 2;
|
const cy = (y1 + y2) / 2;
|
||||||
@@ -1735,99 +1774,6 @@ 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,13 +1,10 @@
|
|||||||
import type { ExcalidrawElement, SceneElementsMap } from "./types";
|
import type { ExcalidrawElement } 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, toBrandedType } from "../utils";
|
import { getUpdatedTimestamp } 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>,
|
||||||
@@ -22,54 +19,14 @@ 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, fixedSegments, fileId, startBinding, endBinding } =
|
const { points, fileId } = updates as any;
|
||||||
updates as any;
|
|
||||||
|
|
||||||
if (
|
if (typeof points !== "undefined") {
|
||||||
isElbowArrow(element) &&
|
|
||||||
(Object.keys(updates).length === 0 || // normalization case
|
|
||||||
typeof points !== "undefined" || // repositioning
|
|
||||||
typeof fixedSegments !== "undefined" || // segment fixing
|
|
||||||
typeof startBinding !== "undefined" ||
|
|
||||||
typeof endBinding !== "undefined") // manual binding to element
|
|
||||||
) {
|
|
||||||
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,
|
|
||||||
startBinding,
|
|
||||||
endBinding,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
isDragging: options?.isDragging,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
};
|
|
||||||
} else if (typeof points !== "undefined") {
|
|
||||||
updates = { ...getSizeFromPoints(points), ...updates };
|
updates = { ...getSizeFromPoints(points), ...updates };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ import type {
|
|||||||
ExcalidrawIframeElement,
|
ExcalidrawIframeElement,
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
FixedSegment,
|
|
||||||
ExcalidrawElbowArrowElement,
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import {
|
import {
|
||||||
arrayToMap,
|
arrayToMap,
|
||||||
@@ -33,7 +31,11 @@ import { getNewGroupIdsForDuplication } from "../groups";
|
|||||||
import type { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { getElementAbsoluteCoords } from ".";
|
import { getElementAbsoluteCoords } from ".";
|
||||||
import { getResizedElementAbsoluteCoords } from "./bounds";
|
import { getResizedElementAbsoluteCoords } from "./bounds";
|
||||||
import { getBoundTextMaxWidth } from "./textElement";
|
import {
|
||||||
|
measureText,
|
||||||
|
normalizeText,
|
||||||
|
getBoundTextMaxWidth,
|
||||||
|
} from "./textElement";
|
||||||
import { wrapText } from "./textWrapping";
|
import { wrapText } from "./textWrapping";
|
||||||
import {
|
import {
|
||||||
DEFAULT_ELEMENT_PROPS,
|
DEFAULT_ELEMENT_PROPS,
|
||||||
@@ -41,13 +43,11 @@ import {
|
|||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
DEFAULT_VERTICAL_ALIGN,
|
DEFAULT_VERTICAL_ALIGN,
|
||||||
ORIG_ID,
|
|
||||||
VERTICAL_ALIGN,
|
VERTICAL_ALIGN,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import type { MarkOptional, Merge, Mutable } from "../utility-types";
|
import type { MarkOptional, Merge, Mutable } from "../utility-types";
|
||||||
import { getLineHeight } from "../fonts";
|
import { getLineHeight } from "../fonts";
|
||||||
import type { Radians } from "../../math";
|
import type { Radians } from "../../math";
|
||||||
import { normalizeText, measureText } from "./textMeasurements";
|
|
||||||
|
|
||||||
export type ElementConstructorOpts = MarkOptional<
|
export type ElementConstructorOpts = MarkOptional<
|
||||||
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
||||||
@@ -99,28 +99,6 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
|||||||
...rest
|
...rest
|
||||||
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
|
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
|
||||||
) => {
|
) => {
|
||||||
// NOTE (mtolmacs): This is a temporary check to detect extremely large
|
|
||||||
// element position or sizing
|
|
||||||
if (
|
|
||||||
x < -1e6 ||
|
|
||||||
x > 1e6 ||
|
|
||||||
y < -1e6 ||
|
|
||||||
y > 1e6 ||
|
|
||||||
width < -1e6 ||
|
|
||||||
width > 1e6 ||
|
|
||||||
height < -1e6 ||
|
|
||||||
height > 1e6
|
|
||||||
) {
|
|
||||||
console.error("New element size or position is too large", {
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
// @ts-ignore
|
|
||||||
points: rest.points,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// assign type to guard against excess properties
|
// assign type to guard against excess properties
|
||||||
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
|
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
|
||||||
id: rest.id || randomId(),
|
id: rest.id || randomId(),
|
||||||
@@ -472,34 +450,15 @@ export const newLinearElement = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const newArrowElement = <T extends boolean>(
|
export const newArrowElement = (
|
||||||
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?: T;
|
elbowed?: boolean;
|
||||||
fixedSegments?: FixedSegment[] | null;
|
|
||||||
} & ElementConstructorOpts,
|
} & ElementConstructorOpts,
|
||||||
): T extends true
|
): NonDeleted<ExcalidrawArrowElement> => {
|
||||||
? 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 || [],
|
||||||
@@ -508,10 +467,8 @@ export const newArrowElement = <T extends boolean>(
|
|||||||
endBinding: null,
|
endBinding: null,
|
||||||
startArrowhead: opts.startArrowhead || null,
|
startArrowhead: opts.startArrowhead || null,
|
||||||
endArrowhead: opts.endArrowhead || null,
|
endArrowhead: opts.endArrowhead || null,
|
||||||
elbowed: false,
|
elbowed: opts.elbowed || false,
|
||||||
} as T extends true
|
};
|
||||||
? NonDeleted<ExcalidrawElbowArrowElement>
|
|
||||||
: NonDeleted<ExcalidrawArrowElement>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const newImageElement = (
|
export const newImageElement = (
|
||||||
@@ -612,18 +569,26 @@ export const deepCopyElement = <T extends ExcalidrawElement>(
|
|||||||
return _deepCopyElement(val);
|
return _deepCopyElement(val);
|
||||||
};
|
};
|
||||||
|
|
||||||
const __test__defineOrigId = (clonedObj: object, origId: string) => {
|
|
||||||
Object.defineProperty(clonedObj, ORIG_ID, {
|
|
||||||
value: origId,
|
|
||||||
writable: false,
|
|
||||||
enumerable: false,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* utility wrapper to generate new id.
|
* utility wrapper to generate new id. In test env it reuses the old + postfix
|
||||||
|
* for test assertions.
|
||||||
*/
|
*/
|
||||||
const regenerateId = () => {
|
export const regenerateId = (
|
||||||
|
/** supply null if no previous id exists */
|
||||||
|
previousId: string | null,
|
||||||
|
) => {
|
||||||
|
if (isTestEnv() && previousId) {
|
||||||
|
let nextId = `${previousId}_copy`;
|
||||||
|
// `window.h` may not be defined in some unit tests
|
||||||
|
if (
|
||||||
|
window.h?.app
|
||||||
|
?.getSceneElementsIncludingDeleted()
|
||||||
|
.find((el: ExcalidrawElement) => el.id === nextId)
|
||||||
|
) {
|
||||||
|
nextId += "_copy";
|
||||||
|
}
|
||||||
|
return nextId;
|
||||||
|
}
|
||||||
return randomId();
|
return randomId();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -649,11 +614,7 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
|
|||||||
): Readonly<TElement> => {
|
): Readonly<TElement> => {
|
||||||
let copy = deepCopyElement(element);
|
let copy = deepCopyElement(element);
|
||||||
|
|
||||||
if (isTestEnv()) {
|
copy.id = regenerateId(copy.id);
|
||||||
__test__defineOrigId(copy, element.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
copy.id = regenerateId();
|
|
||||||
copy.boundElements = null;
|
copy.boundElements = null;
|
||||||
copy.updated = getUpdatedTimestamp();
|
copy.updated = getUpdatedTimestamp();
|
||||||
copy.seed = randomInteger();
|
copy.seed = randomInteger();
|
||||||
@@ -662,7 +623,7 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
|
|||||||
editingGroupId,
|
editingGroupId,
|
||||||
(groupId) => {
|
(groupId) => {
|
||||||
if (!groupIdMapForOperation.has(groupId)) {
|
if (!groupIdMapForOperation.has(groupId)) {
|
||||||
groupIdMapForOperation.set(groupId, regenerateId());
|
groupIdMapForOperation.set(groupId, regenerateId(groupId));
|
||||||
}
|
}
|
||||||
return groupIdMapForOperation.get(groupId)!;
|
return groupIdMapForOperation.get(groupId)!;
|
||||||
},
|
},
|
||||||
@@ -708,7 +669,7 @@ export const duplicateElements = (
|
|||||||
// if we haven't migrated the element id, but an old element with the same
|
// if we haven't migrated the element id, but an old element with the same
|
||||||
// id exists, generate a new id for it and return it
|
// id exists, generate a new id for it and return it
|
||||||
if (origElementsMap.has(id)) {
|
if (origElementsMap.has(id)) {
|
||||||
const newId = regenerateId();
|
const newId = regenerateId(id);
|
||||||
elementNewIdsMap.set(id, newId);
|
elementNewIdsMap.set(id, newId);
|
||||||
return newId;
|
return newId;
|
||||||
}
|
}
|
||||||
@@ -722,9 +683,6 @@ export const duplicateElements = (
|
|||||||
const clonedElement: Mutable<ExcalidrawElement> = _deepCopyElement(element);
|
const clonedElement: Mutable<ExcalidrawElement> = _deepCopyElement(element);
|
||||||
|
|
||||||
clonedElement.id = maybeGetNewId(element.id)!;
|
clonedElement.id = maybeGetNewId(element.id)!;
|
||||||
if (isTestEnv()) {
|
|
||||||
__test__defineOrigId(clonedElement, element.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts?.randomizeSeed) {
|
if (opts?.randomizeSeed) {
|
||||||
clonedElement.seed = randomInteger();
|
clonedElement.seed = randomInteger();
|
||||||
@@ -734,7 +692,7 @@ export const duplicateElements = (
|
|||||||
if (clonedElement.groupIds) {
|
if (clonedElement.groupIds) {
|
||||||
clonedElement.groupIds = clonedElement.groupIds.map((groupId) => {
|
clonedElement.groupIds = clonedElement.groupIds.map((groupId) => {
|
||||||
if (!groupNewIdsMap.has(groupId)) {
|
if (!groupNewIdsMap.has(groupId)) {
|
||||||
groupNewIdsMap.set(groupId, regenerateId());
|
groupNewIdsMap.set(groupId, regenerateId(groupId));
|
||||||
}
|
}
|
||||||
return groupNewIdsMap.get(groupId)!;
|
return groupNewIdsMap.get(groupId)!;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ 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 {
|
||||||
@@ -41,15 +40,20 @@ import type {
|
|||||||
import type { PointerDownState } from "../types";
|
import type { PointerDownState } from "../types";
|
||||||
import type Scene from "../scene/Scene";
|
import type Scene from "../scene/Scene";
|
||||||
import {
|
import {
|
||||||
|
getApproxMinLineWidth,
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
getBoundTextElementId,
|
getBoundTextElementId,
|
||||||
getContainerElement,
|
getContainerElement,
|
||||||
handleBindTextResize,
|
handleBindTextResize,
|
||||||
getBoundTextMaxWidth,
|
getBoundTextMaxWidth,
|
||||||
|
getApproxMinLineHeight,
|
||||||
|
measureText,
|
||||||
|
getMinTextElementWidth,
|
||||||
} from "./textElement";
|
} from "./textElement";
|
||||||
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,
|
||||||
@@ -60,12 +64,6 @@ import {
|
|||||||
type Radians,
|
type Radians,
|
||||||
type LocalPoint,
|
type LocalPoint,
|
||||||
} from "../../math";
|
} from "../../math";
|
||||||
import {
|
|
||||||
getMinTextElementWidth,
|
|
||||||
measureText,
|
|
||||||
getApproxMinLineWidth,
|
|
||||||
getApproxMinLineHeight,
|
|
||||||
} from "./textMeasurements";
|
|
||||||
|
|
||||||
// Returns true when transform (resizing/rotation) happened
|
// Returns true when transform (resizing/rotation) happened
|
||||||
export const transformElements = (
|
export const transformElements = (
|
||||||
@@ -179,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,
|
||||||
@@ -533,10 +531,8 @@ const rotateMultipleElements = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (isElbowArrow(element)) {
|
if (isElbowArrow(element)) {
|
||||||
// Needed to re-route the arrow
|
const points = getArrowLocalFixedPoints(element, elementsMap);
|
||||||
mutateElement(element, {
|
mutateElbowArrow(element, elementsMap, points);
|
||||||
points: getArrowLocalFixedPoints(element, elementsMap),
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
mutateElement(
|
mutateElement(
|
||||||
element,
|
element,
|
||||||
@@ -769,26 +765,6 @@ const getResizedOrigin = (
|
|||||||
y: y - (newHeight - prevHeight) / 2,
|
y: y - (newHeight - prevHeight) / 2,
|
||||||
};
|
};
|
||||||
case "east-side":
|
case "east-side":
|
||||||
// NOTE (mtolmacs): Reverting this for a short period to test if it is
|
|
||||||
// the cause of the megasized elbow arrows showing up.
|
|
||||||
if (
|
|
||||||
Math.abs(
|
|
||||||
y +
|
|
||||||
((prevWidth - newWidth) / 2) * Math.sin(angle) +
|
|
||||||
(prevHeight - newHeight) / 2,
|
|
||||||
) > 1e6
|
|
||||||
) {
|
|
||||||
console.error(
|
|
||||||
"getResizedOrigin() new calculation creates extremely large (> 1e6) y value where the old calculation resulted in",
|
|
||||||
{
|
|
||||||
result:
|
|
||||||
y +
|
|
||||||
(newHeight - prevHeight) / 2 +
|
|
||||||
((prevWidth - newWidth) / 2) * Math.sin(angle),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x: x + ((prevWidth - newWidth) / 2) * (Math.cos(angle) + 1),
|
x: x + ((prevWidth - newWidth) / 2) * (Math.cos(angle) + 1),
|
||||||
y:
|
y:
|
||||||
@@ -1225,7 +1201,6 @@ 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,
|
||||||
@@ -1233,6 +1208,7 @@ export const resizeMultipleElements = (
|
|||||||
flipByY = false,
|
flipByY = false,
|
||||||
nextHeight,
|
nextHeight,
|
||||||
nextWidth,
|
nextWidth,
|
||||||
|
originalElementsMap,
|
||||||
originalBoundingBox,
|
originalBoundingBox,
|
||||||
}: {
|
}: {
|
||||||
nextWidth?: number;
|
nextWidth?: number;
|
||||||
@@ -1241,6 +1217,7 @@ 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;
|
||||||
} = {},
|
} = {},
|
||||||
@@ -1410,9 +1387,6 @@ 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"];
|
|
||||||
};
|
};
|
||||||
}[] = [];
|
}[] = [];
|
||||||
|
|
||||||
@@ -1453,44 +1427,6 @@ 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,
|
||||||
@@ -1536,10 +1472,7 @@ 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,
|
||||||
|
|||||||
+9
-112
@@ -9,121 +9,20 @@ import {
|
|||||||
render,
|
render,
|
||||||
} from "../tests/test-utils";
|
} from "../tests/test-utils";
|
||||||
import { bindLinearElement } from "./binding";
|
import { bindLinearElement } from "./binding";
|
||||||
import { Excalidraw, mutateElement } from "../index";
|
import { Excalidraw } 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();
|
||||||
@@ -132,12 +31,10 @@ describe("elbow arrow routing", () => {
|
|||||||
elbowed: true,
|
elbowed: true,
|
||||||
}) as ExcalidrawElbowArrowElement;
|
}) as ExcalidrawElbowArrowElement;
|
||||||
scene.insertElement(arrow);
|
scene.insertElement(arrow);
|
||||||
mutateElement(arrow, {
|
mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [
|
||||||
points: [
|
pointFrom(-45 - arrow.x, -100.1 - arrow.y),
|
||||||
pointFrom<LocalPoint>(-45 - arrow.x, -100.1 - arrow.y),
|
pointFrom(45 - arrow.x, 99.9 - 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],
|
||||||
@@ -184,9 +81,7 @@ 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);
|
||||||
|
|
||||||
mutateElement(arrow, {
|
mutateElbowArrow(arrow, elementsMap, [pointFrom(0, 0), pointFrom(90, 200)]);
|
||||||
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(arrow.points).toEqual([
|
expect(arrow.points).toEqual([
|
||||||
[0, 0],
|
[0, 0],
|
||||||
@@ -287,6 +182,8 @@ 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
@@ -116,5 +116,8 @@ const normalizeBoundElementsOrder = (
|
|||||||
export const normalizeElementOrder = (
|
export const normalizeElementOrder = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
) => {
|
) => {
|
||||||
return normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
|
// console.time();
|
||||||
|
const ret = normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
|
||||||
|
// console.timeEnd();
|
||||||
|
return ret;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import {
|
|||||||
getContainerCoords,
|
getContainerCoords,
|
||||||
getBoundTextMaxWidth,
|
getBoundTextMaxWidth,
|
||||||
getBoundTextMaxHeight,
|
getBoundTextMaxHeight,
|
||||||
|
detectLineHeight,
|
||||||
|
getLineHeightInPx,
|
||||||
} from "./textElement";
|
} from "./textElement";
|
||||||
import { detectLineHeight, getLineHeightInPx } from "./textMeasurements";
|
|
||||||
import type { ExcalidrawTextElementWithContainer } from "./types";
|
import type { ExcalidrawTextElementWithContainer } from "./types";
|
||||||
|
|
||||||
describe("Test measureText", () => {
|
describe("Test measureText", () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getFontString, arrayToMap } from "../utils";
|
import { getFontString, arrayToMap, isTestEnv, normalizeEOL } from "../utils";
|
||||||
import type {
|
import type {
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
ExcalidrawTextContainer,
|
ExcalidrawTextContainer,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
ExcalidrawTextElementWithContainer,
|
ExcalidrawTextElementWithContainer,
|
||||||
|
FontString,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO,
|
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO,
|
||||||
ARROW_LABEL_WIDTH_FRACTION,
|
ARROW_LABEL_WIDTH_FRACTION,
|
||||||
BOUND_TEXT_PADDING,
|
BOUND_TEXT_PADDING,
|
||||||
|
DEFAULT_FONT_FAMILY,
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
TEXT_ALIGN,
|
TEXT_ALIGN,
|
||||||
VERTICAL_ALIGN,
|
VERTICAL_ALIGN,
|
||||||
@@ -28,7 +30,18 @@ import {
|
|||||||
updateOriginalContainerCache,
|
updateOriginalContainerCache,
|
||||||
} from "./containerCache";
|
} from "./containerCache";
|
||||||
import type { ExtractSetType } from "../utility-types";
|
import type { ExtractSetType } from "../utility-types";
|
||||||
import { measureText } from "./textMeasurements";
|
|
||||||
|
export const normalizeText = (text: string) => {
|
||||||
|
return (
|
||||||
|
normalizeEOL(text)
|
||||||
|
// replace tabs with spaces so they render and measure correctly
|
||||||
|
.replace(/\t/g, " ")
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const splitIntoLines = (text: string) => {
|
||||||
|
return normalizeText(text).split("\n");
|
||||||
|
};
|
||||||
|
|
||||||
export const redrawTextBoundingBox = (
|
export const redrawTextBoundingBox = (
|
||||||
textElement: ExcalidrawTextElement,
|
textElement: ExcalidrawTextElement,
|
||||||
@@ -268,6 +281,201 @@ export const computeBoundTextPosition = (
|
|||||||
return { x, y };
|
return { x, y };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const measureText = (
|
||||||
|
text: string,
|
||||||
|
font: FontString,
|
||||||
|
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||||
|
forceAdvanceWidth?: true,
|
||||||
|
) => {
|
||||||
|
const _text = text
|
||||||
|
.split("\n")
|
||||||
|
// replace empty lines with single space because leading/trailing empty
|
||||||
|
// lines would be stripped from computation
|
||||||
|
.map((x) => x || " ")
|
||||||
|
.join("\n");
|
||||||
|
const fontSize = parseFloat(font);
|
||||||
|
const height = getTextHeight(_text, fontSize, lineHeight);
|
||||||
|
const width = getTextWidth(_text, font, forceAdvanceWidth);
|
||||||
|
return { width, height };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* To get unitless line-height (if unknown) we can calculate it by dividing
|
||||||
|
* height-per-line by fontSize.
|
||||||
|
*/
|
||||||
|
export const detectLineHeight = (textElement: ExcalidrawTextElement) => {
|
||||||
|
const lineCount = splitIntoLines(textElement.text).length;
|
||||||
|
return (textElement.height /
|
||||||
|
lineCount /
|
||||||
|
textElement.fontSize) as ExcalidrawTextElement["lineHeight"];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We calculate the line height from the font size and the unitless line height,
|
||||||
|
* aligning with the W3C spec.
|
||||||
|
*/
|
||||||
|
export const getLineHeightInPx = (
|
||||||
|
fontSize: ExcalidrawTextElement["fontSize"],
|
||||||
|
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||||
|
) => {
|
||||||
|
return fontSize * lineHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
// FIXME rename to getApproxMinContainerHeight
|
||||||
|
export const getApproxMinLineHeight = (
|
||||||
|
fontSize: ExcalidrawTextElement["fontSize"],
|
||||||
|
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||||
|
) => {
|
||||||
|
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
let canvas: HTMLCanvasElement | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param forceAdvanceWidth use to force retrieve the "advance width" ~ `metrics.width`, instead of the actual boundind box width.
|
||||||
|
*
|
||||||
|
* > The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position.
|
||||||
|
*
|
||||||
|
* We need to use the advance width as that's the closest thing to the browser wrapping algo, hence using it for:
|
||||||
|
* - text wrapping
|
||||||
|
* - wysiwyg editor (+padding)
|
||||||
|
*
|
||||||
|
* Everything else should be based on the actual bounding box width.
|
||||||
|
*
|
||||||
|
* `Math.ceil` of the final width adds additional buffer which stabilizes slight wrapping incosistencies.
|
||||||
|
*/
|
||||||
|
export const getLineWidth = (
|
||||||
|
text: string,
|
||||||
|
font: FontString,
|
||||||
|
forceAdvanceWidth?: true,
|
||||||
|
) => {
|
||||||
|
if (!canvas) {
|
||||||
|
canvas = document.createElement("canvas");
|
||||||
|
}
|
||||||
|
const canvas2dContext = canvas.getContext("2d")!;
|
||||||
|
canvas2dContext.font = font;
|
||||||
|
const metrics = canvas2dContext.measureText(text);
|
||||||
|
|
||||||
|
const advanceWidth = metrics.width;
|
||||||
|
|
||||||
|
// retrieve the actual bounding box width if these metrics are available (as of now > 95% coverage)
|
||||||
|
if (
|
||||||
|
!forceAdvanceWidth &&
|
||||||
|
window.TextMetrics &&
|
||||||
|
"actualBoundingBoxLeft" in window.TextMetrics.prototype &&
|
||||||
|
"actualBoundingBoxRight" in window.TextMetrics.prototype
|
||||||
|
) {
|
||||||
|
// could be negative, therefore getting the absolute value
|
||||||
|
const actualWidth =
|
||||||
|
Math.abs(metrics.actualBoundingBoxLeft) +
|
||||||
|
Math.abs(metrics.actualBoundingBoxRight);
|
||||||
|
|
||||||
|
// fallback to advance width if the actual width is zero, i.e. on text editing start
|
||||||
|
// or when actual width does not respect whitespace chars, i.e. spaces
|
||||||
|
// otherwise actual width should always be bigger
|
||||||
|
return Math.max(actualWidth, advanceWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
// since in test env the canvas measureText algo
|
||||||
|
// doesn't measure text and instead just returns number of
|
||||||
|
// characters hence we assume that each letteris 10px
|
||||||
|
if (isTestEnv()) {
|
||||||
|
return advanceWidth * 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
return advanceWidth;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTextWidth = (
|
||||||
|
text: string,
|
||||||
|
font: FontString,
|
||||||
|
forceAdvanceWidth?: true,
|
||||||
|
) => {
|
||||||
|
const lines = splitIntoLines(text);
|
||||||
|
let width = 0;
|
||||||
|
lines.forEach((line) => {
|
||||||
|
width = Math.max(width, getLineWidth(line, font, forceAdvanceWidth));
|
||||||
|
});
|
||||||
|
|
||||||
|
return width;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTextHeight = (
|
||||||
|
text: string,
|
||||||
|
fontSize: number,
|
||||||
|
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||||
|
) => {
|
||||||
|
const lineCount = splitIntoLines(text).length;
|
||||||
|
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const charWidth = (() => {
|
||||||
|
const cachedCharWidth: { [key: FontString]: Array<number> } = {};
|
||||||
|
|
||||||
|
const calculate = (char: string, font: FontString) => {
|
||||||
|
const unicode = char.charCodeAt(0);
|
||||||
|
if (!cachedCharWidth[font]) {
|
||||||
|
cachedCharWidth[font] = [];
|
||||||
|
}
|
||||||
|
if (!cachedCharWidth[font][unicode]) {
|
||||||
|
const width = getLineWidth(char, font, true);
|
||||||
|
cachedCharWidth[font][unicode] = width;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachedCharWidth[font][unicode];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCache = (font: FontString) => {
|
||||||
|
return cachedCharWidth[font];
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearCache = (font: FontString) => {
|
||||||
|
cachedCharWidth[font] = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
calculate,
|
||||||
|
getCache,
|
||||||
|
clearCache,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
|
||||||
|
|
||||||
|
// FIXME rename to getApproxMinContainerWidth
|
||||||
|
export const getApproxMinLineWidth = (
|
||||||
|
font: FontString,
|
||||||
|
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||||
|
) => {
|
||||||
|
const maxCharWidth = getMaxCharWidth(font);
|
||||||
|
if (maxCharWidth === 0) {
|
||||||
|
return (
|
||||||
|
measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
|
||||||
|
BOUND_TEXT_PADDING * 2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return maxCharWidth + BOUND_TEXT_PADDING * 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMinCharWidth = (font: FontString) => {
|
||||||
|
const cache = charWidth.getCache(font);
|
||||||
|
if (!cache) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
|
||||||
|
|
||||||
|
return Math.min(...cacheWithOutEmpty);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMaxCharWidth = (font: FontString) => {
|
||||||
|
const cache = charWidth.getCache(font);
|
||||||
|
if (!cache) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
|
||||||
|
return Math.max(...cacheWithOutEmpty);
|
||||||
|
};
|
||||||
|
|
||||||
export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
|
export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
|
||||||
return container?.boundElements?.length
|
return container?.boundElements?.length
|
||||||
? container?.boundElements?.find((ele) => ele.type === "text")?.id || null
|
? container?.boundElements?.find((ele) => ele.type === "text")?.id || null
|
||||||
@@ -504,6 +712,24 @@ export const getBoundTextMaxHeight = (
|
|||||||
return height - BOUND_TEXT_PADDING * 2;
|
return height - BOUND_TEXT_PADDING * 2;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isMeasureTextSupported = () => {
|
||||||
|
const width = getTextWidth(
|
||||||
|
DUMMY_TEXT,
|
||||||
|
getFontString({
|
||||||
|
fontSize: DEFAULT_FONT_SIZE,
|
||||||
|
fontFamily: DEFAULT_FONT_FAMILY,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return width > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMinTextElementWidth = (
|
||||||
|
font: FontString,
|
||||||
|
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||||
|
) => {
|
||||||
|
return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
|
||||||
|
};
|
||||||
|
|
||||||
/** retrieves text from text elements and concatenates to a single string */
|
/** retrieves text from text elements and concatenates to a single string */
|
||||||
export const getTextFromElements = (
|
export const getTextFromElements = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
|
|||||||
@@ -1,224 +0,0 @@
|
|||||||
import {
|
|
||||||
BOUND_TEXT_PADDING,
|
|
||||||
DEFAULT_FONT_SIZE,
|
|
||||||
DEFAULT_FONT_FAMILY,
|
|
||||||
} from "../constants";
|
|
||||||
import { getFontString, isTestEnv, normalizeEOL } from "../utils";
|
|
||||||
import type { FontString, ExcalidrawTextElement } from "./types";
|
|
||||||
|
|
||||||
export const measureText = (
|
|
||||||
text: string,
|
|
||||||
font: FontString,
|
|
||||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
|
||||||
) => {
|
|
||||||
const _text = text
|
|
||||||
.split("\n")
|
|
||||||
// replace empty lines with single space because leading/trailing empty
|
|
||||||
// lines would be stripped from computation
|
|
||||||
.map((x) => x || " ")
|
|
||||||
.join("\n");
|
|
||||||
const fontSize = parseFloat(font);
|
|
||||||
const height = getTextHeight(_text, fontSize, lineHeight);
|
|
||||||
const width = getTextWidth(_text, font);
|
|
||||||
return { width, height };
|
|
||||||
};
|
|
||||||
|
|
||||||
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
|
|
||||||
|
|
||||||
// FIXME rename to getApproxMinContainerWidth
|
|
||||||
export const getApproxMinLineWidth = (
|
|
||||||
font: FontString,
|
|
||||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
|
||||||
) => {
|
|
||||||
const maxCharWidth = getMaxCharWidth(font);
|
|
||||||
if (maxCharWidth === 0) {
|
|
||||||
return (
|
|
||||||
measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
|
|
||||||
BOUND_TEXT_PADDING * 2
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return maxCharWidth + BOUND_TEXT_PADDING * 2;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getMinTextElementWidth = (
|
|
||||||
font: FontString,
|
|
||||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
|
||||||
) => {
|
|
||||||
return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isMeasureTextSupported = () => {
|
|
||||||
const width = getTextWidth(
|
|
||||||
DUMMY_TEXT,
|
|
||||||
getFontString({
|
|
||||||
fontSize: DEFAULT_FONT_SIZE,
|
|
||||||
fontFamily: DEFAULT_FONT_FAMILY,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return width > 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const normalizeText = (text: string) => {
|
|
||||||
return (
|
|
||||||
normalizeEOL(text)
|
|
||||||
// replace tabs with spaces so they render and measure correctly
|
|
||||||
.replace(/\t/g, " ")
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const splitIntoLines = (text: string) => {
|
|
||||||
return normalizeText(text).split("\n");
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* To get unitless line-height (if unknown) we can calculate it by dividing
|
|
||||||
* height-per-line by fontSize.
|
|
||||||
*/
|
|
||||||
export const detectLineHeight = (textElement: ExcalidrawTextElement) => {
|
|
||||||
const lineCount = splitIntoLines(textElement.text).length;
|
|
||||||
return (textElement.height /
|
|
||||||
lineCount /
|
|
||||||
textElement.fontSize) as ExcalidrawTextElement["lineHeight"];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* We calculate the line height from the font size and the unitless line height,
|
|
||||||
* aligning with the W3C spec.
|
|
||||||
*/
|
|
||||||
export const getLineHeightInPx = (
|
|
||||||
fontSize: ExcalidrawTextElement["fontSize"],
|
|
||||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
|
||||||
) => {
|
|
||||||
return fontSize * lineHeight;
|
|
||||||
};
|
|
||||||
|
|
||||||
// FIXME rename to getApproxMinContainerHeight
|
|
||||||
export const getApproxMinLineHeight = (
|
|
||||||
fontSize: ExcalidrawTextElement["fontSize"],
|
|
||||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
|
||||||
) => {
|
|
||||||
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
|
|
||||||
};
|
|
||||||
|
|
||||||
let textMetricsProvider: TextMetricsProvider | undefined;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set a custom text metrics provider.
|
|
||||||
*
|
|
||||||
* Useful for overriding the width calculation algorithm where canvas API is not available / desired.
|
|
||||||
*/
|
|
||||||
export const setCustomTextMetricsProvider = (provider: TextMetricsProvider) => {
|
|
||||||
textMetricsProvider = provider;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface TextMetricsProvider {
|
|
||||||
getLineWidth(text: string, fontString: FontString): number;
|
|
||||||
}
|
|
||||||
|
|
||||||
class CanvasTextMetricsProvider implements TextMetricsProvider {
|
|
||||||
private canvas: HTMLCanvasElement;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.canvas = document.createElement("canvas");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* We need to use the advance width as that's the closest thing to the browser wrapping algo, hence using it for:
|
|
||||||
* - text wrapping
|
|
||||||
* - wysiwyg editor (+padding)
|
|
||||||
*
|
|
||||||
* > The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position.
|
|
||||||
*/
|
|
||||||
public getLineWidth(text: string, fontString: FontString): number {
|
|
||||||
const context = this.canvas.getContext("2d")!;
|
|
||||||
context.font = fontString;
|
|
||||||
const metrics = context.measureText(text);
|
|
||||||
const advanceWidth = metrics.width;
|
|
||||||
|
|
||||||
// since in test env the canvas measureText algo
|
|
||||||
// doesn't measure text and instead just returns number of
|
|
||||||
// characters hence we assume that each letteris 10px
|
|
||||||
if (isTestEnv()) {
|
|
||||||
return advanceWidth * 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
return advanceWidth;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getLineWidth = (text: string, font: FontString) => {
|
|
||||||
if (!textMetricsProvider) {
|
|
||||||
textMetricsProvider = new CanvasTextMetricsProvider();
|
|
||||||
}
|
|
||||||
|
|
||||||
return textMetricsProvider.getLineWidth(text, font);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getTextWidth = (text: string, font: FontString) => {
|
|
||||||
const lines = splitIntoLines(text);
|
|
||||||
let width = 0;
|
|
||||||
lines.forEach((line) => {
|
|
||||||
width = Math.max(width, getLineWidth(line, font));
|
|
||||||
});
|
|
||||||
|
|
||||||
return width;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getTextHeight = (
|
|
||||||
text: string,
|
|
||||||
fontSize: number,
|
|
||||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
|
||||||
) => {
|
|
||||||
const lineCount = splitIntoLines(text).length;
|
|
||||||
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const charWidth = (() => {
|
|
||||||
const cachedCharWidth: { [key: FontString]: Array<number> } = {};
|
|
||||||
|
|
||||||
const calculate = (char: string, font: FontString) => {
|
|
||||||
const unicode = char.charCodeAt(0);
|
|
||||||
if (!cachedCharWidth[font]) {
|
|
||||||
cachedCharWidth[font] = [];
|
|
||||||
}
|
|
||||||
if (!cachedCharWidth[font][unicode]) {
|
|
||||||
const width = getLineWidth(char, font);
|
|
||||||
cachedCharWidth[font][unicode] = width;
|
|
||||||
}
|
|
||||||
|
|
||||||
return cachedCharWidth[font][unicode];
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCache = (font: FontString) => {
|
|
||||||
return cachedCharWidth[font];
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearCache = (font: FontString) => {
|
|
||||||
cachedCharWidth[font] = [];
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
calculate,
|
|
||||||
getCache,
|
|
||||||
clearCache,
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|
||||||
export const getMinCharWidth = (font: FontString) => {
|
|
||||||
const cache = charWidth.getCache(font);
|
|
||||||
if (!cache) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
|
|
||||||
|
|
||||||
return Math.min(...cacheWithOutEmpty);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getMaxCharWidth = (font: FontString) => {
|
|
||||||
const cache = charWidth.getCache(font);
|
|
||||||
if (!cache) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
|
|
||||||
return Math.max(...cacheWithOutEmpty);
|
|
||||||
};
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ENV } from "../constants";
|
import { ENV } from "../constants";
|
||||||
import { charWidth, getLineWidth } from "./textMeasurements";
|
import { charWidth, getLineWidth } from "./textElement";
|
||||||
import type { FontString } from "./types";
|
import type { FontString } from "./types";
|
||||||
|
|
||||||
let cachedCjkRegex: RegExp | undefined;
|
let cachedCjkRegex: RegExp | undefined;
|
||||||
@@ -385,7 +385,7 @@ export const wrapText = (
|
|||||||
const originalLines = text.split("\n");
|
const originalLines = text.split("\n");
|
||||||
|
|
||||||
for (const originalLine of originalLines) {
|
for (const originalLine of originalLines) {
|
||||||
const currentLineWidth = getLineWidth(originalLine, font);
|
const currentLineWidth = getLineWidth(originalLine, font, true);
|
||||||
|
|
||||||
if (currentLineWidth <= maxWidth) {
|
if (currentLineWidth <= maxWidth) {
|
||||||
lines.push(originalLine);
|
lines.push(originalLine);
|
||||||
@@ -423,7 +423,7 @@ const wrapLine = (
|
|||||||
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
|
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
|
||||||
const testLineWidth = isSingleCharacter(token)
|
const testLineWidth = isSingleCharacter(token)
|
||||||
? currentLineWidth + charWidth.calculate(token, font)
|
? currentLineWidth + charWidth.calculate(token, font)
|
||||||
: getLineWidth(testLine, font);
|
: getLineWidth(testLine, font, true);
|
||||||
|
|
||||||
// build up the current line, skipping length check for possibly trailing whitespaces
|
// build up the current line, skipping length check for possibly trailing whitespaces
|
||||||
if (/\s/.test(token) || testLineWidth <= maxWidth) {
|
if (/\s/.test(token) || testLineWidth <= maxWidth) {
|
||||||
@@ -443,7 +443,7 @@ const wrapLine = (
|
|||||||
|
|
||||||
// trailing line of the wrapped word might still be joined with next token/s
|
// trailing line of the wrapped word might still be joined with next token/s
|
||||||
currentLine = trailingLine;
|
currentLine = trailingLine;
|
||||||
currentLineWidth = getLineWidth(trailingLine, font);
|
currentLineWidth = getLineWidth(trailingLine, font, true);
|
||||||
iterator = tokenIterator.next();
|
iterator = tokenIterator.next();
|
||||||
} else {
|
} else {
|
||||||
// push & reset, but don't iterate on the next token, as we didn't use it yet!
|
// push & reset, but don't iterate on the next token, as we didn't use it yet!
|
||||||
@@ -514,7 +514,7 @@ const wrapWord = (
|
|||||||
* Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`.
|
* Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`.
|
||||||
*/
|
*/
|
||||||
const trimLine = (line: string, font: FontString, maxWidth: number) => {
|
const trimLine = (line: string, font: FontString, maxWidth: number) => {
|
||||||
const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth;
|
const shouldTrimWhitespaces = getLineWidth(line, font, true) > maxWidth;
|
||||||
|
|
||||||
if (!shouldTrimWhitespaces) {
|
if (!shouldTrimWhitespaces) {
|
||||||
return line;
|
return line;
|
||||||
@@ -527,7 +527,7 @@ const trimLine = (line: string, font: FontString, maxWidth: number) => {
|
|||||||
"",
|
"",
|
||||||
];
|
];
|
||||||
|
|
||||||
let trimmedLineWidth = getLineWidth(trimmedLine, font);
|
let trimmedLineWidth = getLineWidth(trimmedLine, font, true);
|
||||||
|
|
||||||
for (const whitespace of Array.from(whitespaces)) {
|
for (const whitespace of Array.from(whitespaces)) {
|
||||||
const _charWidth = charWidth.calculate(whitespace, font);
|
const _charWidth = charWidth.calculate(whitespace, font);
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
getBoundTextElementId,
|
getBoundTextElementId,
|
||||||
getContainerElement,
|
getContainerElement,
|
||||||
getTextElementAngle,
|
getTextElementAngle,
|
||||||
|
getTextWidth,
|
||||||
|
normalizeText,
|
||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
getBoundTextMaxHeight,
|
getBoundTextMaxHeight,
|
||||||
getBoundTextMaxWidth,
|
getBoundTextMaxWidth,
|
||||||
@@ -48,8 +50,6 @@ import {
|
|||||||
originalContainerCache,
|
originalContainerCache,
|
||||||
updateOriginalContainerCache,
|
updateOriginalContainerCache,
|
||||||
} from "./containerCache";
|
} from "./containerCache";
|
||||||
import { getTextWidth } from "./textMeasurements";
|
|
||||||
import { normalizeText } from "./textMeasurements";
|
|
||||||
|
|
||||||
const getTransform = (
|
const getTransform = (
|
||||||
width: number,
|
width: number,
|
||||||
@@ -350,7 +350,7 @@ export const textWysiwyg = ({
|
|||||||
font,
|
font,
|
||||||
getBoundTextMaxWidth(container, boundTextElement),
|
getBoundTextMaxWidth(container, boundTextElement),
|
||||||
);
|
);
|
||||||
const width = getTextWidth(wrappedText, font);
|
const width = getTextWidth(wrappedText, font, true);
|
||||||
editable.style.width = `${width}px`;
|
editable.style.width = `${width}px`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -319,12 +319,6 @@ 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";
|
||||||
@@ -337,23 +331,6 @@ export type ExcalidrawElbowArrowElement = Merge<
|
|||||||
elbowed: true;
|
elbowed: true;
|
||||||
startBinding: FixedPointBinding | null;
|
startBinding: FixedPointBinding | null;
|
||||||
endBinding: FixedPointBinding | null;
|
endBinding: FixedPointBinding | null;
|
||||||
fixedSegments: readonly 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;
|
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import {
|
|||||||
FONT_FAMILY_FALLBACKS,
|
FONT_FAMILY_FALLBACKS,
|
||||||
CJK_HAND_DRAWN_FALLBACK_FONT,
|
CJK_HAND_DRAWN_FALLBACK_FONT,
|
||||||
WINDOWS_EMOJI_FALLBACK_FONT,
|
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||||
|
isSafari,
|
||||||
getFontFamilyFallbacks,
|
getFontFamilyFallbacks,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { isTextElement } from "../element";
|
import { isTextElement } from "../element";
|
||||||
import { getContainerElement } from "../element/textElement";
|
import { charWidth, getContainerElement } from "../element/textElement";
|
||||||
import { containsCJK } from "../element/textWrapping";
|
import { containsCJK } from "../element/textWrapping";
|
||||||
import { ShapeCache } from "../scene/ShapeCache";
|
import { ShapeCache } from "../scene/ShapeCache";
|
||||||
import { getFontString, PromisePool, promiseTry } from "../utils";
|
import { getFontString, PromisePool, promiseTry } from "../utils";
|
||||||
@@ -31,7 +32,6 @@ import type {
|
|||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import type Scene from "../scene/Scene";
|
import type Scene from "../scene/Scene";
|
||||||
import type { ValueOf } from "../utility-types";
|
import type { ValueOf } from "../utility-types";
|
||||||
import { charWidth } from "../element/textMeasurements";
|
|
||||||
|
|
||||||
export class Fonts {
|
export class Fonts {
|
||||||
// it's ok to track fonts across multiple instances only once, so let's use
|
// it's ok to track fonts across multiple instances only once, so let's use
|
||||||
@@ -137,28 +137,50 @@ export class Fonts {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Load font faces for a given scene and trigger scene update.
|
* Load font faces for a given scene and trigger scene update.
|
||||||
|
*
|
||||||
|
* FontFaceSet loadingdone event we listen on may not always
|
||||||
|
* fire (looking at you Safari), so on init we manually load all
|
||||||
|
* fonts and rerender scene text elements once done.
|
||||||
|
*
|
||||||
|
* For Safari we make sure to check against each loaded font face
|
||||||
|
* with the unique characters per family in the scene,
|
||||||
|
* otherwise fonts might remain unloaded.
|
||||||
*/
|
*/
|
||||||
public loadSceneFonts = async (): Promise<FontFace[]> => {
|
public loadSceneFonts = async (): Promise<FontFace[]> => {
|
||||||
const sceneFamilies = this.getSceneFamilies();
|
const sceneFamilies = this.getSceneFamilies();
|
||||||
const charsPerFamily = Fonts.getCharsPerFamily(
|
const charsPerFamily = isSafari
|
||||||
this.scene.getNonDeletedElements(),
|
? Fonts.getCharsPerFamily(this.scene.getNonDeletedElements())
|
||||||
);
|
: undefined;
|
||||||
|
|
||||||
return Fonts.loadFontFaces(sceneFamilies, charsPerFamily);
|
return Fonts.loadFontFaces(sceneFamilies, charsPerFamily);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load font faces for passed elements - use when the scene is unavailable (i.e. export).
|
* Load font faces for passed elements - use when the scene is unavailable (i.e. export).
|
||||||
|
*
|
||||||
|
* For Safari we make sure to check against each loaded font face,
|
||||||
|
* with the unique characters per family in the elements
|
||||||
|
* otherwise fonts might remain unloaded.
|
||||||
*/
|
*/
|
||||||
public static loadElementsFonts = async (
|
public static loadElementsFonts = async (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
): Promise<FontFace[]> => {
|
): Promise<FontFace[]> => {
|
||||||
const fontFamilies = Fonts.getUniqueFamilies(elements);
|
const fontFamilies = Fonts.getUniqueFamilies(elements);
|
||||||
const charsPerFamily = Fonts.getCharsPerFamily(elements);
|
const charsPerFamily = isSafari
|
||||||
|
? Fonts.getCharsPerFamily(elements)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return Fonts.loadFontFaces(fontFamilies, charsPerFamily);
|
return Fonts.loadFontFaces(fontFamilies, charsPerFamily);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all registered font faces.
|
||||||
|
*/
|
||||||
|
public static loadAllFonts = async (): Promise<FontFace[]> => {
|
||||||
|
const allFamilies = Fonts.getAllFamilies();
|
||||||
|
return Fonts.loadFontFaces(allFamilies);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate CSS @font-face declarations for the given elements.
|
* Generate CSS @font-face declarations for the given elements.
|
||||||
*/
|
*/
|
||||||
@@ -201,7 +223,7 @@ export class Fonts {
|
|||||||
|
|
||||||
private static async loadFontFaces(
|
private static async loadFontFaces(
|
||||||
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
|
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
|
||||||
charsPerFamily: Record<number, Set<string>>,
|
charsPerFamily?: Record<number, Set<string>>,
|
||||||
) {
|
) {
|
||||||
// add all registered font faces into the `document.fonts` (if not added already)
|
// add all registered font faces into the `document.fonts` (if not added already)
|
||||||
for (const { fontFaces, metadata } of Fonts.registered.values()) {
|
for (const { fontFaces, metadata } of Fonts.registered.values()) {
|
||||||
@@ -226,7 +248,7 @@ export class Fonts {
|
|||||||
|
|
||||||
private static *fontFacesLoader(
|
private static *fontFacesLoader(
|
||||||
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
|
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
|
||||||
charsPerFamily: Record<number, Set<string>>,
|
charsPerFamily?: Record<number, Set<string>>,
|
||||||
): Generator<Promise<void | readonly [number, FontFace[]]>> {
|
): Generator<Promise<void | readonly [number, FontFace[]]>> {
|
||||||
for (const [index, fontFamily] of fontFamilies.entries()) {
|
for (const [index, fontFamily] of fontFamilies.entries()) {
|
||||||
const font = getFontString({
|
const font = getFontString({
|
||||||
@@ -234,9 +256,12 @@ export class Fonts {
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
});
|
});
|
||||||
|
|
||||||
// WARN: without "text" param it does not have to mean that all font faces are loaded as it could be just one irrelevant font face!
|
// WARN: without "text" param it does not have to mean that all font faces are loaded, instead it could be just one!
|
||||||
// instead, we are always checking chars used in the family, so that no required font faces remain unloaded
|
// for Safari on init, we rather check with the "text" param, even though it's less efficient, as otherwise fonts might remain unloaded
|
||||||
const text = Fonts.getCharacters(charsPerFamily, fontFamily);
|
const text =
|
||||||
|
isSafari && charsPerFamily
|
||||||
|
? Fonts.getCharacters(charsPerFamily, fontFamily)
|
||||||
|
: "";
|
||||||
|
|
||||||
if (!window.document.fonts.check(font, text)) {
|
if (!window.document.fonts.check(font, text)) {
|
||||||
yield promiseTry(async () => {
|
yield promiseTry(async () => {
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import React from "react";
|
||||||
import type { ExcalidrawElement } from "./element/types";
|
import type { ExcalidrawElement } from "./element/types";
|
||||||
import { convertToExcalidrawElements, Excalidraw } from "./index";
|
import { convertToExcalidrawElements, Excalidraw } from "./index";
|
||||||
import { API } from "./tests/helpers/api";
|
import { API } from "./tests/helpers/api";
|
||||||
import { Keyboard, Pointer } from "./tests/helpers/ui";
|
import { Keyboard, Pointer } from "./tests/helpers/ui";
|
||||||
import { getCloneByOrigId, render } from "./tests/test-utils";
|
import { render } from "./tests/test-utils";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
const mouse = new Pointer("mouse");
|
const mouse = new Pointer("mouse");
|
||||||
@@ -412,9 +413,9 @@ describe("adding elements to frames", () => {
|
|||||||
|
|
||||||
dragElementIntoFrame(frame, rect2);
|
dragElementIntoFrame(frame, rect2);
|
||||||
|
|
||||||
selectElementAndDuplicate(rect2);
|
const rect2_copy = { ...rect2, id: `${rect2.id}_copy` };
|
||||||
|
|
||||||
const rect2_copy = getCloneByOrigId(rect2.id);
|
selectElementAndDuplicate(rect2);
|
||||||
|
|
||||||
expect(rect2_copy.frameId).toBe(frame.id);
|
expect(rect2_copy.frameId).toBe(frame.id);
|
||||||
expect(rect2.frameId).toBe(frame.id);
|
expect(rect2.frameId).toBe(frame.id);
|
||||||
@@ -426,11 +427,11 @@ describe("adding elements to frames", () => {
|
|||||||
|
|
||||||
dragElementIntoFrame(frame, rect2);
|
dragElementIntoFrame(frame, rect2);
|
||||||
|
|
||||||
|
const rect2_copy = { ...rect2, id: `${rect2.id}_copy` };
|
||||||
|
|
||||||
// move the rect2 outside the frame
|
// move the rect2 outside the frame
|
||||||
selectElementAndDuplicate(rect2, [-1000, -1000]);
|
selectElementAndDuplicate(rect2, [-1000, -1000]);
|
||||||
|
|
||||||
const rect2_copy = getCloneByOrigId(rect2.id);
|
|
||||||
|
|
||||||
expect(rect2_copy.frameId).toBe(frame.id);
|
expect(rect2_copy.frameId).toBe(frame.id);
|
||||||
expect(rect2.frameId).toBe(null);
|
expect(rect2.frameId).toBe(null);
|
||||||
expectEqualIds([rect2_copy, frame, rect2]);
|
expectEqualIds([rect2_copy, frame, rect2]);
|
||||||
|
|||||||
+43
-213
@@ -95,11 +95,12 @@ export const getElementsCompletelyInFrame = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const isElementContainingFrame = (
|
export const isElementContainingFrame = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
frame: ExcalidrawFrameLikeElement,
|
frame: ExcalidrawFrameLikeElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
) => {
|
) => {
|
||||||
return getElementsWithinSelection([frame], element, elementsMap).some(
|
return getElementsWithinSelection(elements, element, elementsMap).some(
|
||||||
(e) => e.id === frame.id,
|
(e) => e.id === frame.id,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -143,7 +144,7 @@ export const elementOverlapsWithFrame = (
|
|||||||
return (
|
return (
|
||||||
elementsAreInFrameBounds([element], frame, elementsMap) ||
|
elementsAreInFrameBounds([element], frame, elementsMap) ||
|
||||||
isElementIntersectingFrame(element, frame, elementsMap) ||
|
isElementIntersectingFrame(element, frame, elementsMap) ||
|
||||||
isElementContainingFrame(element, frame, elementsMap)
|
isElementContainingFrame([frame], element, frame, elementsMap)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -282,7 +283,7 @@ export const getElementsInResizingFrame = (
|
|||||||
const elementsCompletelyInFrame = new Set([
|
const elementsCompletelyInFrame = new Set([
|
||||||
...getElementsCompletelyInFrame(allElements, frame, elementsMap),
|
...getElementsCompletelyInFrame(allElements, frame, elementsMap),
|
||||||
...prevElementsInFrame.filter((element) =>
|
...prevElementsInFrame.filter((element) =>
|
||||||
isElementContainingFrame(element, frame, elementsMap),
|
isElementContainingFrame(allElements, element, frame, elementsMap),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -369,57 +370,12 @@ export const getElementsInNewFrame = (
|
|||||||
frame: ExcalidrawFrameLikeElement,
|
frame: ExcalidrawFrameLikeElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
) => {
|
) => {
|
||||||
return omitPartialGroups(
|
return omitGroupsContainingFrameLikes(
|
||||||
omitGroupsContainingFrameLikes(
|
elements,
|
||||||
elements,
|
getElementsCompletelyInFrame(elements, frame, elementsMap),
|
||||||
getElementsCompletelyInFrame(elements, frame, elementsMap),
|
|
||||||
),
|
|
||||||
frame,
|
|
||||||
elementsMap,
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const omitPartialGroups = (
|
|
||||||
elements: ExcalidrawElement[],
|
|
||||||
frame: ExcalidrawFrameLikeElement,
|
|
||||||
allElementsMap: ElementsMap,
|
|
||||||
) => {
|
|
||||||
const elementsToReturn = [];
|
|
||||||
const checkedGroups = new Map<string, boolean>();
|
|
||||||
|
|
||||||
for (const element of elements) {
|
|
||||||
let shouldOmit = false;
|
|
||||||
if (element.groupIds.length > 0) {
|
|
||||||
// if some partial group should be omitted, then all elements in that group should be omitted
|
|
||||||
if (element.groupIds.some((gid) => checkedGroups.get(gid))) {
|
|
||||||
shouldOmit = true;
|
|
||||||
} else {
|
|
||||||
const allElementsInGroup = new Set(
|
|
||||||
element.groupIds.flatMap((gid) =>
|
|
||||||
getElementsInGroup(allElementsMap, gid),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
shouldOmit = !elementsAreInFrameBounds(
|
|
||||||
Array.from(allElementsInGroup),
|
|
||||||
frame,
|
|
||||||
allElementsMap,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
element.groupIds.forEach((gid) => {
|
|
||||||
checkedGroups.set(gid, shouldOmit);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!shouldOmit) {
|
|
||||||
elementsToReturn.push(element);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return elementsToReturn;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getContainingFrame = (
|
export const getContainingFrame = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
@@ -498,7 +454,6 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
|
|||||||
allElements: T,
|
allElements: T,
|
||||||
elementsToAdd: NonDeletedExcalidrawElement[],
|
elementsToAdd: NonDeletedExcalidrawElement[],
|
||||||
frame: ExcalidrawFrameLikeElement,
|
frame: ExcalidrawFrameLikeElement,
|
||||||
appState: AppState,
|
|
||||||
): T => {
|
): T => {
|
||||||
const elementsMap = arrayToMap(allElements);
|
const elementsMap = arrayToMap(allElements);
|
||||||
const currTargetFrameChildrenMap = new Map<ExcalidrawElement["id"], true>();
|
const currTargetFrameChildrenMap = new Map<ExcalidrawElement["id"], true>();
|
||||||
@@ -534,17 +489,6 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// if the element is already in another frame (which is also in elementsToAdd),
|
|
||||||
// it means that frame and children are selected at the same time
|
|
||||||
// => keep original frame membership, do not add to the target frame
|
|
||||||
if (
|
|
||||||
element.frameId &&
|
|
||||||
appState.selectedElementIds[element.id] &&
|
|
||||||
appState.selectedElementIds[element.frameId]
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currTargetFrameChildrenMap.has(element.id)) {
|
if (!currTargetFrameChildrenMap.has(element.id)) {
|
||||||
finalElementsToAdd.push(element);
|
finalElementsToAdd.push(element);
|
||||||
}
|
}
|
||||||
@@ -633,7 +577,6 @@ export const replaceAllElementsInFrame = <T extends ExcalidrawElement>(
|
|||||||
removeAllElementsFromFrame(allElements, frame),
|
removeAllElementsFromFrame(allElements, frame),
|
||||||
nextElementsInFrame,
|
nextElementsInFrame,
|
||||||
frame,
|
frame,
|
||||||
app.state,
|
|
||||||
).slice();
|
).slice();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -740,16 +683,6 @@ export const getTargetFrame = (
|
|||||||
? getContainerElement(element, elementsMap) || element
|
? getContainerElement(element, elementsMap) || element
|
||||||
: element;
|
: element;
|
||||||
|
|
||||||
// if the element and its containing frame are both selected, then
|
|
||||||
// the containing frame is the target frame
|
|
||||||
if (
|
|
||||||
_element.frameId &&
|
|
||||||
appState.selectedElementIds[_element.id] &&
|
|
||||||
appState.selectedElementIds[_element.frameId]
|
|
||||||
) {
|
|
||||||
return getContainingFrame(_element, elementsMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
return appState.selectedElementIds[_element.id] &&
|
return appState.selectedElementIds[_element.id] &&
|
||||||
appState.selectedElementsAreBeingDragged
|
appState.selectedElementsAreBeingDragged
|
||||||
? appState.frameToHighlight
|
? appState.frameToHighlight
|
||||||
@@ -762,151 +695,61 @@ export const isElementInFrame = (
|
|||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
allElementsMap: ElementsMap,
|
allElementsMap: ElementsMap,
|
||||||
appState: StaticCanvasAppState,
|
appState: StaticCanvasAppState,
|
||||||
opts?: {
|
|
||||||
targetFrame?: ExcalidrawFrameLikeElement;
|
|
||||||
checkedGroups?: Map<string, boolean>;
|
|
||||||
},
|
|
||||||
) => {
|
) => {
|
||||||
const frame =
|
const frame = getTargetFrame(element, allElementsMap, appState);
|
||||||
opts?.targetFrame ?? getTargetFrame(element, allElementsMap, appState);
|
|
||||||
|
|
||||||
if (!frame) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const _element = isTextElement(element)
|
const _element = isTextElement(element)
|
||||||
? getContainerElement(element, allElementsMap) || element
|
? getContainerElement(element, allElementsMap) || element
|
||||||
: element;
|
: element;
|
||||||
|
|
||||||
const setGroupsInFrame = (isInFrame: boolean) => {
|
if (frame) {
|
||||||
if (opts?.checkedGroups) {
|
// Perf improvement:
|
||||||
_element.groupIds.forEach((groupId) => {
|
// For an element that's already in a frame, if it's not being dragged
|
||||||
opts.checkedGroups?.set(groupId, isInFrame);
|
// then there is no need to refer to geometry (which, yes, is slow) to check if it's in a frame.
|
||||||
});
|
// It has to be in its containing frame.
|
||||||
|
if (
|
||||||
|
!appState.selectedElementIds[element.id] ||
|
||||||
|
!appState.selectedElementsAreBeingDragged
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
if (
|
if (_element.groupIds.length === 0) {
|
||||||
// if the element is not selected, or it is selected but not being dragged,
|
return elementOverlapsWithFrame(_element, frame, allElementsMap);
|
||||||
// frame membership won't update, so return true
|
|
||||||
!appState.selectedElementIds[_element.id] ||
|
|
||||||
!appState.selectedElementsAreBeingDragged ||
|
|
||||||
// if both frame and element are selected, won't update membership, so return true
|
|
||||||
(appState.selectedElementIds[_element.id] &&
|
|
||||||
appState.selectedElementIds[frame.id])
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_element.groupIds.length === 0) {
|
|
||||||
return elementOverlapsWithFrame(_element, frame, allElementsMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const gid of _element.groupIds) {
|
|
||||||
if (opts?.checkedGroups?.has(gid)) {
|
|
||||||
return opts.checkedGroups.get(gid)!!;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const allElementsInGroup = new Set(
|
const allElementsInGroup = new Set(
|
||||||
_element.groupIds
|
_element.groupIds.flatMap((gid) =>
|
||||||
.filter((gid) => {
|
getElementsInGroup(allElementsMap, gid),
|
||||||
if (opts?.checkedGroups) {
|
),
|
||||||
return !opts.checkedGroups.has(gid);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.flatMap((gid) => getElementsInGroup(allElementsMap, gid)),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (appState.editingGroupId && appState.selectedElementsAreBeingDragged) {
|
|
||||||
const selectedElements = new Set(
|
|
||||||
getSelectedElements(allElementsMap, appState),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const editingGroupOverlapsFrame = appState.frameToHighlight !== null;
|
if (appState.editingGroupId && appState.selectedElementsAreBeingDragged) {
|
||||||
|
const selectedElements = new Set(
|
||||||
|
getSelectedElements(allElementsMap, appState),
|
||||||
|
);
|
||||||
|
|
||||||
if (editingGroupOverlapsFrame) {
|
const editingGroupOverlapsFrame = appState.frameToHighlight !== null;
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedElements.forEach((selectedElement) => {
|
if (editingGroupOverlapsFrame) {
|
||||||
allElementsInGroup.delete(selectedElement);
|
return true;
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const elementInGroup of allElementsInGroup) {
|
|
||||||
if (isFrameLikeElement(elementInGroup)) {
|
|
||||||
setGroupsInFrame(false);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const elementInGroup of allElementsInGroup) {
|
|
||||||
if (elementOverlapsWithFrame(elementInGroup, frame, allElementsMap)) {
|
|
||||||
setGroupsInFrame(true);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const shouldApplyFrameClip = (
|
|
||||||
element: ExcalidrawElement,
|
|
||||||
frame: ExcalidrawFrameLikeElement,
|
|
||||||
appState: StaticCanvasAppState,
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
checkedGroups?: Map<string, boolean>,
|
|
||||||
) => {
|
|
||||||
if (!appState.frameRendering || !appState.frameRendering.clip) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// for individual elements, only clip when the element is
|
|
||||||
// a. overlapping with the frame, or
|
|
||||||
// b. containing the frame, for example when an element is used as a background
|
|
||||||
// and is therefore bigger than the frame and completely contains the frame
|
|
||||||
const shouldClipElementItself =
|
|
||||||
isElementIntersectingFrame(element, frame, elementsMap) ||
|
|
||||||
isElementContainingFrame(element, frame, elementsMap);
|
|
||||||
|
|
||||||
if (shouldClipElementItself) {
|
|
||||||
for (const groupId of element.groupIds) {
|
|
||||||
checkedGroups?.set(groupId, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if an element is outside the frame, but is part of a group that has some elements
|
|
||||||
// "in" the frame, we should clip the element
|
|
||||||
if (
|
|
||||||
!shouldClipElementItself &&
|
|
||||||
element.groupIds.length > 0 &&
|
|
||||||
!elementsAreInFrameBounds([element], frame, elementsMap)
|
|
||||||
) {
|
|
||||||
let shouldClip = false;
|
|
||||||
|
|
||||||
// if no elements are being dragged, we can skip the geometry check
|
|
||||||
// because we know if the element is in the given frame or not
|
|
||||||
if (!appState.selectedElementsAreBeingDragged) {
|
|
||||||
shouldClip = element.frameId === frame.id;
|
|
||||||
for (const groupId of element.groupIds) {
|
|
||||||
checkedGroups?.set(groupId, shouldClip);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
shouldClip = isElementInFrame(element, elementsMap, appState, {
|
selectedElements.forEach((selectedElement) => {
|
||||||
targetFrame: frame,
|
allElementsInGroup.delete(selectedElement);
|
||||||
checkedGroups,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const groupId of element.groupIds) {
|
for (const elementInGroup of allElementsInGroup) {
|
||||||
checkedGroups?.set(groupId, shouldClip);
|
if (isFrameLikeElement(elementInGroup)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return shouldClip;
|
for (const elementInGroup of allElementsInGroup) {
|
||||||
|
if (elementOverlapsWithFrame(elementInGroup, frame, allElementsMap)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -936,16 +779,3 @@ export const getElementsOverlappingFrame = (
|
|||||||
.filter((el) => !el.frameId || el.frameId === frame.id)
|
.filter((el) => !el.frameId || el.frameId === frame.id)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const frameAndChildrenSelectedTogether = (
|
|
||||||
selectedElements: readonly ExcalidrawElement[],
|
|
||||||
) => {
|
|
||||||
const selectedElementsMap = arrayToMap(selectedElements);
|
|
||||||
|
|
||||||
return (
|
|
||||||
selectedElements.length > 1 &&
|
|
||||||
selectedElements.some(
|
|
||||||
(element) => element.frameId && selectedElementsMap.has(element.frameId),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user