Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e84a931e1 | |||
| 3b6de119b3 | |||
| 2b0e4c9623 | |||
| c9ba7f839c | |||
| b4ce7c713b | |||
| 816c81c12e | |||
| 92d25446d6 | |||
| e73a5b0116 | |||
| 21dd1cfacc | |||
| fa1f7d9f22 | |||
| 3d8c12fba4 | |||
| 757dfeb6ad | |||
| a0e93b6040 | |||
| 499e9d64a5 | |||
| c1dbbdf678 | |||
| 47c254216b | |||
| d1cff91b75 | |||
| 437595fa65 | |||
| 60b275880d | |||
| cae9d2bcbd | |||
| 2874f9e48c | |||
| 0b3a5e7cc4 | |||
| 7ea3229e17 | |||
| b0404b10b6 | |||
| eb959128ac | |||
| 4c3d037f9c | |||
| 5852d0d410 | |||
| c1e00c44f5 | |||
| ffcb67b21f | |||
| 46ddd60948 | |||
| 89a9badc27 | |||
| 8b3e149db6 | |||
| a70417f23f | |||
| 1c8e8bb0f3 | |||
| 3a5ef4020d | |||
| 063533aede | |||
| b43260d97b | |||
| 83d3943cd0 | |||
| f39ac4a653 | |||
| 54a9826817 | |||
| d29fd62e41 | |||
| b57f3e0096 | |||
| f12ae80ba1 | |||
| f7b537a8b1 | |||
| 94364af68f | |||
| dfa1ce572b | |||
| b552c60714 | |||
| 216afc3625 | |||
| 802cde3501 | |||
| f5cf81ce42 | |||
| 6a891365b9 | |||
| 54fa0c9089 | |||
| dfdd994dbb | |||
| 28691e14b1 | |||
| 60759d314d | |||
| d5e37cda81 | |||
| 6135548534 | |||
| acf54c6f38 | |||
| 84a309d669 | |||
| 3c8e893cab | |||
| 9ba0f5dbc9 | |||
| 60ab14c2f6 | |||
| 0988ecfef4 | |||
| 1f47d61e8c | |||
| 9d760336d1 | |||
| 0443511954 | |||
| 5a73b9a363 | |||
| 24a6941861 | |||
| a0b98a944f | |||
| 6ebf52279d | |||
| 3b97f5a10c | |||
| da59205846 | |||
| b9a255407f | |||
| cc6c29c0b9 | |||
| 87faa5d3da | |||
| c158187f20 | |||
| 63e1148280 | |||
| b5fc873323 | |||
| 6c908553a9 | |||
| 0586fc138c |
+2
-2
@@ -12,7 +12,7 @@ VITE_APP_WS_SERVER_URL=http://localhost:3002
|
||||
VITE_APP_PLUS_LP=https://plus.excalidraw.com
|
||||
VITE_APP_PLUS_APP=http://localhost:3000
|
||||
|
||||
VITE_APP_AI_BACKEND=http://localhost:3015
|
||||
VITE_APP_AI_BACKEND=http://localhost:3016
|
||||
|
||||
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
|
||||
|
||||
@@ -27,7 +27,7 @@ VITE_APP_ENABLE_TRACKING=true
|
||||
FAST_REFRESH=false
|
||||
|
||||
# The port the run the dev server
|
||||
VITE_APP_PORT=3000
|
||||
VITE_APP_PORT=3001
|
||||
|
||||
#Debug flags
|
||||
|
||||
|
||||
+22
-1
@@ -39,5 +39,26 @@
|
||||
"allowReferrer": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["packages/excalidraw/**/*.{ts,tsx}"],
|
||||
"excludedFiles": ["packages/excalidraw/**/*.test.{ts,tsx}", "packages/excalidraw/**/*.test.*.{ts,tsx}"],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["@excalidraw/excalidraw"],
|
||||
"message": "Do not import from the barrel 'index.tsx' files. Use direct relative imports to the specific module instead.",
|
||||
"allowTypeImports": true
|
||||
}
|
||||
],
|
||||
"paths": [".", "..", "../..", "../../..", "../../../..", "../../../../..", "../index", "../../index", "../../../index", "../../../../index"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
export SENTRY_RELEASE=$(sentry-cli releases propose-version)
|
||||
sentry-cli releases new $SENTRY_RELEASE --project $SENTRY_PROJECT
|
||||
sentry-cli releases set-commits --auto $SENTRY_RELEASE
|
||||
sentry-cli releases files $SENTRY_RELEASE upload-sourcemaps --no-rewrite ./build/static/js/ --url-prefix "~/static/js"
|
||||
sentry-cli sourcemaps upload --release $SENTRY_RELEASE --no-rewrite ./build/static/js/ --url-prefix "~/static/js"
|
||||
sentry-cli releases finalize $SENTRY_RELEASE
|
||||
sentry-cli releases deploys $SENTRY_RELEASE new -e production
|
||||
env:
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
- name: "Install Node"
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: "18.x"
|
||||
node-version: "20.x"
|
||||
- name: "Install Deps"
|
||||
run: yarn install
|
||||
- name: "Test Coverage"
|
||||
|
||||
@@ -9,10 +9,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js 18.x
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- name: Install and test
|
||||
run: |
|
||||
yarn install
|
||||
|
||||
@@ -172,7 +172,7 @@ convertToExcalidrawElements([
|
||||
type: "arrow",
|
||||
x: 450,
|
||||
y: 20,
|
||||
startArrowhead: "dot",
|
||||
startArrowhead: "circle",
|
||||
endArrowhead: "triangle",
|
||||
strokeColor: "#1971c2",
|
||||
strokeWidth: 2,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/data/transform";
|
||||
import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/element/transform";
|
||||
import type { FileId } from "@excalidraw/excalidraw/element/types";
|
||||
|
||||
const elements: ExcalidrawElementSkeleton[] = [
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"@excalidraw/excalidraw": "*",
|
||||
"browser-fs-access": "0.29.1"
|
||||
"browser-fs-access": "0.38.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "5.0.12",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vite": "5.0.12"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
|
||||
@@ -4,8 +4,6 @@ import { unstable_batchedUpdates } from "react-dom";
|
||||
|
||||
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
|
||||
|
||||
const INPUT_CHANGE_INTERVAL_MS = 500;
|
||||
|
||||
export type ResolvablePromise<T> = Promise<T> & {
|
||||
resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void;
|
||||
reject: (error: Error) => void;
|
||||
@@ -54,40 +52,6 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
||||
extensions,
|
||||
mimeTypes,
|
||||
multiple: opts.multiple ?? false,
|
||||
legacySetup: (resolve, reject, input) => {
|
||||
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
|
||||
const focusHandler = () => {
|
||||
checkForFile();
|
||||
document.addEventListener("keyup", scheduleRejection);
|
||||
document.addEventListener("pointerup", scheduleRejection);
|
||||
scheduleRejection();
|
||||
};
|
||||
const checkForFile = () => {
|
||||
// this hack might not work when expecting multiple files
|
||||
if (input.files?.length) {
|
||||
const ret = opts.multiple ? [...input.files] : input.files[0];
|
||||
resolve(ret as RetType);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(() => {
|
||||
window.addEventListener("focus", focusHandler);
|
||||
});
|
||||
const interval = window.setInterval(() => {
|
||||
checkForFile();
|
||||
}, INPUT_CHANGE_INTERVAL_MS);
|
||||
return (rejectPromise) => {
|
||||
clearInterval(interval);
|
||||
scheduleRejection.cancel();
|
||||
window.removeEventListener("focus", focusHandler);
|
||||
document.removeEventListener("keyup", scheduleRejection);
|
||||
document.removeEventListener("pointerup", scheduleRejection);
|
||||
if (rejectPromise) {
|
||||
// so that something is shown in console if we need to debug this
|
||||
console.warn("Opening the file was canceled (legacy-fs).");
|
||||
rejectPromise(new Error("Request Aborted"));
|
||||
}
|
||||
};
|
||||
},
|
||||
}) as Promise<RetType>;
|
||||
};
|
||||
|
||||
|
||||
+132
-28
@@ -5,6 +5,8 @@ import {
|
||||
CaptureUpdateAction,
|
||||
reconcileElements,
|
||||
useEditorInterface,
|
||||
ExcalidrawAPIProvider,
|
||||
useExcalidrawAPI,
|
||||
} from "@excalidraw/excalidraw";
|
||||
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
||||
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
|
||||
@@ -34,7 +36,6 @@ import {
|
||||
import polyfill from "@excalidraw/excalidraw/polyfill";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { loadFromBlob } from "@excalidraw/excalidraw/data/blob";
|
||||
import { useCallbackRefState } from "@excalidraw/excalidraw/hooks/useCallbackRefState";
|
||||
import { t } from "@excalidraw/excalidraw/i18n";
|
||||
|
||||
import {
|
||||
@@ -48,7 +49,11 @@ import {
|
||||
youtubeIcon,
|
||||
} from "@excalidraw/excalidraw/components/icons";
|
||||
import { isElementLink } from "@excalidraw/element";
|
||||
import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore";
|
||||
import {
|
||||
bumpElementVersions,
|
||||
restoreAppState,
|
||||
restoreElements,
|
||||
} from "@excalidraw/excalidraw/data/restore";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { isInitializedImageElement } from "@excalidraw/element";
|
||||
import clsx from "clsx";
|
||||
@@ -70,6 +75,7 @@ import type {
|
||||
BinaryFiles,
|
||||
ExcalidrawInitialDataState,
|
||||
UIAppState,
|
||||
ExcalidrawProps,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
import type { ResolutionType } from "@excalidraw/common/utility-types";
|
||||
import type { ResolvablePromise } from "@excalidraw/common/utils";
|
||||
@@ -105,11 +111,12 @@ import { TopErrorBoundary } from "./components/TopErrorBoundary";
|
||||
import {
|
||||
exportToBackend,
|
||||
getCollaborationLinkData,
|
||||
importFromBackend,
|
||||
isCollaborationLink,
|
||||
loadScene,
|
||||
} from "./data";
|
||||
|
||||
import { updateStaleImageStatuses } from "./data/FileManager";
|
||||
import { FileStatusStore } from "./data/fileStatusStore";
|
||||
import {
|
||||
importFromLocalStorage,
|
||||
importUsernameFromLocalStorage,
|
||||
@@ -224,9 +231,20 @@ const initializeScene = async (opts: {
|
||||
|
||||
const localDataState = importFromLocalStorage();
|
||||
|
||||
let scene: RestoredDataState & {
|
||||
let scene: Omit<
|
||||
RestoredDataState,
|
||||
// we're not storing files in the scene database/localStorage, and instead
|
||||
// fetch them async from a different store
|
||||
"files"
|
||||
> & {
|
||||
scrollToContent?: boolean;
|
||||
} = await loadScene(null, null, localDataState);
|
||||
} = {
|
||||
elements: restoreElements(localDataState?.elements, null, {
|
||||
repairBindings: true,
|
||||
deleteInvisibleElements: true,
|
||||
}),
|
||||
appState: restoreAppState(localDataState?.appState, null),
|
||||
};
|
||||
|
||||
let roomLinkData = getCollaborationLinkData(window.location.href);
|
||||
const isExternalScene = !!(id || jsonBackendMatch || roomLinkData);
|
||||
@@ -240,11 +258,26 @@ const initializeScene = async (opts: {
|
||||
(await openConfirmModal(shareableLinkConfirmDialog))
|
||||
) {
|
||||
if (jsonBackendMatch) {
|
||||
scene = await loadScene(
|
||||
const imported = await importFromBackend(
|
||||
jsonBackendMatch[1],
|
||||
jsonBackendMatch[2],
|
||||
localDataState,
|
||||
);
|
||||
|
||||
scene = {
|
||||
elements: bumpElementVersions(
|
||||
restoreElements(imported.elements, null, {
|
||||
repairBindings: true,
|
||||
deleteInvisibleElements: true,
|
||||
}),
|
||||
localDataState?.elements,
|
||||
),
|
||||
appState: restoreAppState(
|
||||
imported.appState,
|
||||
// local appState when importing from backend to ensure we restore
|
||||
// localStorage user settings which we do not persist on server.
|
||||
localDataState?.appState,
|
||||
),
|
||||
};
|
||||
}
|
||||
scene.scrollToContent = true;
|
||||
if (!roomLinkData) {
|
||||
@@ -339,6 +372,8 @@ const initializeScene = async (opts: {
|
||||
};
|
||||
|
||||
const ExcalidrawWrapper = () => {
|
||||
const excalidrawAPI = useExcalidrawAPI();
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const isCollabDisabled = isRunningInIframe();
|
||||
|
||||
@@ -369,9 +404,6 @@ const ExcalidrawWrapper = () => {
|
||||
}, VERSION_TIMEOUT);
|
||||
}, []);
|
||||
|
||||
const [excalidrawAPI, excalidrawRefCallback] =
|
||||
useCallbackRefState<ExcalidrawImperativeAPI>();
|
||||
|
||||
const [, setShareDialogState] = useAtom(shareDialogStateAtom);
|
||||
const [collabAPI] = useAtom(collabAPIAtom);
|
||||
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
|
||||
@@ -403,18 +435,15 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
}, [excalidrawAPI]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadImages = (
|
||||
data: ResolutionType<typeof initializeScene>,
|
||||
isInitialLoad = false,
|
||||
) => {
|
||||
if (!data.scene) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoisted loadImages
|
||||
// ---------------------------------------------------------------------------
|
||||
const loadImages = useCallback(
|
||||
(data: ResolutionType<typeof initializeScene>, isInitialLoad = false) => {
|
||||
if (!data.scene || !excalidrawAPI) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (collabAPI?.isCollaborating()) {
|
||||
if (data.scene.elements) {
|
||||
collabAPI
|
||||
@@ -441,6 +470,12 @@ const ExcalidrawWrapper = () => {
|
||||
}, [] as FileId[]) || [];
|
||||
|
||||
if (data.isExternalScene) {
|
||||
if (fileIds.length) {
|
||||
// Direct Firebase call (not through FileManager), so track manually
|
||||
FileStatusStore.updateStatuses(
|
||||
fileIds.map((id) => [id, "loading"]),
|
||||
);
|
||||
}
|
||||
loadFilesFromFirebase(
|
||||
`${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
|
||||
data.key,
|
||||
@@ -452,12 +487,18 @@ const ExcalidrawWrapper = () => {
|
||||
erroredFiles,
|
||||
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
});
|
||||
FileStatusStore.updateStatuses([
|
||||
...loadedFiles.map((f) => [f.id, "loaded"] as [FileId, "loaded"]),
|
||||
...[...erroredFiles.keys()].map(
|
||||
(id) => [id, "error"] as [FileId, "error"],
|
||||
),
|
||||
]);
|
||||
});
|
||||
} else if (isInitialLoad) {
|
||||
if (fileIds.length) {
|
||||
LocalData.fileStorage
|
||||
.getFiles(fileIds)
|
||||
.then(({ loadedFiles, erroredFiles }) => {
|
||||
.then(async ({ loadedFiles, erroredFiles }) => {
|
||||
if (loadedFiles.length) {
|
||||
excalidrawAPI.addFiles(loadedFiles);
|
||||
}
|
||||
@@ -470,10 +511,19 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
// on fresh load, clear unused files from IDB (from previous
|
||||
// session)
|
||||
LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds });
|
||||
LocalData.fileStorage.clearObsoleteFiles({
|
||||
currentFileIds: fileIds,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
[collabAPI, excalidrawAPI],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
|
||||
return;
|
||||
}
|
||||
|
||||
initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => {
|
||||
loadImages(data, /* isInitialLoad */ true);
|
||||
@@ -496,8 +546,10 @@ const ExcalidrawWrapper = () => {
|
||||
loadImages(data);
|
||||
if (data.scene) {
|
||||
excalidrawAPI.updateScene({
|
||||
...data.scene,
|
||||
...restore(data.scene, null, null, { repairBindings: true }),
|
||||
elements: restoreElements(data.scene.elements, null, {
|
||||
repairBindings: true,
|
||||
}),
|
||||
appState: restoreAppState(data.scene.appState, null),
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
});
|
||||
}
|
||||
@@ -596,7 +648,7 @@ const ExcalidrawWrapper = () => {
|
||||
false,
|
||||
);
|
||||
};
|
||||
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
||||
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode, loadImages]);
|
||||
|
||||
useEffect(() => {
|
||||
const unloadHandler = (event: BeforeUnloadEvent) => {
|
||||
@@ -741,6 +793,56 @@ const ExcalidrawWrapper = () => {
|
||||
[setShareDialogState],
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// onExport — intercepts file save to wait for pending image loads
|
||||
// ---------------------------------------------------------------------------
|
||||
const onExport: Required<ExcalidrawProps>["onExport"] = useCallback(
|
||||
async function* () {
|
||||
let snapshot = FileStatusStore.getSnapshot();
|
||||
const { pending, total } = FileStatusStore.getPendingCount(
|
||||
snapshot.value,
|
||||
);
|
||||
if (pending === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Yield initial progress
|
||||
yield {
|
||||
type: "progress",
|
||||
progress: (total - pending) / total,
|
||||
message: `Loading images (${total - pending}/${total})...`,
|
||||
};
|
||||
|
||||
// Wait for all pending images to finish
|
||||
while (true) {
|
||||
snapshot = await FileStatusStore.pull(snapshot.version);
|
||||
const { pending: nowPending, total: nowTotal } =
|
||||
FileStatusStore.getPendingCount(snapshot.value);
|
||||
|
||||
yield {
|
||||
type: "progress",
|
||||
progress: (nowTotal - nowPending) / nowTotal,
|
||||
message: `Loading images (${nowTotal - nowPending}/${nowTotal})...`,
|
||||
};
|
||||
|
||||
if (nowPending === 0) {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
yield {
|
||||
type: "progress",
|
||||
message: `Preparing export...`,
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// const onExport = () => {
|
||||
// return new Promise((r) => setTimeout(r, 2500));
|
||||
// // console.log("onExport");
|
||||
// };
|
||||
|
||||
// browsers generally prevent infinite self-embedding, there are
|
||||
// cases where it still happens, and while we disallow self-embedding
|
||||
// by not whitelisting our own origin, this serves as an additional guard
|
||||
@@ -807,8 +909,8 @@ const ExcalidrawWrapper = () => {
|
||||
})}
|
||||
>
|
||||
<Excalidraw
|
||||
excalidrawAPI={excalidrawRefCallback}
|
||||
onChange={onChange}
|
||||
onExport={onExport}
|
||||
initialData={initialStatePromiseRef.current.promise}
|
||||
isCollaborating={isCollaborating}
|
||||
onPointerUpdate={collabAPI?.onPointerUpdate}
|
||||
@@ -1174,7 +1276,9 @@ const ExcalidrawApp = () => {
|
||||
return (
|
||||
<TopErrorBoundary>
|
||||
<Provider store={appJotaiStore}>
|
||||
<ExcalidrawWrapper />
|
||||
<ExcalidrawAPIProvider>
|
||||
<ExcalidrawWrapper />
|
||||
</ExcalidrawAPIProvider>
|
||||
</Provider>
|
||||
</TopErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -46,6 +46,7 @@ export const STORAGE_KEYS = {
|
||||
VERSION_FILES: "version-files",
|
||||
|
||||
IDB_LIBRARY: "excalidraw-library",
|
||||
IDB_TTD_CHATS: "excalidraw-ttd-chats",
|
||||
|
||||
// do not use apart from migrations
|
||||
__LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library",
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
reconcileElements,
|
||||
} from "@excalidraw/excalidraw";
|
||||
import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
|
||||
import { APP_NAME, EVENT } from "@excalidraw/common";
|
||||
import { APP_NAME, cloneJSON, EVENT, toBrandedType } from "@excalidraw/common";
|
||||
import {
|
||||
IDLE_THRESHOLD,
|
||||
ACTIVE_THRESHOLD,
|
||||
@@ -29,6 +29,8 @@ import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
|
||||
import throttle from "lodash.throttle";
|
||||
import { PureComponent } from "react";
|
||||
|
||||
import { bumpElementVersions } from "@excalidraw/excalidraw/data/restore";
|
||||
|
||||
import type {
|
||||
ReconciledExcalidrawElement,
|
||||
RemoteExcalidrawElement,
|
||||
@@ -70,6 +72,7 @@ import {
|
||||
FileManager,
|
||||
updateStaleImageStatuses,
|
||||
} from "../data/FileManager";
|
||||
import { FileStatusStore } from "../data/fileStatusStore";
|
||||
import { LocalData } from "../data/LocalData";
|
||||
import {
|
||||
isSavedToFirebase,
|
||||
@@ -147,6 +150,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
};
|
||||
this.portal = new Portal(this);
|
||||
this.fileManager = new FileManager({
|
||||
onFileStatusChange: FileStatusStore.updateStatuses.bind(FileStatusStore),
|
||||
getFiles: async (fileIds) => {
|
||||
const { roomId, roomKey } = this.portal;
|
||||
if (!roomId || !roomKey) {
|
||||
@@ -311,6 +315,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
saveCollabRoomToFirebase = async (
|
||||
syncableElements: readonly SyncableExcalidrawElement[],
|
||||
) => {
|
||||
syncableElements = cloneJSON(syncableElements);
|
||||
try {
|
||||
const storedElements = await saveToFirebase(
|
||||
this.portal,
|
||||
@@ -579,7 +584,9 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
case WS_SUBTYPES.INIT: {
|
||||
if (!this.portal.socketInitialized) {
|
||||
this.initializeRoom({ fetchScene: false });
|
||||
const remoteElements = decryptedData.payload.elements;
|
||||
const remoteElements = toBrandedType<
|
||||
readonly RemoteExcalidrawElement[]
|
||||
>(decryptedData.payload.elements);
|
||||
const reconciledElements =
|
||||
this._reconcileElements(remoteElements);
|
||||
this.handleRemoteSceneUpdate(reconciledElements);
|
||||
@@ -593,7 +600,11 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
}
|
||||
case WS_SUBTYPES.UPDATE:
|
||||
this.handleRemoteSceneUpdate(
|
||||
this._reconcileElements(decryptedData.payload.elements),
|
||||
this._reconcileElements(
|
||||
toBrandedType<readonly RemoteExcalidrawElement[]>(
|
||||
decryptedData.payload.elements,
|
||||
),
|
||||
),
|
||||
);
|
||||
break;
|
||||
case WS_SUBTYPES.MOUSE_LOCATION: {
|
||||
@@ -742,20 +753,28 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
};
|
||||
|
||||
private _reconcileElements = (
|
||||
remoteElements: readonly ExcalidrawElement[],
|
||||
remoteElements: readonly RemoteExcalidrawElement[],
|
||||
): ReconciledExcalidrawElement[] => {
|
||||
const localElements = this.getSceneElementsIncludingDeleted();
|
||||
const appState = this.excalidrawAPI.getAppState();
|
||||
const restoredRemoteElements = restoreElements(
|
||||
|
||||
const existingElements = this.getSceneElementsIncludingDeleted();
|
||||
|
||||
// NOTE ideally we restore _after_ reconciliation but we can't do that
|
||||
// as we'd regenerate even elements such as appState.newElement which would
|
||||
// break the state
|
||||
remoteElements = restoreElements(remoteElements, existingElements);
|
||||
|
||||
let reconciledElements = reconcileElements(
|
||||
existingElements,
|
||||
remoteElements,
|
||||
this.excalidrawAPI.getSceneElementsMapIncludingDeleted(),
|
||||
);
|
||||
const reconciledElements = reconcileElements(
|
||||
localElements,
|
||||
restoredRemoteElements as RemoteExcalidrawElement[],
|
||||
appState,
|
||||
);
|
||||
|
||||
reconciledElements = bumpElementVersions(
|
||||
reconciledElements,
|
||||
existingElements,
|
||||
);
|
||||
|
||||
// Avoid broadcasting to the rest of the collaborators the scene
|
||||
// we just received!
|
||||
// Note: this needs to be set before updating the scene as it
|
||||
|
||||
@@ -4,12 +4,15 @@ import {
|
||||
getTextFromElements,
|
||||
MIME_TYPES,
|
||||
TTDDialog,
|
||||
TTDStreamFetch,
|
||||
} from "@excalidraw/excalidraw";
|
||||
import { getDataURL } from "@excalidraw/excalidraw/data/blob";
|
||||
import { safelyParseJSON } from "@excalidraw/common";
|
||||
|
||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { TTDIndexedDBAdapter } from "../data/TTDStorage";
|
||||
|
||||
export const AIComponents = ({
|
||||
excalidrawAPI,
|
||||
}: {
|
||||
@@ -99,61 +102,23 @@ export const AIComponents = ({
|
||||
/>
|
||||
|
||||
<TTDDialog
|
||||
onTextSubmit={async (input) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${
|
||||
import.meta.env.VITE_APP_AI_BACKEND
|
||||
}/v1/ai/text-to-diagram/generate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ prompt: input }),
|
||||
},
|
||||
);
|
||||
onTextSubmit={async (props) => {
|
||||
const { onChunk, onStreamCreated, signal, messages } = props;
|
||||
|
||||
const rateLimit = response.headers.has("X-Ratelimit-Limit")
|
||||
? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
|
||||
: undefined;
|
||||
const result = await TTDStreamFetch({
|
||||
url: `${
|
||||
import.meta.env.VITE_APP_AI_BACKEND
|
||||
}/v1/ai/text-to-diagram/chat-streaming`,
|
||||
messages,
|
||||
onChunk,
|
||||
onStreamCreated,
|
||||
extractRateLimits: true,
|
||||
signal,
|
||||
});
|
||||
|
||||
const rateLimitRemaining = response.headers.has(
|
||||
"X-Ratelimit-Remaining",
|
||||
)
|
||||
? parseInt(
|
||||
response.headers.get("X-Ratelimit-Remaining") || "0",
|
||||
10,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const json = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
rateLimit,
|
||||
rateLimitRemaining,
|
||||
error: new Error(
|
||||
"Too many requests today, please try again tomorrow!",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(json.message || "Generation failed...");
|
||||
}
|
||||
|
||||
const generatedResponse = json.generatedResponse;
|
||||
if (!generatedResponse) {
|
||||
throw new Error("Generation failed...");
|
||||
}
|
||||
|
||||
return { generatedResponse, rateLimit, rateLimitRemaining };
|
||||
} catch (err: any) {
|
||||
throw new Error("Request failed");
|
||||
}
|
||||
return result;
|
||||
}}
|
||||
persistenceAdapter={TTDIndexedDBAdapter}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -62,7 +62,7 @@ export const AppMainMenu: React.FC<{
|
||||
{isDevEnv() && (
|
||||
<MainMenu.Item
|
||||
icon={eyeIcon}
|
||||
onClick={() => {
|
||||
onSelect={() => {
|
||||
if (window.visualDebug) {
|
||||
delete window.visualDebug;
|
||||
saveDebugState({ enabled: false });
|
||||
@@ -77,6 +77,7 @@ export const AppMainMenu: React.FC<{
|
||||
</MainMenu.Item>
|
||||
)}
|
||||
<MainMenu.Separator />
|
||||
<MainMenu.DefaultItems.Preferences />
|
||||
<MainMenu.DefaultItems.ToggleTheme
|
||||
allowSystemTheme
|
||||
theme={props.theme}
|
||||
|
||||
@@ -33,7 +33,15 @@ export const AppWelcomeScreen: React.FC<{
|
||||
return bit;
|
||||
});
|
||||
} else {
|
||||
headingContent = t("welcomeScreen.app.center_heading");
|
||||
headingContent = (
|
||||
<>
|
||||
{t("welcomeScreen.app.center_heading")}
|
||||
<br />
|
||||
{t("welcomeScreen.app.center_heading_line2")}
|
||||
<br />
|
||||
{t("welcomeScreen.app.center_heading_line3")}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -27,7 +27,10 @@ import { isCurve } from "@excalidraw/math/curve";
|
||||
import React from "react";
|
||||
|
||||
import type { Curve } from "@excalidraw/math";
|
||||
import type { DebugElement } from "@excalidraw/common";
|
||||
import type {
|
||||
DebugElement,
|
||||
DebugPolygon,
|
||||
} from "@excalidraw/element/visualdebug";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
@@ -75,6 +78,44 @@ const renderCubicBezier = (
|
||||
context.restore();
|
||||
};
|
||||
|
||||
const renderPolygon = (
|
||||
context: CanvasRenderingContext2D,
|
||||
zoom: number,
|
||||
polygon: DebugPolygon,
|
||||
color: string,
|
||||
) => {
|
||||
const { points, fill, close } = polygon;
|
||||
|
||||
if (points.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.save();
|
||||
context.beginPath();
|
||||
context.moveTo(points[0][0] * zoom, points[0][1] * zoom);
|
||||
for (let i = 1; i < points.length; i += 1) {
|
||||
context.lineTo(points[i][0] * zoom, points[i][1] * zoom);
|
||||
}
|
||||
if (close !== false) {
|
||||
context.closePath();
|
||||
}
|
||||
|
||||
if (fill) {
|
||||
context.save();
|
||||
context.globalAlpha = 0.15;
|
||||
context.fillStyle = color;
|
||||
context.fill();
|
||||
context.restore();
|
||||
}
|
||||
|
||||
context.strokeStyle = color;
|
||||
context.stroke();
|
||||
context.restore();
|
||||
};
|
||||
|
||||
const isDebugPolygon = (data: DebugElement["data"]): data is DebugPolygon =>
|
||||
(data as DebugPolygon).type === "polygon";
|
||||
|
||||
const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
|
||||
context.strokeStyle = "#888";
|
||||
context.save();
|
||||
@@ -280,6 +321,9 @@ const render = (
|
||||
el.color,
|
||||
);
|
||||
break;
|
||||
case isDebugPolygon(el.data):
|
||||
renderPolygon(context, appState.zoom.value, el.data, el.color);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown element type ${JSON.stringify(el)}`);
|
||||
}
|
||||
@@ -370,7 +414,6 @@ export const debugRenderer = throttleRAF(
|
||||
) => {
|
||||
_debugRenderer(canvas, appState, elements, scale);
|
||||
},
|
||||
{ trailing: true },
|
||||
);
|
||||
|
||||
export const loadSavedDebugState = () => {
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { THEME } from "@excalidraw/common";
|
||||
import oc from "open-color";
|
||||
import React from "react";
|
||||
|
||||
import type { Theme } from "@excalidraw/element/types";
|
||||
|
||||
// https://github.com/tholman/github-corners
|
||||
export const GitHubCorner = React.memo(
|
||||
({ theme, dir }: { theme: Theme; dir: string }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 250 250"
|
||||
className="rtl-mirror"
|
||||
style={{
|
||||
marginTop: "calc(var(--space-factor) * -1)",
|
||||
[dir === "rtl" ? "marginLeft" : "marginRight"]:
|
||||
"calc(var(--space-factor) * -1)",
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href="https://github.com/excalidraw/excalidraw"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="GitHub repository"
|
||||
>
|
||||
<path
|
||||
d="M0 0l115 115h15l12 27 108 108V0z"
|
||||
fill={theme === THEME.LIGHT ? oc.gray[6] : oc.gray[7]}
|
||||
/>
|
||||
<path
|
||||
className="octo-arm"
|
||||
d="M128 109c-15-9-9-19-9-19 3-7 2-11 2-11-1-7 3-2 3-2 4 5 2 11 2 11-3 10 5 15 9 16"
|
||||
style={{ transformOrigin: "130px 106px" }}
|
||||
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
|
||||
/>
|
||||
<path
|
||||
className="octo-body"
|
||||
d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z"
|
||||
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
|
||||
/>
|
||||
</a>
|
||||
</svg>
|
||||
),
|
||||
);
|
||||
@@ -40,10 +40,12 @@ export class FileManager {
|
||||
|
||||
private _getFiles;
|
||||
private _saveFiles;
|
||||
private _onFileStatusChange;
|
||||
|
||||
constructor({
|
||||
getFiles,
|
||||
saveFiles,
|
||||
onFileStatusChange,
|
||||
}: {
|
||||
getFiles: (fileIds: FileId[]) => Promise<{
|
||||
loadedFiles: BinaryFileData[];
|
||||
@@ -53,9 +55,13 @@ export class FileManager {
|
||||
savedFiles: Map<FileId, BinaryFileData>;
|
||||
erroredFiles: Map<FileId, BinaryFileData>;
|
||||
}>;
|
||||
onFileStatusChange?: (
|
||||
updates: Array<[FileId, "loading" | "loaded" | "error"]>,
|
||||
) => void;
|
||||
}) {
|
||||
this._getFiles = getFiles;
|
||||
this._saveFiles = saveFiles;
|
||||
this._onFileStatusChange = onFileStatusChange;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,6 +152,8 @@ export class FileManager {
|
||||
this.fetchingFiles.set(id, true);
|
||||
}
|
||||
|
||||
this._onFileStatusChange?.(ids.map((id) => [id, "loading"]));
|
||||
|
||||
try {
|
||||
const { loadedFiles, erroredFiles } = await this._getFiles(ids);
|
||||
|
||||
@@ -156,6 +164,13 @@ export class FileManager {
|
||||
this.erroredFiles_fetch.set(fileId, true);
|
||||
}
|
||||
|
||||
this._onFileStatusChange?.([
|
||||
...loadedFiles.map((f) => [f.id, "loaded"] as [FileId, "loaded"]),
|
||||
...[...erroredFiles.keys()].map(
|
||||
(id) => [id, "error"] as [FileId, "error"],
|
||||
),
|
||||
]);
|
||||
|
||||
return { loadedFiles, erroredFiles };
|
||||
} finally {
|
||||
for (const id of ids) {
|
||||
@@ -195,6 +210,13 @@ export class FileManager {
|
||||
};
|
||||
|
||||
reset() {
|
||||
if (this._onFileStatusChange && this.fetchingFiles.size) {
|
||||
this._onFileStatusChange(
|
||||
[...this.fetchingFiles.keys()].map(
|
||||
(id) => [id, "error"] as [FileId, "error"],
|
||||
),
|
||||
);
|
||||
}
|
||||
this.fetchingFiles.clear();
|
||||
this.savingFiles.clear();
|
||||
this.savedFiles.clear();
|
||||
|
||||
@@ -42,6 +42,7 @@ import type { MaybePromise } from "@excalidraw/common/utility-types";
|
||||
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
|
||||
|
||||
import { FileManager } from "./FileManager";
|
||||
import { FileStatusStore } from "./fileStatusStore";
|
||||
import { Locker } from "./Locker";
|
||||
import { updateBrowserStateVersion } from "./tabSync";
|
||||
|
||||
@@ -166,6 +167,7 @@ export class LocalData {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static fileStorage = new LocalFileManager({
|
||||
onFileStatusChange: FileStatusStore.updateStatuses.bind(FileStatusStore),
|
||||
getFiles(ids) {
|
||||
return getMany(ids, filesStore).then(
|
||||
async (filesData: (BinaryFileData | undefined)[]) => {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createStore, get, set } from "idb-keyval";
|
||||
|
||||
import type { SavedChats } from "@excalidraw/excalidraw/components/TTDDialog/types";
|
||||
|
||||
import { STORAGE_KEYS } from "../app_constants";
|
||||
|
||||
/**
|
||||
* IndexedDB adapter for TTD chat storage.
|
||||
* Implements TTDPersistenceAdapter interface.
|
||||
*/
|
||||
export class TTDIndexedDBAdapter {
|
||||
/** IndexedDB database name */
|
||||
private static idb_name = STORAGE_KEYS.IDB_TTD_CHATS;
|
||||
/** Store key for chat data */
|
||||
private static key = "ttdChats";
|
||||
|
||||
private static store = createStore(
|
||||
`${TTDIndexedDBAdapter.idb_name}-db`,
|
||||
`${TTDIndexedDBAdapter.idb_name}-store`,
|
||||
);
|
||||
|
||||
/**
|
||||
* Load saved chats from IndexedDB.
|
||||
* @returns Promise resolving to saved chats array (empty if none found)
|
||||
*/
|
||||
static async loadChats(): Promise<SavedChats> {
|
||||
try {
|
||||
const data = await get<SavedChats>(
|
||||
TTDIndexedDBAdapter.key,
|
||||
TTDIndexedDBAdapter.store,
|
||||
);
|
||||
return data || [];
|
||||
} catch (error) {
|
||||
console.warn("Failed to load TTD chats from IndexedDB:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save chats to IndexedDB.
|
||||
* @param chats - The chats array to persist
|
||||
*/
|
||||
static async saveChats(chats: SavedChats): Promise<void> {
|
||||
try {
|
||||
await set(TTDIndexedDBAdapter.key, chats, TTDIndexedDBAdapter.store);
|
||||
} catch (error) {
|
||||
console.warn("Failed to save TTD chats to IndexedDB:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { VersionedSnapshotStore } from "@excalidraw/common";
|
||||
|
||||
import type { FileId } from "@excalidraw/element/types";
|
||||
|
||||
export type FileLoadingStatus = "loading" | "loaded" | "error";
|
||||
|
||||
export class FileStatusStore {
|
||||
private static store = new VersionedSnapshotStore<
|
||||
Map<FileId, FileLoadingStatus>
|
||||
>(new Map());
|
||||
|
||||
static getSnapshot() {
|
||||
return this.store.getSnapshot();
|
||||
}
|
||||
|
||||
static pull(sinceVersion?: number) {
|
||||
return this.store.pull(sinceVersion);
|
||||
}
|
||||
|
||||
static updateStatuses(updates: Array<[FileId, FileLoadingStatus]>) {
|
||||
if (!updates.length) {
|
||||
return;
|
||||
}
|
||||
this.store.update((prev) => {
|
||||
let changed = false;
|
||||
const next = new Map(prev);
|
||||
for (const [id, status] of updates) {
|
||||
if (next.get(id) !== status) {
|
||||
next.set(id, status);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}
|
||||
|
||||
static getPendingCount(statuses: Map<FileId, FileLoadingStatus>) {
|
||||
let pending = 0;
|
||||
let total = 0;
|
||||
for (const status of statuses.values()) {
|
||||
total++;
|
||||
if (status === "loading") {
|
||||
pending++;
|
||||
}
|
||||
}
|
||||
return { pending, total };
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { reconcileElements } from "@excalidraw/excalidraw";
|
||||
import { MIME_TYPES } from "@excalidraw/common";
|
||||
import { MIME_TYPES, toBrandedType } from "@excalidraw/common";
|
||||
import { decompressData } from "@excalidraw/excalidraw/data/encode";
|
||||
import {
|
||||
encryptData,
|
||||
@@ -243,7 +243,7 @@ export const saveToFirebase = async (
|
||||
|
||||
FirebaseSceneVersionCache.set(socket, storedElements);
|
||||
|
||||
return storedElements;
|
||||
return toBrandedType<RemoteExcalidrawElement[]>(storedElements);
|
||||
};
|
||||
|
||||
export const loadFromFirebase = async (
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
IV_LENGTH_BYTES,
|
||||
} from "@excalidraw/excalidraw/data/encryption";
|
||||
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
|
||||
import { restore } from "@excalidraw/excalidraw/data/restore";
|
||||
import { isInvisiblySmallElement } from "@excalidraw/element";
|
||||
import { isInitializedImageElement } from "@excalidraw/element";
|
||||
import { t } from "@excalidraw/excalidraw/i18n";
|
||||
@@ -84,13 +83,13 @@ export type SocketUpdateDataSource = {
|
||||
SCENE_INIT: {
|
||||
type: WS_SUBTYPES.INIT;
|
||||
payload: {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
elements: readonly OrderedExcalidrawElement[];
|
||||
};
|
||||
};
|
||||
SCENE_UPDATE: {
|
||||
type: WS_SUBTYPES.UPDATE;
|
||||
payload: {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
elements: readonly OrderedExcalidrawElement[];
|
||||
};
|
||||
};
|
||||
MOUSE_LOCATION: {
|
||||
@@ -200,7 +199,7 @@ const legacy_decodeFromBackend = async ({
|
||||
};
|
||||
};
|
||||
|
||||
const importFromBackend = async (
|
||||
export const importFromBackend = async (
|
||||
id: string,
|
||||
decryptionKey: string,
|
||||
): Promise<ImportedDataState> => {
|
||||
@@ -242,45 +241,6 @@ const importFromBackend = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const loadScene = async (
|
||||
id: string | null,
|
||||
privateKey: string | null,
|
||||
// Supply local state even if importing from backend to ensure we restore
|
||||
// localStorage user settings which we do not persist on server.
|
||||
// Non-optional so we don't forget to pass it even if `undefined`.
|
||||
localDataState: ImportedDataState | undefined | null,
|
||||
) => {
|
||||
let data;
|
||||
if (id != null && privateKey != null) {
|
||||
// the private key is used to decrypt the content from the server, take
|
||||
// extra care not to leak it
|
||||
data = restore(
|
||||
await importFromBackend(id, privateKey),
|
||||
localDataState?.appState,
|
||||
localDataState?.elements,
|
||||
{
|
||||
repairBindings: true,
|
||||
refreshDimensions: false,
|
||||
deleteInvisibleElements: true,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
data = restore(localDataState || null, null, null, {
|
||||
repairBindings: true,
|
||||
deleteInvisibleElements: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
elements: data.elements,
|
||||
appState: data.appState,
|
||||
// note: this will always be empty because we're not storing files
|
||||
// in the scene database/localStorage, and instead fetch them async
|
||||
// from a different database
|
||||
files: data.files,
|
||||
};
|
||||
};
|
||||
|
||||
type ExportToBackendResult =
|
||||
| { url: null; errorMessage: string }
|
||||
| { url: string; errorMessage: null };
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"socket.io-client": "4.7.2",
|
||||
"uqr": "0.1.2",
|
||||
"vite-plugin-html": "3.2.2"
|
||||
},
|
||||
"prettier": "@excalidraw/prettier-config",
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Spinner from "@excalidraw/excalidraw/components/Spinner";
|
||||
|
||||
interface QRCodeProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const QRCode = ({ value }: QRCodeProps) => {
|
||||
const [svgData, setSvgData] = useState<string | null>(null);
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
import("./qrcode.chunk")
|
||||
.then(({ generateQRCodeSVG }) => {
|
||||
if (mounted) {
|
||||
try {
|
||||
setSvgData(generateQRCodeSVG(value));
|
||||
} catch {
|
||||
setError(true);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (mounted) {
|
||||
setError(true);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [value]);
|
||||
|
||||
if (error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!svgData) {
|
||||
return (
|
||||
<div className="ShareDialog__active__qrcode ShareDialog__active__qrcode--loading">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="ShareDialog__active__qrcode"
|
||||
role="img"
|
||||
aria-label="QR code for collaboration link"
|
||||
dangerouslySetInnerHTML={{ __html: svgData }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -140,6 +140,31 @@
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
&__qrcode {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
padding: 1rem;
|
||||
background: #fff;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #e0e0e0;
|
||||
|
||||
$size: 150px;
|
||||
width: $size;
|
||||
height: $size;
|
||||
|
||||
& svg {
|
||||
width: $size;
|
||||
height: $size;
|
||||
}
|
||||
|
||||
&--loading {
|
||||
background: var(--island-bg-color);
|
||||
border: 1px solid var(--dialog-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
&__description {
|
||||
border-top: 1px solid var(--color-gray-20);
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { atom, useAtom, useAtomValue } from "../app-jotai";
|
||||
import { activeRoomLinkAtom } from "../collab/Collab";
|
||||
|
||||
import "./ShareDialog.scss";
|
||||
import { QRCode } from "./QRCode";
|
||||
|
||||
import type { CollabAPI } from "../collab/Collab";
|
||||
|
||||
@@ -142,6 +143,7 @@ const ActiveRoomDialog = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<QRCode value={activeRoomLink} />
|
||||
<div className="ShareDialog__active__description">
|
||||
<p>
|
||||
<span
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { renderSVG } from "uqr";
|
||||
|
||||
export const generateQRCodeSVG = (text: string): string => {
|
||||
return renderSVG(text);
|
||||
};
|
||||
@@ -50,7 +50,11 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
|
||||
<div
|
||||
class="welcome-screen-center__heading welcome-screen-decor excalifont"
|
||||
>
|
||||
All your data is saved locally in your browser.
|
||||
Your drawings are saved in your browser's storage.
|
||||
<br />
|
||||
Browser storage can be cleared unexpectedly.
|
||||
<br />
|
||||
Save your work to a file regularly to avoid losing it.
|
||||
</div>
|
||||
<div
|
||||
class="welcome-screen-menu"
|
||||
|
||||
@@ -102,6 +102,14 @@ export default defineConfig(({ mode }) => {
|
||||
// Taking the substring after "locales/"
|
||||
return `locales/${id.substring(index + 8)}`;
|
||||
}
|
||||
|
||||
if (id.includes("@excalidraw/mermaid-to-excalidraw")) {
|
||||
return "mermaid-to-excalidraw";
|
||||
}
|
||||
|
||||
if (id.includes("@codemirror/") || id.includes("@lezer/")) {
|
||||
return "codemirror.chunk";
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -146,6 +154,11 @@ export default defineConfig(({ mode }) => {
|
||||
"**/locales/**",
|
||||
"service-worker.js",
|
||||
"**/*.chunk-*.js",
|
||||
// CodeMirrorEditor can't be assigned a `.chunk` name via
|
||||
// manualChunks because Rollup would hoist shared deps (React)
|
||||
// via a static import from the main bundle, defeating lazy
|
||||
// loading. So we exclude it by name instead.
|
||||
"**/CodeMirrorEditor-*.js",
|
||||
],
|
||||
runtimeCaching: [
|
||||
{
|
||||
@@ -185,7 +198,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: new RegExp(".chunk-.+.js"),
|
||||
urlPattern: new RegExp("(.chunk-.+|CodeMirrorEditor-.+)\\.js"),
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "chunk",
|
||||
@@ -196,6 +209,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
},
|
||||
],
|
||||
maximumFileSizeToCacheInBytes: 2.3 * 1024 ** 2, // 2.3MB
|
||||
},
|
||||
manifest: {
|
||||
short_name: "Excalidraw",
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
debug: typeof Debug;
|
||||
}
|
||||
}
|
||||
|
||||
const lessPrecise = (num: number, precision = 5) =>
|
||||
parseFloat(num.toPrecision(precision));
|
||||
|
||||
const getAvgFrameTime = (times: number[]) =>
|
||||
lessPrecise(times.reduce((a, b) => a + b) / times.length);
|
||||
|
||||
const getFps = (frametime: number) => lessPrecise(1000 / frametime);
|
||||
|
||||
export class Debug {
|
||||
public static DEBUG_LOG_TIMES = true;
|
||||
|
||||
@@ -24,34 +16,35 @@ export class Debug {
|
||||
private static LAST_DEBUG_LOG_CALL = 0;
|
||||
private static DEBUG_LOG_INTERVAL_ID: null | number = null;
|
||||
|
||||
private static LAST_FRAME_TIMESTAMP = 0;
|
||||
private static FRAME_COUNT = 0;
|
||||
private static ANIMATION_FRAME_ID: null | number = null;
|
||||
|
||||
private static scheduleAnimationFrame = () => {
|
||||
if (Debug.DEBUG_LOG_INTERVAL_ID !== null) {
|
||||
Debug.ANIMATION_FRAME_ID = requestAnimationFrame((timestamp) => {
|
||||
if (Debug.LAST_FRAME_TIMESTAMP !== timestamp) {
|
||||
Debug.LAST_FRAME_TIMESTAMP = timestamp;
|
||||
Debug.FRAME_COUNT++;
|
||||
}
|
||||
|
||||
if (Debug.DEBUG_LOG_INTERVAL_ID !== null) {
|
||||
Debug.scheduleAnimationFrame();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private static setupInterval = () => {
|
||||
if (Debug.DEBUG_LOG_INTERVAL_ID === null) {
|
||||
console.info("%c(starting perf recording)", "color: lime");
|
||||
Debug.DEBUG_LOG_INTERVAL_ID = window.setInterval(Debug.debugLogger, 1000);
|
||||
Debug.scheduleAnimationFrame();
|
||||
}
|
||||
Debug.LAST_DEBUG_LOG_CALL = Date.now();
|
||||
};
|
||||
|
||||
private static debugLogger = () => {
|
||||
if (
|
||||
Date.now() - Debug.LAST_DEBUG_LOG_CALL > 600 &&
|
||||
Debug.DEBUG_LOG_INTERVAL_ID !== null
|
||||
) {
|
||||
window.clearInterval(Debug.DEBUG_LOG_INTERVAL_ID);
|
||||
Debug.DEBUG_LOG_INTERVAL_ID = null;
|
||||
for (const [name, { avg }] of Object.entries(Debug.TIMES_AVG)) {
|
||||
if (avg != null) {
|
||||
console.info(
|
||||
`%c${name} run avg: ${avg}ms (${getFps(avg)} fps)`,
|
||||
"color: blue",
|
||||
);
|
||||
}
|
||||
}
|
||||
console.info("%c(stopping perf recording)", "color: red");
|
||||
Debug.TIMES_AGGR = {};
|
||||
Debug.TIMES_AVG = {};
|
||||
return;
|
||||
}
|
||||
if (Debug.DEBUG_LOG_TIMES) {
|
||||
for (const [name, { t, times }] of Object.entries(Debug.TIMES_AGGR)) {
|
||||
if (times.length) {
|
||||
@@ -65,8 +58,18 @@ export class Debug {
|
||||
}
|
||||
for (const [name, { t, times, avg }] of Object.entries(Debug.TIMES_AVG)) {
|
||||
if (times.length) {
|
||||
const avgFrameTime = getAvgFrameTime(times);
|
||||
console.info(name, `${avgFrameTime}ms (${getFps(avgFrameTime)} fps)`);
|
||||
// const avgFrameTime = getAvgFrameTime(times);
|
||||
const totalTime = times.reduce((a, b) => a + b);
|
||||
const avgFrameTime = lessPrecise(totalTime / Debug.FRAME_COUNT);
|
||||
console.info(
|
||||
name,
|
||||
`- ${times.length} calls - ${avgFrameTime}ms/frame across ${
|
||||
Debug.FRAME_COUNT
|
||||
} frames (${lessPrecise(
|
||||
(avgFrameTime / 16.67) * 100,
|
||||
1,
|
||||
)}% of frame budget)`,
|
||||
);
|
||||
Debug.TIMES_AVG[name] = {
|
||||
t,
|
||||
times: [],
|
||||
@@ -76,6 +79,24 @@ export class Debug {
|
||||
}
|
||||
}
|
||||
}
|
||||
Debug.FRAME_COUNT = 0;
|
||||
|
||||
// Check for stop condition after logging
|
||||
if (
|
||||
Date.now() - Debug.LAST_DEBUG_LOG_CALL > 600 &&
|
||||
Debug.DEBUG_LOG_INTERVAL_ID !== null
|
||||
) {
|
||||
console.info("%c(stopping perf recording)", "color: red");
|
||||
window.clearInterval(Debug.DEBUG_LOG_INTERVAL_ID);
|
||||
window.cancelAnimationFrame(Debug.ANIMATION_FRAME_ID!);
|
||||
Debug.ANIMATION_FRAME_ID = null;
|
||||
Debug.FRAME_COUNT = 0;
|
||||
Debug.LAST_FRAME_TIMESTAMP = 0;
|
||||
|
||||
Debug.DEBUG_LOG_INTERVAL_ID = null;
|
||||
Debug.TIMES_AGGR = {};
|
||||
Debug.TIMES_AVG = {};
|
||||
}
|
||||
};
|
||||
|
||||
public static logTime = (time?: number, name = "default") => {
|
||||
@@ -109,7 +130,7 @@ export class Debug {
|
||||
return (...args: T) => {
|
||||
const t0 = performance.now();
|
||||
const ret = fn(...args);
|
||||
Debug.logTime(performance.now() - t0, name);
|
||||
Debug[type](performance.now() - t0, name);
|
||||
return ret;
|
||||
};
|
||||
};
|
||||
@@ -130,6 +151,70 @@ export class Debug {
|
||||
return ret;
|
||||
};
|
||||
};
|
||||
|
||||
private static CHANGED_CACHE: Record<string, Record<string, unknown>> = {};
|
||||
|
||||
public static logChanged(name: string, obj: Record<string, unknown>) {
|
||||
const prev = Debug.CHANGED_CACHE[name];
|
||||
|
||||
Debug.CHANGED_CACHE[name] = obj;
|
||||
|
||||
if (!prev) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allKeys = new Set([...Object.keys(prev), ...Object.keys(obj)]);
|
||||
const changed: Record<string, { prev: unknown; next: unknown }> = {};
|
||||
|
||||
for (const key of allKeys) {
|
||||
const prevVal = prev[key];
|
||||
const nextVal = obj[key];
|
||||
if (!deepEqual(prevVal, nextVal)) {
|
||||
changed[key] = { prev: prevVal, next: nextVal };
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(changed).length > 0) {
|
||||
console.info(`[${name}] changed:`, changed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deepEqual(a: unknown, b: unknown): boolean {
|
||||
if (Object.is(a, b)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
a === null ||
|
||||
b === null ||
|
||||
typeof a !== "object" ||
|
||||
typeof b !== "object"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(a) !== Array.isArray(b)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const keysA = Object.keys(a as Record<string, unknown>);
|
||||
const keysB = Object.keys(b as Record<string, unknown>);
|
||||
|
||||
if (keysA.length !== keysB.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const key of keysA) {
|
||||
if (
|
||||
!deepEqual(
|
||||
(a as Record<string, unknown>)[key],
|
||||
(b as Record<string, unknown>)[key],
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
//@ts-ignore
|
||||
window.debug = Debug;
|
||||
@@ -55,5 +55,11 @@
|
||||
"scripts": {
|
||||
"gen:types": "rimraf types && tsc",
|
||||
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||
},
|
||||
"dependencies": {
|
||||
"tinycolor2": "1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/tinycolor2": "1.4.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`COLOR_PALETTE > color palette doesn't regress 1`] = `
|
||||
{
|
||||
"black": "#1e1e1e",
|
||||
"blue": [
|
||||
"#e7f5ff",
|
||||
"#a5d8ff",
|
||||
"#4dabf7",
|
||||
"#228be6",
|
||||
"#1971c2",
|
||||
],
|
||||
"bronze": [
|
||||
"#f8f1ee",
|
||||
"#eaddd7",
|
||||
"#d2bab0",
|
||||
"#a18072",
|
||||
"#846358",
|
||||
],
|
||||
"cyan": [
|
||||
"#e3fafc",
|
||||
"#99e9f2",
|
||||
"#3bc9db",
|
||||
"#15aabf",
|
||||
"#0c8599",
|
||||
],
|
||||
"grape": [
|
||||
"#f8f0fc",
|
||||
"#eebefa",
|
||||
"#da77f2",
|
||||
"#be4bdb",
|
||||
"#9c36b5",
|
||||
],
|
||||
"gray": [
|
||||
"#f8f9fa",
|
||||
"#e9ecef",
|
||||
"#ced4da",
|
||||
"#868e96",
|
||||
"#343a40",
|
||||
],
|
||||
"green": [
|
||||
"#ebfbee",
|
||||
"#b2f2bb",
|
||||
"#69db7c",
|
||||
"#40c057",
|
||||
"#2f9e44",
|
||||
],
|
||||
"orange": [
|
||||
"#fff4e6",
|
||||
"#ffd8a8",
|
||||
"#ffa94d",
|
||||
"#fd7e14",
|
||||
"#e8590c",
|
||||
],
|
||||
"pink": [
|
||||
"#fff0f6",
|
||||
"#fcc2d7",
|
||||
"#f783ac",
|
||||
"#e64980",
|
||||
"#c2255c",
|
||||
],
|
||||
"red": [
|
||||
"#fff5f5",
|
||||
"#ffc9c9",
|
||||
"#ff8787",
|
||||
"#fa5252",
|
||||
"#e03131",
|
||||
],
|
||||
"teal": [
|
||||
"#e6fcf5",
|
||||
"#96f2d7",
|
||||
"#38d9a9",
|
||||
"#12b886",
|
||||
"#099268",
|
||||
],
|
||||
"transparent": "transparent",
|
||||
"violet": [
|
||||
"#f3f0ff",
|
||||
"#d0bfff",
|
||||
"#9775fa",
|
||||
"#7950f2",
|
||||
"#6741d9",
|
||||
],
|
||||
"white": "#ffffff",
|
||||
"yellow": [
|
||||
"#fff9db",
|
||||
"#ffec99",
|
||||
"#ffd43b",
|
||||
"#fab005",
|
||||
"#f08c00",
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`applyDarkModeFilter > COLOR_PALETTE regression tests > matches snapshot for all palette colors 1`] = `
|
||||
{
|
||||
"black": "#d3d3d3",
|
||||
"blue": [
|
||||
"#121e26",
|
||||
"#154162",
|
||||
"#2273b4",
|
||||
"#3791e0",
|
||||
"#56a2e8",
|
||||
],
|
||||
"bronze": [
|
||||
"#221c1a",
|
||||
"#362b26",
|
||||
"#5a463d",
|
||||
"#917569",
|
||||
"#a98d84",
|
||||
],
|
||||
"cyan": [
|
||||
"#0a1e20",
|
||||
"#004149",
|
||||
"#007281",
|
||||
"#0f8fa1",
|
||||
"#3da5b6",
|
||||
],
|
||||
"grape": [
|
||||
"#211a25",
|
||||
"#5b3165",
|
||||
"#a954be",
|
||||
"#d471ed",
|
||||
"#e28af8",
|
||||
],
|
||||
"gray": [
|
||||
"#161718",
|
||||
"#202325",
|
||||
"#33383d",
|
||||
"#6e757c",
|
||||
"#b7bcc1",
|
||||
],
|
||||
"green": [
|
||||
"#0f1d12",
|
||||
"#043b0c",
|
||||
"#056715",
|
||||
"#16842a",
|
||||
"#39994b",
|
||||
],
|
||||
"orange": [
|
||||
"#22190d",
|
||||
"#4c2b01",
|
||||
"#924800",
|
||||
"#cd6005",
|
||||
"#f17634",
|
||||
],
|
||||
"pink": [
|
||||
"#26191e",
|
||||
"#602e40",
|
||||
"#b04d70",
|
||||
"#f56e9d",
|
||||
"#ff8dbc",
|
||||
],
|
||||
"red": [
|
||||
"#1f1717",
|
||||
"#5a2c2c",
|
||||
"#b44d4d",
|
||||
"#fa6969",
|
||||
"#ff8383",
|
||||
],
|
||||
"teal": [
|
||||
"#0a1d17",
|
||||
"#00422b",
|
||||
"#00744b",
|
||||
"#039267",
|
||||
"#32a783",
|
||||
],
|
||||
"transparent": "#ededed00",
|
||||
"violet": [
|
||||
"#1f1c29",
|
||||
"#4a3b72",
|
||||
"#8a6cdf",
|
||||
"#a885ff",
|
||||
"#b595ff",
|
||||
],
|
||||
"white": "#121212",
|
||||
"yellow": [
|
||||
"#1e1900",
|
||||
"#362600",
|
||||
"#5f3a00",
|
||||
"#905000",
|
||||
"#b86200",
|
||||
],
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,74 @@
|
||||
import { AppEventBus } from "./appEventBus";
|
||||
|
||||
type TestEvents = {
|
||||
initialize: [api: number];
|
||||
pointerUp: [pointerId: string];
|
||||
viewState: [zoom: number];
|
||||
};
|
||||
|
||||
const behavior = {
|
||||
initialize: { cardinality: "once", replay: "last" },
|
||||
pointerUp: { cardinality: "many", replay: "none" },
|
||||
viewState: { cardinality: "many", replay: "last" },
|
||||
} as const;
|
||||
|
||||
const flushMicrotasks = async () => Promise.resolve();
|
||||
|
||||
describe("AppEventBus", () => {
|
||||
it("replays once events to late callback and Promise subscribers", async () => {
|
||||
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
|
||||
bus.emit("initialize", 42);
|
||||
|
||||
const calls: number[] = [];
|
||||
bus.on("initialize", (value) => {
|
||||
calls.push(value);
|
||||
});
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
await flushMicrotasks();
|
||||
expect(calls).toEqual([42]);
|
||||
|
||||
await expect(bus.on("initialize")).resolves.toBe(42);
|
||||
});
|
||||
|
||||
it("does not replay stream events to late subscribers", async () => {
|
||||
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
|
||||
bus.emit("pointerUp", "first");
|
||||
|
||||
const calls: string[] = [];
|
||||
bus.on("pointerUp", (pointerId) => {
|
||||
calls.push(pointerId);
|
||||
});
|
||||
|
||||
await flushMicrotasks();
|
||||
expect(calls).toEqual([]);
|
||||
|
||||
bus.emit("pointerUp", "second");
|
||||
expect(calls).toEqual(["second"]);
|
||||
});
|
||||
|
||||
it("replays replay-last stream events and stays subscribed", async () => {
|
||||
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
|
||||
bus.emit("viewState", 1);
|
||||
|
||||
const calls: number[] = [];
|
||||
bus.on("viewState", (zoom) => {
|
||||
calls.push(zoom);
|
||||
});
|
||||
|
||||
await flushMicrotasks();
|
||||
expect(calls).toEqual([1]);
|
||||
|
||||
bus.emit("viewState", 2);
|
||||
expect(calls).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("throws when emitting a once event twice", () => {
|
||||
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
|
||||
bus.emit("initialize", 1);
|
||||
|
||||
expect(() => {
|
||||
bus.emit("initialize", 2);
|
||||
}).toThrow('Event "initialize" can only be emitted once');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { UnsubscribeCallback } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { Emitter } from "./emitter";
|
||||
import { isProdEnv } from "./utils";
|
||||
|
||||
export type AppEventPayloadMap = Record<string, unknown[]>;
|
||||
|
||||
export type AppEventBehavior = {
|
||||
cardinality: "once" | "many";
|
||||
replay: "none" | "last";
|
||||
};
|
||||
|
||||
export type AppEventBehaviorMap<Events extends AppEventPayloadMap> = {
|
||||
[K in keyof Events]: AppEventBehavior;
|
||||
};
|
||||
|
||||
type AwaitableAppEventKeys<
|
||||
Events extends AppEventPayloadMap,
|
||||
Behavior extends AppEventBehaviorMap<Events>,
|
||||
> = {
|
||||
[K in keyof Events]: Behavior[K]["cardinality"] extends "once"
|
||||
? Behavior[K]["replay"] extends "last"
|
||||
? K
|
||||
: never
|
||||
: never;
|
||||
}[keyof Events];
|
||||
|
||||
type AppEventPromiseValue<Args extends any[]> = Args extends [infer Only]
|
||||
? Only
|
||||
: Args;
|
||||
|
||||
export class AppEventBus<
|
||||
Events extends AppEventPayloadMap,
|
||||
Behavior extends AppEventBehaviorMap<Events>,
|
||||
> {
|
||||
private readonly emitters = new Map<keyof Events, Emitter<any>>();
|
||||
private readonly lastPayload = new Map<keyof Events, any[]>();
|
||||
private readonly emittedOnce = new Set<keyof Events>();
|
||||
|
||||
constructor(private readonly behavior: Behavior) {}
|
||||
|
||||
private getEmitter<K extends keyof Events>(name: K): Emitter<Events[K]> {
|
||||
let emitter = this.emitters.get(name);
|
||||
if (!emitter) {
|
||||
emitter = new Emitter<any>();
|
||||
this.emitters.set(name, emitter);
|
||||
}
|
||||
return emitter as Emitter<Events[K]>;
|
||||
}
|
||||
|
||||
private toPromiseValue<Args extends any[]>(
|
||||
args: Args,
|
||||
): AppEventPromiseValue<Args> {
|
||||
return (args.length === 1 ? args[0] : args) as AppEventPromiseValue<Args>;
|
||||
}
|
||||
|
||||
public on<K extends keyof Events>(
|
||||
name: K,
|
||||
callback: (...args: Events[K]) => void,
|
||||
): UnsubscribeCallback;
|
||||
public on<K extends AwaitableAppEventKeys<Events, Behavior>>(
|
||||
name: K,
|
||||
): Promise<AppEventPromiseValue<Events[K]>>;
|
||||
public on<K extends keyof Events>(
|
||||
name: K,
|
||||
callback?: (...args: Events[K]) => void,
|
||||
): UnsubscribeCallback | Promise<AppEventPromiseValue<Events[K]>> {
|
||||
const eventBehavior = this.behavior[name];
|
||||
const cachedPayload = this.lastPayload.get(name) as Events[K] | undefined;
|
||||
|
||||
if (callback) {
|
||||
if (eventBehavior.replay === "last" && cachedPayload) {
|
||||
queueMicrotask(() => callback(...cachedPayload));
|
||||
|
||||
if (eventBehavior.cardinality === "once") {
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
return this.getEmitter(name).on(callback);
|
||||
}
|
||||
|
||||
if (
|
||||
eventBehavior.cardinality !== "once" ||
|
||||
eventBehavior.replay !== "last"
|
||||
) {
|
||||
throw new Error(`Event "${String(name)}" requires a callback`);
|
||||
}
|
||||
|
||||
if (cachedPayload) {
|
||||
return Promise.resolve(this.toPromiseValue(cachedPayload));
|
||||
}
|
||||
|
||||
return new Promise<AppEventPromiseValue<Events[K]>>((resolve) => {
|
||||
this.getEmitter(name).once((...args: Events[K]) => {
|
||||
resolve(this.toPromiseValue(args));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public emit<K extends keyof Events>(name: K, ...args: Events[K]) {
|
||||
const eventBehavior = this.behavior[name];
|
||||
|
||||
if (!isProdEnv()) {
|
||||
if (eventBehavior.cardinality === "once") {
|
||||
if (this.emittedOnce.has(name)) {
|
||||
throw new Error(`Event "${String(name)}" can only be emitted once`);
|
||||
}
|
||||
this.emittedOnce.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (eventBehavior.replay === "last") {
|
||||
this.lastPayload.set(name, args);
|
||||
}
|
||||
|
||||
try {
|
||||
this.getEmitter(name).trigger(...args);
|
||||
} finally {
|
||||
if (eventBehavior.cardinality === "once") {
|
||||
this.getEmitter(name).clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public clear() {
|
||||
this.lastPayload.clear();
|
||||
this.emittedOnce.clear();
|
||||
|
||||
for (const emitter of this.emitters.values()) {
|
||||
emitter.clear();
|
||||
}
|
||||
|
||||
this.emitters.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import {
|
||||
applyDarkModeFilter,
|
||||
COLOR_PALETTE,
|
||||
rgbToHex,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
describe("COLOR_PALETTE", () => {
|
||||
it("color palette doesn't regress", () => {
|
||||
expect(COLOR_PALETTE).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyDarkModeFilter", () => {
|
||||
describe("basic transformations", () => {
|
||||
it("transforms black to near-white", () => {
|
||||
const result = applyDarkModeFilter("#000000");
|
||||
// Black inverted 93% + hue rotate should be near white/light gray
|
||||
expect(result).toBe("#ededed");
|
||||
});
|
||||
|
||||
it("transforms white to near-black", () => {
|
||||
const result = applyDarkModeFilter("#ffffff");
|
||||
// White inverted 93% should be near black/dark gray
|
||||
expect(result).toBe("#121212");
|
||||
});
|
||||
|
||||
it("transforms pure red", () => {
|
||||
const result = applyDarkModeFilter("#ff0000");
|
||||
// Invert 93% + hue rotate 180deg produces a cyan-ish tint
|
||||
expect(result).toBe("#ff9090");
|
||||
});
|
||||
|
||||
it("transforms pure green", () => {
|
||||
const result = applyDarkModeFilter("#00ff00");
|
||||
// Invert 93% + hue rotate 180deg
|
||||
expect(result).toBe("#008f00");
|
||||
});
|
||||
|
||||
it("transforms pure blue", () => {
|
||||
const result = applyDarkModeFilter("#0000ff");
|
||||
// Invert 93% + hue rotate 180deg produces a light purple
|
||||
expect(result).toBe("#cdcdff");
|
||||
});
|
||||
});
|
||||
|
||||
describe("color formats", () => {
|
||||
it("handles hex with hash", () => {
|
||||
const result = applyDarkModeFilter("#ff0000");
|
||||
// Fully opaque colors return 6-char hex
|
||||
expect(result).toMatch(/^#[0-9a-f]{6}$/);
|
||||
});
|
||||
|
||||
it("handles named colors", () => {
|
||||
const result = applyDarkModeFilter("red");
|
||||
// "red" = #ff0000, fully opaque
|
||||
expect(result).toBe("#ff9090");
|
||||
});
|
||||
|
||||
it("handles rgb format", () => {
|
||||
const result = applyDarkModeFilter("rgb(255, 0, 0)");
|
||||
expect(result).toBe("#ff9090");
|
||||
});
|
||||
|
||||
it("handles rgba format and preserves alpha", () => {
|
||||
const result = applyDarkModeFilter("rgba(255, 0, 0, 0.5)");
|
||||
expect(result).toMatch(/^#[0-9a-f]{8}$/);
|
||||
// Alpha 0.5 = 128 in hex = 80
|
||||
expect(result).toBe("#ff909080");
|
||||
});
|
||||
|
||||
it("handles transparent", () => {
|
||||
const result = applyDarkModeFilter("transparent");
|
||||
// transparent = rgba(0,0,0,0), inverted should still have 0 alpha
|
||||
expect(result).toBe("#ededed00");
|
||||
});
|
||||
|
||||
it("handles shorthand hex", () => {
|
||||
const result = applyDarkModeFilter("#f00");
|
||||
expect(result).toBe("#ff9090");
|
||||
});
|
||||
});
|
||||
|
||||
describe("alpha preservation", () => {
|
||||
it("omits alpha for full opacity", () => {
|
||||
const result = applyDarkModeFilter("#ff0000ff");
|
||||
// Full opacity returns 6-char hex (no alpha suffix)
|
||||
expect(result).toBe("#ff9090");
|
||||
});
|
||||
|
||||
it("preserves 50% opacity", () => {
|
||||
const result = applyDarkModeFilter("#ff000080");
|
||||
expect(result.slice(-2)).toBe("80");
|
||||
});
|
||||
|
||||
it("preserves 0% opacity", () => {
|
||||
const result = applyDarkModeFilter("#ff000000");
|
||||
expect(result.slice(-2)).toBe("00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("COLOR_PALETTE regression tests", () => {
|
||||
it("transforms black from palette", () => {
|
||||
// COLOR_PALETTE.black is #1e1e1e (not pure black)
|
||||
const result = applyDarkModeFilter(COLOR_PALETTE.black);
|
||||
expect(result).toBe("#d3d3d3");
|
||||
});
|
||||
|
||||
it("transforms white from palette", () => {
|
||||
const result = applyDarkModeFilter(COLOR_PALETTE.white);
|
||||
expect(result).toBe("#121212");
|
||||
});
|
||||
|
||||
it("transforms transparent from palette", () => {
|
||||
const result = applyDarkModeFilter(COLOR_PALETTE.transparent);
|
||||
expect(result).toBe("#ededed00");
|
||||
});
|
||||
|
||||
// Test each color family from the palette (all opaque, so 6-char hex)
|
||||
describe("red shades", () => {
|
||||
const redShades = COLOR_PALETTE.red;
|
||||
it.each(redShades.map((color, i) => [color, i]))(
|
||||
"transforms red shade %s (index %d)",
|
||||
(color) => {
|
||||
const result = applyDarkModeFilter(color as string);
|
||||
expect(result).toMatch(/^#[0-9a-f]{6}$/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("blue shades", () => {
|
||||
const blueShades = COLOR_PALETTE.blue;
|
||||
it.each(blueShades.map((color, i) => [color, i]))(
|
||||
"transforms blue shade %s (index %d)",
|
||||
(color) => {
|
||||
const result = applyDarkModeFilter(color as string);
|
||||
expect(result).toMatch(/^#[0-9a-f]{6}$/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("green shades", () => {
|
||||
const greenShades = COLOR_PALETTE.green;
|
||||
it.each(greenShades.map((color, i) => [color, i]))(
|
||||
"transforms green shade %s (index %d)",
|
||||
(color) => {
|
||||
const result = applyDarkModeFilter(color as string);
|
||||
expect(result).toMatch(/^#[0-9a-f]{6}$/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("gray shades", () => {
|
||||
const grayShades = COLOR_PALETTE.gray;
|
||||
it.each(grayShades.map((color, i) => [color, i]))(
|
||||
"transforms gray shade %s (index %d)",
|
||||
(color) => {
|
||||
const result = applyDarkModeFilter(color as string);
|
||||
expect(result).toMatch(/^#[0-9a-f]{6}$/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("bronze shades", () => {
|
||||
const bronzeShades = COLOR_PALETTE.bronze;
|
||||
it.each(bronzeShades.map((color, i) => [color, i]))(
|
||||
"transforms bronze shade %s (index %d)",
|
||||
(color) => {
|
||||
const result = applyDarkModeFilter(color as string);
|
||||
expect(result).toMatch(/^#[0-9a-f]{6}$/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Snapshot test for full palette to catch any regressions
|
||||
it("matches snapshot for all palette colors", () => {
|
||||
const transformedPalette: Record<string, string | string[]> = {};
|
||||
|
||||
transformedPalette.black = applyDarkModeFilter(COLOR_PALETTE.black);
|
||||
transformedPalette.white = applyDarkModeFilter(COLOR_PALETTE.white);
|
||||
transformedPalette.transparent = applyDarkModeFilter(
|
||||
COLOR_PALETTE.transparent,
|
||||
);
|
||||
|
||||
// Transform color arrays
|
||||
for (const colorName of [
|
||||
"gray",
|
||||
"red",
|
||||
"pink",
|
||||
"grape",
|
||||
"violet",
|
||||
"blue",
|
||||
"cyan",
|
||||
"teal",
|
||||
"green",
|
||||
"yellow",
|
||||
"orange",
|
||||
"bronze",
|
||||
] as const) {
|
||||
const shades = COLOR_PALETTE[colorName];
|
||||
transformedPalette[colorName] = shades.map((shade) =>
|
||||
applyDarkModeFilter(shade),
|
||||
);
|
||||
}
|
||||
|
||||
expect(transformedPalette).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("caching", () => {
|
||||
it("returns same result for same input (cached)", () => {
|
||||
const result1 = applyDarkModeFilter("#ff0000");
|
||||
const result2 = applyDarkModeFilter("#ff0000");
|
||||
expect(result1).toBe(result2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("rgbToHex", () => {
|
||||
describe("basic RGB conversion", () => {
|
||||
it("converts black (0,0,0)", () => {
|
||||
expect(rgbToHex(0, 0, 0)).toBe("#000000");
|
||||
});
|
||||
|
||||
it("converts white (255,255,255)", () => {
|
||||
expect(rgbToHex(255, 255, 255)).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it("converts red (255,0,0)", () => {
|
||||
expect(rgbToHex(255, 0, 0)).toBe("#ff0000");
|
||||
});
|
||||
|
||||
it("converts green (0,255,0)", () => {
|
||||
expect(rgbToHex(0, 255, 0)).toBe("#00ff00");
|
||||
});
|
||||
|
||||
it("converts blue (0,0,255)", () => {
|
||||
expect(rgbToHex(0, 0, 255)).toBe("#0000ff");
|
||||
});
|
||||
|
||||
it("converts arbitrary color", () => {
|
||||
expect(rgbToHex(30, 30, 30)).toBe("#1e1e1e");
|
||||
});
|
||||
});
|
||||
|
||||
describe("leading zeros preservation", () => {
|
||||
it("preserves leading zeros for low values", () => {
|
||||
expect(rgbToHex(0, 0, 1)).toBe("#000001");
|
||||
expect(rgbToHex(0, 1, 0)).toBe("#000100");
|
||||
expect(rgbToHex(1, 0, 0)).toBe("#010000");
|
||||
});
|
||||
|
||||
it("preserves zeros for single-digit hex values", () => {
|
||||
expect(rgbToHex(15, 15, 15)).toBe("#0f0f0f");
|
||||
});
|
||||
});
|
||||
|
||||
describe("alpha handling", () => {
|
||||
it("omits alpha when undefined", () => {
|
||||
expect(rgbToHex(255, 0, 0)).toBe("#ff0000");
|
||||
expect(rgbToHex(255, 0, 0, undefined)).toBe("#ff0000");
|
||||
});
|
||||
|
||||
it("omits alpha when fully opaque (1)", () => {
|
||||
expect(rgbToHex(255, 0, 0, 1)).toBe("#ff0000");
|
||||
});
|
||||
|
||||
it("includes alpha for semi-transparent (0.5)", () => {
|
||||
// 0.5 * 255 = 127.5 -> rounds to 128 = 0x80
|
||||
expect(rgbToHex(255, 0, 0, 0.5)).toBe("#ff000080");
|
||||
});
|
||||
|
||||
it("includes alpha for fully transparent (0)", () => {
|
||||
expect(rgbToHex(255, 0, 0, 0)).toBe("#ff000000");
|
||||
});
|
||||
|
||||
it("includes alpha for near-opaque (0.99)", () => {
|
||||
// 0.99 * 255 = 252.45 -> rounds to 252 = 0xfc
|
||||
expect(rgbToHex(255, 0, 0, 0.99)).toBe("#ff0000fc");
|
||||
});
|
||||
|
||||
it("pads alpha with leading zero when needed", () => {
|
||||
// 0.05 * 255 = 12.75 -> rounds to 13 = 0x0d
|
||||
expect(rgbToHex(255, 0, 0, 0.05)).toBe("#ff00000d");
|
||||
});
|
||||
});
|
||||
});
|
||||
+241
-59
@@ -1,8 +1,117 @@
|
||||
import oc from "open-color";
|
||||
import tinycolor from "tinycolor2";
|
||||
|
||||
import type { Merge } from "./utility-types";
|
||||
import { clamp } from "@excalidraw/math";
|
||||
import { degreesToRadians } from "@excalidraw/math";
|
||||
|
||||
export const COLOR_OUTLINE_CONTRAST_THRESHOLD = 240;
|
||||
import type { Degrees } from "@excalidraw/math";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dark mode color transformation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Browser-only cache to avoid memory leaks on server
|
||||
const DARK_MODE_COLORS_CACHE: Map<string, string> | null =
|
||||
typeof window !== "undefined" ? new Map() : null;
|
||||
|
||||
function cssHueRotate(
|
||||
red: number,
|
||||
green: number,
|
||||
blue: number,
|
||||
degrees: Degrees,
|
||||
): { r: number; g: number; b: number } {
|
||||
// normalize
|
||||
const r = red / 255;
|
||||
const g = green / 255;
|
||||
const b = blue / 255;
|
||||
|
||||
// Convert degrees to radians
|
||||
const a = degreesToRadians(degrees);
|
||||
|
||||
const c = Math.cos(a);
|
||||
const s = Math.sin(a);
|
||||
|
||||
// rotation matrix
|
||||
const matrix = [
|
||||
0.213 + c * 0.787 - s * 0.213,
|
||||
0.715 - c * 0.715 - s * 0.715,
|
||||
0.072 - c * 0.072 + s * 0.928,
|
||||
0.213 - c * 0.213 + s * 0.143,
|
||||
0.715 + c * 0.285 + s * 0.14,
|
||||
0.072 - c * 0.072 - s * 0.283,
|
||||
0.213 - c * 0.213 - s * 0.787,
|
||||
0.715 - c * 0.715 + s * 0.715,
|
||||
0.072 + c * 0.928 + s * 0.072,
|
||||
];
|
||||
|
||||
// transform
|
||||
const newR = r * matrix[0] + g * matrix[1] + b * matrix[2];
|
||||
const newG = r * matrix[3] + g * matrix[4] + b * matrix[5];
|
||||
const newB = r * matrix[6] + g * matrix[7] + b * matrix[8];
|
||||
|
||||
// clamp the values to [0, 1] range and convert back to [0, 255]
|
||||
return {
|
||||
r: Math.round(Math.max(0, Math.min(1, newR)) * 255),
|
||||
g: Math.round(Math.max(0, Math.min(1, newG)) * 255),
|
||||
b: Math.round(Math.max(0, Math.min(1, newB)) * 255),
|
||||
};
|
||||
}
|
||||
|
||||
const cssInvert = (
|
||||
r: number,
|
||||
g: number,
|
||||
b: number,
|
||||
percent: number,
|
||||
): { r: number; g: number; b: number } => {
|
||||
const p = clamp(percent, 0, 100) / 100;
|
||||
|
||||
// Function to invert a single color component
|
||||
const invertComponent = (color: number): number => {
|
||||
// Apply the invert formula
|
||||
const inverted = color * (1 - p) + (255 - color) * p;
|
||||
// Round to the nearest integer and clamp to [0, 255]
|
||||
return Math.round(clamp(inverted, 0, 255));
|
||||
};
|
||||
|
||||
// Calculate the inverted RGB components
|
||||
const invertedR = invertComponent(r);
|
||||
const invertedG = invertComponent(g);
|
||||
const invertedB = invertComponent(b);
|
||||
|
||||
return { r: invertedR, g: invertedG, b: invertedB };
|
||||
};
|
||||
|
||||
export const applyDarkModeFilter = (color: string): string => {
|
||||
const cached = DARK_MODE_COLORS_CACHE?.get(color);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const tc = tinycolor(color);
|
||||
const alpha = tc.getAlpha();
|
||||
|
||||
// order of operations matters
|
||||
// (corresponds to "filter: invert(invertPercent) hue-rotate(hueDegrees)" in css)
|
||||
const rgb = tc.toRgb();
|
||||
const inverted = cssInvert(rgb.r, rgb.g, rgb.b, 93);
|
||||
const rotated = cssHueRotate(
|
||||
inverted.r,
|
||||
inverted.g,
|
||||
inverted.b,
|
||||
180 as Degrees,
|
||||
);
|
||||
|
||||
const result = rgbToHex(rotated.r, rotated.g, rotated.b, alpha);
|
||||
|
||||
if (DARK_MODE_COLORS_CACHE) {
|
||||
DARK_MODE_COLORS_CACHE.set(color, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color palette
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// FIXME can't put to utils.ts rn because of circular dependency
|
||||
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
|
||||
@@ -17,15 +126,7 @@ const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
|
||||
}, {} as Pick<R, K[number]>) as Pick<R, K[number]>;
|
||||
};
|
||||
|
||||
export type ColorPickerColor =
|
||||
| Exclude<keyof oc, "indigo" | "lime">
|
||||
| "transparent"
|
||||
| "bronze";
|
||||
export type ColorTuple = readonly [string, string, string, string, string];
|
||||
export type ColorPalette = Merge<
|
||||
Record<ColorPickerColor, ColorTuple>,
|
||||
{ black: "#1e1e1e"; white: "#ffffff"; transparent: "transparent" }
|
||||
>;
|
||||
|
||||
// used general type instead of specific type (ColorPalette) to support custom colors
|
||||
export type ColorPaletteCustom = { [key: string]: ColorTuple | string };
|
||||
@@ -38,38 +139,30 @@ export const DEFAULT_CHART_COLOR_INDEX = 4;
|
||||
|
||||
export const DEFAULT_ELEMENT_STROKE_COLOR_INDEX = 4;
|
||||
export const DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX = 1;
|
||||
export const ELEMENTS_PALETTE_SHADE_INDEXES = [0, 2, 4, 6, 8] as const;
|
||||
export const CANVAS_PALETTE_SHADE_INDEXES = [0, 1, 2, 3, 4] as const;
|
||||
|
||||
export const getSpecificColorShades = (
|
||||
color: Exclude<
|
||||
ColorPickerColor,
|
||||
"transparent" | "white" | "black" | "bronze"
|
||||
>,
|
||||
indexArr: Readonly<ColorShadesIndexes>,
|
||||
) => {
|
||||
return indexArr.map((index) => oc[color][index]) as any as ColorTuple;
|
||||
};
|
||||
|
||||
export const COLOR_PALETTE = {
|
||||
transparent: "transparent",
|
||||
black: "#1e1e1e",
|
||||
white: "#ffffff",
|
||||
// open-colors
|
||||
gray: getSpecificColorShades("gray", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
red: getSpecificColorShades("red", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
pink: getSpecificColorShades("pink", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
grape: getSpecificColorShades("grape", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
violet: getSpecificColorShades("violet", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
blue: getSpecificColorShades("blue", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
cyan: getSpecificColorShades("cyan", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
teal: getSpecificColorShades("teal", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
green: getSpecificColorShades("green", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
yellow: getSpecificColorShades("yellow", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
orange: getSpecificColorShades("orange", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
// radix bronze shades 3,5,7,9,11
|
||||
// open-color from https://github.com/yeun/open-color/blob/master/open-color.js
|
||||
// corresponds to indexes [0,2,4,6,8] (weights: 50, 200, 400, 600, 800)
|
||||
gray: ["#f8f9fa", "#e9ecef", "#ced4da", "#868e96", "#343a40"],
|
||||
red: ["#fff5f5", "#ffc9c9", "#ff8787", "#fa5252", "#e03131"],
|
||||
pink: ["#fff0f6", "#fcc2d7", "#f783ac", "#e64980", "#c2255c"],
|
||||
grape: ["#f8f0fc", "#eebefa", "#da77f2", "#be4bdb", "#9c36b5"],
|
||||
violet: ["#f3f0ff", "#d0bfff", "#9775fa", "#7950f2", "#6741d9"],
|
||||
blue: ["#e7f5ff", "#a5d8ff", "#4dabf7", "#228be6", "#1971c2"],
|
||||
cyan: ["#e3fafc", "#99e9f2", "#3bc9db", "#15aabf", "#0c8599"],
|
||||
teal: ["#e6fcf5", "#96f2d7", "#38d9a9", "#12b886", "#099268"],
|
||||
green: ["#ebfbee", "#b2f2bb", "#69db7c", "#40c057", "#2f9e44"],
|
||||
yellow: ["#fff9db", "#ffec99", "#ffd43b", "#fab005", "#f08c00"],
|
||||
orange: ["#fff4e6", "#ffd8a8", "#ffa94d", "#fd7e14", "#e8590c"],
|
||||
// radix bronze shades [3,5,7,9,11]
|
||||
bronze: ["#f8f1ee", "#eaddd7", "#d2bab0", "#a18072", "#846358"],
|
||||
} as ColorPalette;
|
||||
} as const;
|
||||
|
||||
export type ColorPalette = typeof COLOR_PALETTE;
|
||||
export type ColorPickerColor = keyof typeof COLOR_PALETTE;
|
||||
|
||||
const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
|
||||
"cyan",
|
||||
@@ -84,7 +177,6 @@ const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
|
||||
"red",
|
||||
]);
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// quick picks defaults
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -119,7 +211,6 @@ export const DEFAULT_CANVAS_BACKGROUND_PICKS = [
|
||||
"#fdf8f6",
|
||||
] as ColorTuple;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// palette defaults
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -145,29 +236,120 @@ export const DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE = {
|
||||
...COMMON_ELEMENT_SHADES,
|
||||
} as const;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// helpers
|
||||
// color palette helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// !!!MUST BE WITHOUT GRAY, TRANSPARENT AND BLACK!!!
|
||||
export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) =>
|
||||
[
|
||||
// 2nd row
|
||||
COLOR_PALETTE.cyan[index],
|
||||
COLOR_PALETTE.blue[index],
|
||||
COLOR_PALETTE.violet[index],
|
||||
COLOR_PALETTE.grape[index],
|
||||
COLOR_PALETTE.pink[index],
|
||||
export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) => [
|
||||
// 2nd row
|
||||
COLOR_PALETTE.cyan[index],
|
||||
COLOR_PALETTE.blue[index],
|
||||
COLOR_PALETTE.violet[index],
|
||||
COLOR_PALETTE.grape[index],
|
||||
COLOR_PALETTE.pink[index],
|
||||
|
||||
// 3rd row
|
||||
COLOR_PALETTE.green[index],
|
||||
COLOR_PALETTE.teal[index],
|
||||
COLOR_PALETTE.yellow[index],
|
||||
COLOR_PALETTE.orange[index],
|
||||
COLOR_PALETTE.red[index],
|
||||
] as const;
|
||||
|
||||
export const rgbToHex = (r: number, g: number, b: number) =>
|
||||
`#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
||||
// 3rd row
|
||||
COLOR_PALETTE.green[index],
|
||||
COLOR_PALETTE.teal[index],
|
||||
COLOR_PALETTE.yellow[index],
|
||||
COLOR_PALETTE.orange[index],
|
||||
COLOR_PALETTE.red[index],
|
||||
];
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// other helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const rgbToHex = (r: number, g: number, b: number, a?: number) => {
|
||||
// (1 << 24) adds 0x1000000 to ensure the hex string is always 7 chars,
|
||||
// then slice(1) removes the leading "1" to get exactly 6 hex digits
|
||||
// e.g. rgb(0,0,0) -> 0x1000000 -> "1000000" -> "000000"
|
||||
const hex6 = `#${((1 << 24) + (r << 16) + (g << 8) + b)
|
||||
.toString(16)
|
||||
.slice(1)}`;
|
||||
if (a !== undefined && a < 1) {
|
||||
// convert alpha from 0-1 float to 0-255 int, then to 2-digit hex
|
||||
// e.g. 0.5 -> 128 -> "80"
|
||||
const alphaHex = Math.round(a * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
return `${hex6}${alphaHex}`;
|
||||
}
|
||||
return hex6;
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns #RRGGBB or #RRGGBBAA based on color containing non-opaque alpha,
|
||||
* null if not valid color
|
||||
*/
|
||||
export const colorToHex = (color: string): string | null => {
|
||||
const tc = tinycolor(color);
|
||||
if (!tc.isValid()) {
|
||||
return null;
|
||||
}
|
||||
const { r, g, b, a } = tc.toRgb();
|
||||
return rgbToHex(r, g, b, a);
|
||||
};
|
||||
|
||||
export const isTransparent = (color: string) => {
|
||||
return tinycolor(color).getAlpha() === 0;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// color contract helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const COLOR_OUTLINE_CONTRAST_THRESHOLD = 240;
|
||||
|
||||
const calculateContrast = (r: number, g: number, b: number): number => {
|
||||
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
|
||||
return yiq;
|
||||
};
|
||||
|
||||
// YIQ algo, inspiration from https://stackoverflow.com/a/11868398
|
||||
export const isColorDark = (color: string, threshold = 160): boolean => {
|
||||
// no color ("") -> assume it default to black
|
||||
if (!color) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isTransparent(color)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tc = tinycolor(color);
|
||||
if (!tc.isValid()) {
|
||||
// invalid color -> assume it defaults to black
|
||||
return true;
|
||||
}
|
||||
|
||||
const { r, g, b } = tc.toRgb();
|
||||
return calculateContrast(r, g, b) < threshold;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// normalization
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* tries to keep the input color as-is if it's valid, making minimal adjustments
|
||||
* (trimming whitespace or adding `#` to hex colors)
|
||||
*/
|
||||
export const normalizeInputColor = (color: string): string | null => {
|
||||
color = color.trim();
|
||||
if (isTransparent(color)) {
|
||||
return color;
|
||||
}
|
||||
|
||||
const tc = tinycolor(color);
|
||||
if (tc.isValid()) {
|
||||
// testing for `#` first fixes a bug on Electron (more specfically, an
|
||||
// Obsidian popout window), where a hex color without `#` is considered valid
|
||||
if (["hex", "hex8"].includes(tc.getFormat()) && !color.startsWith("#")) {
|
||||
return `#${color}`;
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -106,6 +106,7 @@ export const CLASSES = {
|
||||
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
|
||||
SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope",
|
||||
FRAME_NAME: "frame-name",
|
||||
DROPDOWN_MENU_EVENT_WRAPPER: "dropdown-menu-event-wrapper",
|
||||
};
|
||||
|
||||
export const FONT_SIZES = {
|
||||
@@ -190,6 +191,8 @@ export const THEME = {
|
||||
DARK: "dark",
|
||||
} as const;
|
||||
|
||||
export const DARK_THEME_FILTER = "invert(93%) hue-rotate(180deg)";
|
||||
|
||||
export const FRAME_STYLE = {
|
||||
strokeColor: "#bbb" as ExcalidrawElement["strokeColor"],
|
||||
strokeWidth: 2 as ExcalidrawElement["strokeWidth"],
|
||||
@@ -249,6 +252,7 @@ export const STRING_MIME_TYPES = {
|
||||
json: "application/json",
|
||||
// excalidraw data
|
||||
excalidraw: "application/vnd.excalidraw+json",
|
||||
excalidrawClipboard: "application/vnd.excalidraw.clipboard+json",
|
||||
// LEGACY: fully-qualified library JSON data
|
||||
excalidrawlib: "application/vnd.excalidrawlib+json",
|
||||
// list of excalidraw library item ids
|
||||
@@ -305,9 +309,6 @@ export const IDLE_THRESHOLD = 60_000;
|
||||
// Report a user active each ACTIVE_THRESHOLD milliseconds
|
||||
export const ACTIVE_THRESHOLD = 3_000;
|
||||
|
||||
// duplicates --theme-filter, should be removed soon
|
||||
export const THEME_FILTER = "invert(93%) hue-rotate(180deg)";
|
||||
|
||||
export const URL_QUERY_KEYS = {
|
||||
addLibrary: "addLibrary",
|
||||
} as const;
|
||||
|
||||
@@ -16,7 +16,6 @@ export type EditorInterface = Readonly<{
|
||||
const DESKTOP_UI_MODE_STORAGE_KEY = "excalidraw.desktopUIMode";
|
||||
|
||||
// breakpoints
|
||||
// mobile: up to 699px
|
||||
export const MQ_MAX_MOBILE = 599;
|
||||
|
||||
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
||||
@@ -24,9 +23,9 @@ export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
|
||||
|
||||
// tablets
|
||||
export const MQ_MIN_TABLET = MQ_MAX_MOBILE + 1; // lower bound (excludes phones)
|
||||
export const MQ_MAX_TABLET = 1400; // upper bound (excludes laptops/desktops)
|
||||
export const MQ_MAX_TABLET = 1180; // ipad air
|
||||
|
||||
// desktop/laptop
|
||||
// desktop/laptop (NOTE: not used for form factor detection)
|
||||
export const MQ_MIN_WIDTH_DESKTOP = 1440;
|
||||
|
||||
// sidebar
|
||||
|
||||
@@ -11,5 +11,7 @@ export * from "./random";
|
||||
export * from "./url";
|
||||
export * from "./utils";
|
||||
export * from "./emitter";
|
||||
export * from "./visualdebug";
|
||||
export * from "./appEventBus";
|
||||
export * from "./editorInterface";
|
||||
export * from "./versionedSnapshotStore";
|
||||
export { Debug } from "../debug";
|
||||
|
||||
@@ -3,6 +3,12 @@ import {
|
||||
mapFind,
|
||||
reduceToCommonValue,
|
||||
} from "@excalidraw/common";
|
||||
import { vi } from "vitest";
|
||||
|
||||
// Import directly to avoid the @excalidraw/common throttleRAF mock from setupTests.ts.
|
||||
import { throttleRAF } from "./utils";
|
||||
|
||||
type RafCallback = FrameRequestCallback;
|
||||
|
||||
describe("@excalidraw/common/utils", () => {
|
||||
describe("isTransparent()", () => {
|
||||
@@ -79,4 +85,87 @@ describe("@excalidraw/common/utils", () => {
|
||||
expect(mapFind([1, 2], () => null)).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("throttleRAF()", () => {
|
||||
let frameCallbacks: Map<number, RafCallback>;
|
||||
let nextFrameId: number;
|
||||
|
||||
const runScheduledFrame = (timestamp = 16) => {
|
||||
const callbacks = [...frameCallbacks.values()];
|
||||
frameCallbacks.clear();
|
||||
callbacks.forEach((callback) => callback(timestamp));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
frameCallbacks = new Map();
|
||||
nextFrameId = 0;
|
||||
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation(
|
||||
(callback) => {
|
||||
const frameId = ++nextFrameId;
|
||||
frameCallbacks.set(frameId, callback);
|
||||
return frameId;
|
||||
},
|
||||
);
|
||||
|
||||
vi.spyOn(window, "cancelAnimationFrame").mockImplementation((frameId) => {
|
||||
frameCallbacks.delete(frameId);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should invoke the callback with the last args from the same frame", () => {
|
||||
const fn = vi.fn();
|
||||
const throttled = throttleRAF(fn);
|
||||
|
||||
throttled("first", 1);
|
||||
throttled("second", 2);
|
||||
throttled("last", 3);
|
||||
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1);
|
||||
|
||||
runScheduledFrame();
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(fn).toHaveBeenCalledWith("last", 3);
|
||||
});
|
||||
|
||||
it("should flush the pending callback immediately", () => {
|
||||
const fn = vi.fn();
|
||||
const throttled = throttleRAF(fn);
|
||||
|
||||
throttled("first");
|
||||
throttled("last");
|
||||
|
||||
throttled.flush();
|
||||
|
||||
expect(window.cancelAnimationFrame).toHaveBeenCalledTimes(1);
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(fn).toHaveBeenCalledWith("last");
|
||||
|
||||
runScheduledFrame();
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should cancel the pending callback", () => {
|
||||
const fn = vi.fn();
|
||||
const throttled = throttleRAF(fn);
|
||||
|
||||
throttled("first");
|
||||
throttled("last");
|
||||
|
||||
throttled.cancel();
|
||||
|
||||
expect(window.cancelAnimationFrame).toHaveBeenCalledTimes(1);
|
||||
|
||||
runScheduledFrame();
|
||||
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { average } from "@excalidraw/math";
|
||||
|
||||
import type { GlobalCoord } from "@excalidraw/math";
|
||||
|
||||
import type { FontFamilyValues, FontString } from "@excalidraw/element/types";
|
||||
|
||||
import type {
|
||||
@@ -10,7 +12,6 @@ import type {
|
||||
Zoom,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { COLOR_PALETTE } from "./colors";
|
||||
import {
|
||||
DEFAULT_VERSION,
|
||||
ENV,
|
||||
@@ -87,7 +88,8 @@ export const isWritableElement = (
|
||||
(target.type === "text" ||
|
||||
target.type === "number" ||
|
||||
target.type === "password" ||
|
||||
target.type === "search"));
|
||||
target.type === "search")) ||
|
||||
(target instanceof HTMLElement && target.closest(".cm-editor") !== null);
|
||||
|
||||
export const getFontFamilyString = ({
|
||||
fontFamily,
|
||||
@@ -149,38 +151,27 @@ export const debounce = <T extends any[]>(
|
||||
return ret;
|
||||
};
|
||||
|
||||
// throttle callback to execute once per animation frame
|
||||
export const throttleRAF = <T extends any[]>(
|
||||
fn: (...args: T) => void,
|
||||
opts?: { trailing?: boolean },
|
||||
) => {
|
||||
// throttle callback to execute once per animation frame using the latest args
|
||||
export const throttleRAF = <T extends any[]>(fn: (...args: T) => void) => {
|
||||
let timerId: number | null = null;
|
||||
let lastArgs: T | null = null;
|
||||
let lastArgsTrailing: T | null = null;
|
||||
|
||||
const scheduleFunc = (args: T) => {
|
||||
const scheduleFunc = () => {
|
||||
timerId = window.requestAnimationFrame(() => {
|
||||
timerId = null;
|
||||
fn(...args);
|
||||
const args = lastArgs;
|
||||
lastArgs = null;
|
||||
if (lastArgsTrailing) {
|
||||
lastArgs = lastArgsTrailing;
|
||||
lastArgsTrailing = null;
|
||||
scheduleFunc(lastArgs);
|
||||
|
||||
if (args) {
|
||||
fn(...args);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const ret = (...args: T) => {
|
||||
if (isTestEnv()) {
|
||||
fn(...args);
|
||||
return;
|
||||
}
|
||||
lastArgs = args;
|
||||
if (timerId === null) {
|
||||
scheduleFunc(lastArgs);
|
||||
} else if (opts?.trailing) {
|
||||
lastArgsTrailing = args;
|
||||
scheduleFunc();
|
||||
}
|
||||
};
|
||||
ret.flush = () => {
|
||||
@@ -189,12 +180,12 @@ export const throttleRAF = <T extends any[]>(
|
||||
timerId = null;
|
||||
}
|
||||
if (lastArgs) {
|
||||
fn(...(lastArgsTrailing || lastArgs));
|
||||
lastArgs = lastArgsTrailing = null;
|
||||
fn(...lastArgs);
|
||||
lastArgs = null;
|
||||
}
|
||||
};
|
||||
ret.cancel = () => {
|
||||
lastArgs = lastArgsTrailing = null;
|
||||
lastArgs = null;
|
||||
if (timerId !== null) {
|
||||
cancelAnimationFrame(timerId);
|
||||
timerId = null;
|
||||
@@ -442,7 +433,7 @@ export const viewportCoordsToSceneCoords = (
|
||||
const x = (clientX - offsetLeft) / zoom.value - scrollX;
|
||||
const y = (clientY - offsetTop) / zoom.value - scrollY;
|
||||
|
||||
return { x, y };
|
||||
return { x, y } as GlobalCoord;
|
||||
};
|
||||
|
||||
export const sceneCoordsToViewportCoords = (
|
||||
@@ -548,16 +539,6 @@ export const mapFind = <T, K>(
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const isTransparent = (color: string) => {
|
||||
const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0";
|
||||
const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00";
|
||||
return (
|
||||
isRGBTransparent ||
|
||||
isRRGGBBTransparent ||
|
||||
color === COLOR_PALETTE.transparent
|
||||
);
|
||||
};
|
||||
|
||||
export type ResolvablePromise<T> = Promise<T> & {
|
||||
resolve: [T] extends [undefined]
|
||||
? (value?: MaybePromise<Awaited<T>>) => void
|
||||
@@ -1157,39 +1138,69 @@ export const normalizeEOL = (str: string) => {
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
type HasBrand<T> = {
|
||||
export type HasBrand<T> = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
[K in keyof T]: K extends `~brand${infer _}` ? true : never;
|
||||
[K in keyof T]: K extends `~brand${infer _}` | "_brand" ? true : never;
|
||||
}[keyof T];
|
||||
|
||||
type RemoveAllBrands<T> = HasBrand<T> extends true
|
||||
? {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
[K in keyof T as K extends `~brand~${infer _}` ? never : K]: T[K];
|
||||
[K in keyof T as K extends `~brand~${infer _}` | "_brand"
|
||||
? never
|
||||
: K]: T[K];
|
||||
}
|
||||
: never;
|
||||
: T;
|
||||
|
||||
// adapted from https://github.com/colinhacks/zod/discussions/1994#discussioncomment-6068940
|
||||
// currently does not cover all types (e.g. tuples, promises...)
|
||||
type Unbrand<T> = T extends Map<infer E, infer F>
|
||||
? Map<E, F>
|
||||
// For accepting values - uses loose matching for branded types
|
||||
// Preserves readonly modifier: mutable array requires mutable input
|
||||
type UnbrandForValue<T> = T extends Map<infer E, infer F>
|
||||
? Map<UnbrandForValue<E>, UnbrandForValue<F>>
|
||||
: T extends Set<infer E>
|
||||
? Set<E>
|
||||
: T extends Array<infer E>
|
||||
? Array<E>
|
||||
? Set<UnbrandForValue<E>>
|
||||
: T extends readonly any[]
|
||||
? T extends any[]
|
||||
? unknown[] // mutable array - require mutable input
|
||||
: readonly unknown[] // readonly array - accept readonly input
|
||||
: RemoveAllBrands<T>;
|
||||
|
||||
// For return types - preserves array element unbranding
|
||||
export type Unbrand<T> = T extends Map<infer E, infer F>
|
||||
? Map<Unbrand<E>, Unbrand<F>>
|
||||
: T extends Set<infer E>
|
||||
? Set<Unbrand<E>>
|
||||
: T extends readonly (infer E)[]
|
||||
? Array<Unbrand<E>>
|
||||
: RemoveAllBrands<T>;
|
||||
|
||||
export type CombineBrands<BrandedType, CurrentType> =
|
||||
BrandedType extends readonly (infer BE)[]
|
||||
? CurrentType extends readonly (infer CE)[]
|
||||
? Array<CE & BE>
|
||||
: CurrentType & BrandedType
|
||||
: CurrentType & BrandedType;
|
||||
|
||||
export type CombineBrandsIfNeeded<T, Required> = [T] extends [Required]
|
||||
? T[]
|
||||
: HasBrand<T> extends true
|
||||
? CombineBrands<T, Required>[]
|
||||
: Required[];
|
||||
|
||||
/**
|
||||
* Makes type into a branded type, ensuring that value is assignable to
|
||||
* the base ubranded type. Optionally you can explicitly supply current value
|
||||
* the base unbranded type. Optionally you can explicitly supply current value
|
||||
* type to combine both (useful for composite branded types. Make sure you
|
||||
* compose branded types which are not composite themselves.)
|
||||
*/
|
||||
export const toBrandedType = <BrandedType, CurrentType = BrandedType>(
|
||||
value: Unbrand<BrandedType>,
|
||||
) => {
|
||||
return value as CurrentType & BrandedType;
|
||||
};
|
||||
export function toBrandedType<BrandedType>(
|
||||
value: UnbrandForValue<BrandedType>,
|
||||
): BrandedType;
|
||||
export function toBrandedType<BrandedType, CurrentType>(
|
||||
value: CurrentType,
|
||||
): CombineBrands<BrandedType, CurrentType>;
|
||||
export function toBrandedType(value: unknown) {
|
||||
return value;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -1311,3 +1322,10 @@ export const setFeatureFlag = <F extends keyof FEATURE_FLAGS>(
|
||||
console.error("unable to set feature flag", e);
|
||||
}
|
||||
};
|
||||
|
||||
export const oneOf = <N extends string | number | symbol | null, H extends N>(
|
||||
needle: N,
|
||||
haystack: readonly H[],
|
||||
): needle is H => {
|
||||
return haystack.includes(needle as any);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
export type VersionedSnapshot<T> = Readonly<{
|
||||
version: number;
|
||||
value: T;
|
||||
}>;
|
||||
|
||||
export class VersionedSnapshotStore<T> {
|
||||
private version = 0;
|
||||
private value: T;
|
||||
private readonly waiters = new Set<
|
||||
(snapshot: VersionedSnapshot<T>) => void
|
||||
>();
|
||||
private readonly subscribers = new Set<
|
||||
(snapshot: VersionedSnapshot<T>) => void
|
||||
>();
|
||||
|
||||
constructor(
|
||||
initialValue: T,
|
||||
private readonly isEqual: (prev: T, next: T) => boolean = Object.is,
|
||||
) {
|
||||
this.value = initialValue;
|
||||
}
|
||||
|
||||
public getSnapshot(): VersionedSnapshot<T> {
|
||||
return { version: this.version, value: this.value };
|
||||
}
|
||||
|
||||
public set(nextValue: T): boolean {
|
||||
if (this.isEqual(this.value, nextValue)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.value = nextValue;
|
||||
this.version += 1;
|
||||
|
||||
const snapshot = this.getSnapshot();
|
||||
|
||||
for (const subscriber of this.subscribers) {
|
||||
subscriber(snapshot);
|
||||
}
|
||||
for (const waiter of this.waiters) {
|
||||
waiter(snapshot);
|
||||
}
|
||||
this.waiters.clear();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public update(updater: (prev: T) => T): boolean {
|
||||
return this.set(updater(this.value));
|
||||
}
|
||||
|
||||
public subscribe(
|
||||
subscriber: (snapshot: VersionedSnapshot<T>) => void,
|
||||
): () => void {
|
||||
this.subscribers.add(subscriber);
|
||||
return () => {
|
||||
this.subscribers.delete(subscriber);
|
||||
};
|
||||
}
|
||||
|
||||
public pull(sinceVersion = -1): Promise<VersionedSnapshot<T>> {
|
||||
if (this.version !== sinceVersion) {
|
||||
return Promise.resolve(this.getSnapshot());
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.waiters.add(resolve);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,12 @@
|
||||
"development": "./dist/dev/index.js",
|
||||
"production": "./dist/prod/index.js",
|
||||
"default": "./dist/prod/index.js"
|
||||
},
|
||||
"./visualdebug": {
|
||||
"types": "./dist/types/element/src/visualdebug.d.ts",
|
||||
"development": "./dist/dev/visualdebug.js",
|
||||
"production": "./dist/prod/visualdebug.js",
|
||||
"default": "./dist/prod/visualdebug.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -438,6 +438,8 @@ export class Scene {
|
||||
options: {
|
||||
informMutation: boolean;
|
||||
isDragging: boolean;
|
||||
isBindingEnabled?: boolean;
|
||||
isMidpointSnappingEnabled?: boolean;
|
||||
} = {
|
||||
informMutation: true,
|
||||
isDragging: false,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Arrowhead, AnyArrowhead } from "./types";
|
||||
|
||||
export const normalizeArrowhead = (
|
||||
arrowhead: AnyArrowhead | null | undefined,
|
||||
): Arrowhead | null => {
|
||||
switch (arrowhead) {
|
||||
case undefined:
|
||||
case null:
|
||||
return null;
|
||||
case "dot":
|
||||
return "circle";
|
||||
case "crowfoot_one":
|
||||
return "cardinality_one";
|
||||
case "crowfoot_many":
|
||||
return "cardinality_many";
|
||||
case "crowfoot_one_or_many":
|
||||
return "cardinality_one_or_many";
|
||||
default:
|
||||
return arrowhead;
|
||||
}
|
||||
};
|
||||
|
||||
export const getArrowheadForPicker = (
|
||||
arrowhead: AnyArrowhead | null | undefined,
|
||||
): Arrowhead | null => {
|
||||
const normalizedArrowhead = normalizeArrowhead(arrowhead);
|
||||
if (normalizedArrowhead === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizedArrowhead;
|
||||
};
|
||||
@@ -0,0 +1,558 @@
|
||||
import { pointDistance, pointFrom, type GlobalPoint } from "@excalidraw/math";
|
||||
import { invariant } from "@excalidraw/common";
|
||||
|
||||
import type { AppState, NullableGridSize } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import {
|
||||
bindBindingElement,
|
||||
calculateFixedPointForNonElbowArrowBinding,
|
||||
FOCUS_POINT_SIZE,
|
||||
getBindingGap,
|
||||
getGlobalFixedPointForBindableElement,
|
||||
isBindingEnabled,
|
||||
maxBindingDistance_simple,
|
||||
unbindBindingElement,
|
||||
updateBoundPoint,
|
||||
} from "../binding";
|
||||
import {
|
||||
isBindableElement,
|
||||
isBindingElement,
|
||||
isElbowArrow,
|
||||
} from "../typeChecks";
|
||||
import { LinearElementEditor } from "../linearElementEditor";
|
||||
import { getHoveredElementForFocusPoint, hitElementItself } from "../collision";
|
||||
import { moveArrowAboveBindable } from "../zindex";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawBindableElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
PointsPositionUpdates,
|
||||
} from "../types";
|
||||
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export const isFocusPointVisible = (
|
||||
focusPoint: GlobalPoint,
|
||||
arrow: ExcalidrawArrowElement,
|
||||
bindableElement: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
appState: {
|
||||
isBindingEnabled: AppState["isBindingEnabled"];
|
||||
zoom: AppState["zoom"];
|
||||
},
|
||||
startOrEnd: "start" | "end",
|
||||
ignoreOverlap = false,
|
||||
): boolean => {
|
||||
// No focus point management for elbow arrows, because elbow arrows
|
||||
// always have their focus point at the arrow point itself
|
||||
if (
|
||||
isElbowArrow(arrow) ||
|
||||
!isBindingEnabled(appState) ||
|
||||
arrow.points.length !== 2
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Avoid showing the focus point indicator if the focus point is essentially
|
||||
// on top of the arrow point it belongs to itself, if not ignoring specifically
|
||||
if (!ignoreOverlap) {
|
||||
const associatedPointIdx =
|
||||
arrow.startBinding?.elementId === bindableElement.id
|
||||
? 0
|
||||
: arrow.points.length - 1;
|
||||
const associatedArrowPoint =
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
arrow,
|
||||
associatedPointIdx,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
if (
|
||||
pointDistance(focusPoint, associatedArrowPoint) <
|
||||
(FOCUS_POINT_SIZE * 1.5) / appState.zoom.value
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const arrowPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
arrow,
|
||||
startOrEnd === "end" ? arrow.points.length - 1 : 0,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
// Check if the focus point is within the element's shape bounds
|
||||
// Endpoint dragging takes precedence
|
||||
return (
|
||||
pointDistance(focusPoint, arrowPoint) >=
|
||||
(FOCUS_POINT_SIZE * 1.5) / appState.zoom.value &&
|
||||
hitElementItself({
|
||||
element: bindableElement,
|
||||
elementsMap,
|
||||
point: focusPoint,
|
||||
threshold: getBindingGap(bindableElement, arrow),
|
||||
overrideShouldTestInside: true,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// Updates the arrow endpoints in "orbit" configuration
|
||||
const focusPointUpdate = (
|
||||
arrow: ExcalidrawArrowElement,
|
||||
bindableElement: ExcalidrawBindableElement | null,
|
||||
isStartBinding: boolean,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
scene: Scene,
|
||||
appState: AppState,
|
||||
switchToInsideBinding: boolean,
|
||||
) => {
|
||||
const pointUpdates = new Map();
|
||||
|
||||
const bindingField = isStartBinding ? "startBinding" : "endBinding";
|
||||
const adjacentBindingField = isStartBinding ? "endBinding" : "startBinding";
|
||||
let currentBinding = arrow[bindingField];
|
||||
let adjacentBinding = arrow[adjacentBindingField];
|
||||
|
||||
// Update the dragged focus point related end
|
||||
if (currentBinding && bindableElement) {
|
||||
// Update the targeted bindings
|
||||
const boundToSameElement =
|
||||
bindableElement &&
|
||||
adjacentBinding &&
|
||||
currentBinding.elementId === adjacentBinding.elementId;
|
||||
if (switchToInsideBinding || boundToSameElement) {
|
||||
currentBinding = {
|
||||
...currentBinding,
|
||||
mode: "inside",
|
||||
};
|
||||
} else {
|
||||
currentBinding = {
|
||||
...currentBinding,
|
||||
mode: "orbit",
|
||||
};
|
||||
}
|
||||
|
||||
const pointIndex = isStartBinding ? 0 : arrow.points.length - 1;
|
||||
const newPoint = updateBoundPoint(
|
||||
arrow,
|
||||
bindingField as "startBinding" | "endBinding",
|
||||
currentBinding,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
true,
|
||||
);
|
||||
|
||||
if (newPoint) {
|
||||
pointUpdates.set(pointIndex, { point: newPoint });
|
||||
}
|
||||
}
|
||||
|
||||
// Also update the adjacent end if it has a binding
|
||||
if (adjacentBinding && adjacentBinding.mode === "orbit") {
|
||||
const adjacentBindableElement = elementsMap.get(
|
||||
adjacentBinding.elementId,
|
||||
) as ExcalidrawBindableElement;
|
||||
|
||||
if (
|
||||
adjacentBindableElement &&
|
||||
isBindableElement(adjacentBindableElement) &&
|
||||
isBindingEnabled(appState)
|
||||
) {
|
||||
// Same shape bound on both ends
|
||||
const boundToSameElementAfterUpdate =
|
||||
bindableElement && adjacentBinding.elementId === bindableElement.id;
|
||||
if (switchToInsideBinding || boundToSameElementAfterUpdate) {
|
||||
adjacentBinding = {
|
||||
...adjacentBinding,
|
||||
mode: "inside",
|
||||
};
|
||||
} else {
|
||||
adjacentBinding = {
|
||||
...adjacentBinding,
|
||||
mode: "orbit",
|
||||
};
|
||||
}
|
||||
|
||||
const adjacentPointIndex = isStartBinding ? arrow.points.length - 1 : 0;
|
||||
const adjacentNewPoint = updateBoundPoint(
|
||||
arrow,
|
||||
adjacentBindingField,
|
||||
adjacentBinding,
|
||||
adjacentBindableElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
if (adjacentNewPoint) {
|
||||
pointUpdates.set(adjacentPointIndex, {
|
||||
point: adjacentNewPoint,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pointUpdates.size > 0) {
|
||||
LinearElementEditor.movePoints(arrow, scene, pointUpdates, {
|
||||
[bindingField]: currentBinding,
|
||||
[adjacentBindingField]: adjacentBinding,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const handleFocusPointDrag = (
|
||||
linearElementEditor: LinearElementEditor,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
pointerCoords: { x: number; y: number },
|
||||
scene: Scene,
|
||||
appState: AppState,
|
||||
gridSize: NullableGridSize,
|
||||
switchToInsideBinding: boolean,
|
||||
) => {
|
||||
const arrow = LinearElementEditor.getElement(
|
||||
linearElementEditor.elementId,
|
||||
elementsMap,
|
||||
) as any;
|
||||
|
||||
// Sanity checks
|
||||
if (
|
||||
!arrow ||
|
||||
!isBindingElement(arrow) ||
|
||||
isElbowArrow(arrow) ||
|
||||
!linearElementEditor.hoveredFocusPointBinding ||
|
||||
!linearElementEditor.draggedFocusPointBinding
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isStartBinding =
|
||||
linearElementEditor.draggedFocusPointBinding === "start";
|
||||
const binding = isStartBinding ? arrow.startBinding : arrow.endBinding;
|
||||
const { x: offsetX, y: offsetY } = linearElementEditor.pointerOffset;
|
||||
const point = pointFrom<GlobalPoint>(
|
||||
pointerCoords.x - offsetX,
|
||||
pointerCoords.y - offsetY,
|
||||
);
|
||||
const bindingField = isStartBinding ? "startBinding" : "endBinding";
|
||||
const hit = getHoveredElementForFocusPoint(
|
||||
point,
|
||||
arrow,
|
||||
scene.getNonDeletedElements(),
|
||||
elementsMap,
|
||||
maxBindingDistance_simple(appState.zoom),
|
||||
);
|
||||
|
||||
// Hovering a bindable element
|
||||
if (hit && isBindingEnabled(appState)) {
|
||||
// Break existing binding if bound to another shape or if binding is disabled
|
||||
if (arrow[bindingField] && hit.id !== binding?.elementId) {
|
||||
unbindBindingElement(
|
||||
arrow,
|
||||
linearElementEditor.draggedFocusPointBinding,
|
||||
scene,
|
||||
);
|
||||
}
|
||||
|
||||
// Handle binding mode switch
|
||||
const newMode =
|
||||
switchToInsideBinding && arrow[bindingField]?.mode === "orbit"
|
||||
? "inside"
|
||||
: !switchToInsideBinding && arrow[bindingField]?.mode === "inside"
|
||||
? "orbit"
|
||||
: null;
|
||||
|
||||
// If no existing binding, create it
|
||||
if (!arrow[bindingField] || newMode) {
|
||||
// Create a new binding if none exists
|
||||
bindBindingElement(
|
||||
arrow,
|
||||
hit,
|
||||
newMode || "orbit",
|
||||
linearElementEditor.draggedFocusPointBinding,
|
||||
scene,
|
||||
point,
|
||||
);
|
||||
}
|
||||
|
||||
// Update the binding's fixed point
|
||||
scene.mutateElement(arrow, {
|
||||
[bindingField]: {
|
||||
...arrow[bindingField],
|
||||
elementId: hit.id,
|
||||
mode: newMode || arrow[bindingField]?.mode || "orbit",
|
||||
...calculateFixedPointForNonElbowArrowBinding(
|
||||
arrow,
|
||||
hit,
|
||||
linearElementEditor.draggedFocusPointBinding,
|
||||
elementsMap,
|
||||
point,
|
||||
),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Not hovering any bindable element, move the arrow endpoint
|
||||
const pointUpdates: PointsPositionUpdates = new Map();
|
||||
const pointIndex = isStartBinding ? 0 : arrow.points.length - 1;
|
||||
pointUpdates.set(pointIndex, {
|
||||
point: LinearElementEditor.createPointAt(
|
||||
arrow,
|
||||
elementsMap,
|
||||
point[0],
|
||||
point[1],
|
||||
gridSize,
|
||||
),
|
||||
});
|
||||
LinearElementEditor.movePoints(arrow, scene, pointUpdates);
|
||||
if (arrow[bindingField]) {
|
||||
unbindBindingElement(arrow, isStartBinding ? "start" : "end", scene);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the arrow endpoints
|
||||
focusPointUpdate(
|
||||
arrow,
|
||||
hit,
|
||||
isStartBinding,
|
||||
elementsMap,
|
||||
scene,
|
||||
appState,
|
||||
switchToInsideBinding,
|
||||
);
|
||||
|
||||
if (hit && isBindingEnabled(appState)) {
|
||||
moveArrowAboveBindable(
|
||||
point,
|
||||
arrow,
|
||||
scene.getElementsIncludingDeleted(),
|
||||
elementsMap,
|
||||
scene,
|
||||
hit,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleFocusPointPointerDown = (
|
||||
arrow: ExcalidrawArrowElement,
|
||||
pointerDownState: { origin: { x: number; y: number } },
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
appState: AppState,
|
||||
): {
|
||||
hitFocusPoint: "start" | "end" | null;
|
||||
pointerOffset: { x: number; y: number };
|
||||
} => {
|
||||
const pointerPos = pointFrom(
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
);
|
||||
const hitThreshold = (FOCUS_POINT_SIZE * 1.5) / appState.zoom.value;
|
||||
|
||||
// Check start binding focus point
|
||||
if (arrow.startBinding?.elementId) {
|
||||
const bindableElement = elementsMap.get(arrow.startBinding.elementId);
|
||||
if (
|
||||
bindableElement &&
|
||||
isBindableElement(bindableElement) &&
|
||||
!bindableElement.isDeleted
|
||||
) {
|
||||
const focusPoint = getGlobalFixedPointForBindableElement(
|
||||
arrow.startBinding.fixedPoint,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
);
|
||||
if (
|
||||
isFocusPointVisible(
|
||||
focusPoint,
|
||||
arrow,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
appState,
|
||||
"start",
|
||||
) &&
|
||||
pointDistance(pointerPos, focusPoint) <= hitThreshold
|
||||
) {
|
||||
return {
|
||||
hitFocusPoint: "start",
|
||||
pointerOffset: {
|
||||
x: pointerPos[0] - focusPoint[0],
|
||||
y: pointerPos[1] - focusPoint[1],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check end binding focus point (only if start not already hit)
|
||||
if (arrow.endBinding?.elementId) {
|
||||
const bindableElement = elementsMap.get(arrow.endBinding.elementId);
|
||||
if (
|
||||
bindableElement &&
|
||||
isBindableElement(bindableElement) &&
|
||||
!bindableElement.isDeleted
|
||||
) {
|
||||
const focusPoint = getGlobalFixedPointForBindableElement(
|
||||
arrow.endBinding.fixedPoint,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
);
|
||||
if (
|
||||
isFocusPointVisible(
|
||||
focusPoint,
|
||||
arrow,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
appState,
|
||||
"end",
|
||||
) &&
|
||||
pointDistance(pointerPos, focusPoint) <= hitThreshold
|
||||
) {
|
||||
return {
|
||||
hitFocusPoint: "end",
|
||||
pointerOffset: {
|
||||
x: pointerPos[0] - focusPoint[0],
|
||||
y: pointerPos[1] - focusPoint[1],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hitFocusPoint: null,
|
||||
pointerOffset: { x: 0, y: 0 },
|
||||
};
|
||||
};
|
||||
|
||||
export const handleFocusPointPointerUp = (
|
||||
linearElementEditor: LinearElementEditor,
|
||||
scene: Scene,
|
||||
) => {
|
||||
invariant(
|
||||
linearElementEditor.draggedFocusPointBinding,
|
||||
"Must have a dragged focus point at pointer release",
|
||||
);
|
||||
|
||||
const arrow = LinearElementEditor.getElement<ExcalidrawArrowElement>(
|
||||
linearElementEditor.elementId,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
invariant(arrow, "Arrow must be in the scene");
|
||||
|
||||
// Clean up
|
||||
const bindingKey =
|
||||
linearElementEditor.draggedFocusPointBinding === "start"
|
||||
? "startBinding"
|
||||
: "endBinding";
|
||||
const otherBindingKey =
|
||||
linearElementEditor.draggedFocusPointBinding === "start"
|
||||
? "endBinding"
|
||||
: "startBinding";
|
||||
const boundElementId = arrow[bindingKey]?.elementId;
|
||||
const otherBoundElementId = arrow[otherBindingKey]?.elementId;
|
||||
const oldBoundElement =
|
||||
boundElementId &&
|
||||
scene
|
||||
.getNonDeletedElements()
|
||||
.find(
|
||||
(element) =>
|
||||
element.id !== boundElementId &&
|
||||
element.id !== otherBoundElementId &&
|
||||
isBindableElement(element) &&
|
||||
element.boundElements?.find(({ id }) => id === arrow.id),
|
||||
);
|
||||
if (oldBoundElement) {
|
||||
scene.mutateElement(oldBoundElement, {
|
||||
boundElements: oldBoundElement.boundElements?.filter(
|
||||
({ id }) => id !== arrow.id,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Record the new bound element
|
||||
const boundElement =
|
||||
boundElementId && scene.getNonDeletedElementsMap().get(boundElementId);
|
||||
if (boundElement) {
|
||||
scene.mutateElement(boundElement, {
|
||||
boundElements: [
|
||||
...(boundElement.boundElements || [])?.filter(
|
||||
({ id }) => id !== arrow.id,
|
||||
),
|
||||
{
|
||||
id: arrow.id,
|
||||
type: "arrow",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const handleFocusPointHover = (
|
||||
arrow: ExcalidrawArrowElement,
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
scene: Scene,
|
||||
appState: AppState,
|
||||
): "start" | "end" | null => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const pointerPos = pointFrom(scenePointerX, scenePointerY);
|
||||
const hitThreshold = (FOCUS_POINT_SIZE * 1.5) / appState.zoom.value;
|
||||
|
||||
// Check start binding focus point
|
||||
if (arrow.startBinding?.elementId) {
|
||||
const bindableElement = elementsMap.get(arrow.startBinding.elementId);
|
||||
if (
|
||||
bindableElement &&
|
||||
isBindableElement(bindableElement) &&
|
||||
!bindableElement.isDeleted
|
||||
) {
|
||||
const focusPoint = getGlobalFixedPointForBindableElement(
|
||||
arrow.startBinding.fixedPoint,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
);
|
||||
if (
|
||||
isFocusPointVisible(
|
||||
focusPoint,
|
||||
arrow,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
appState,
|
||||
"start",
|
||||
) &&
|
||||
pointDistance(pointerPos, focusPoint) <= hitThreshold
|
||||
) {
|
||||
return "start";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check end binding focus point (only if start not already hovered)
|
||||
if (arrow.endBinding?.elementId) {
|
||||
const bindableElement = elementsMap.get(arrow.endBinding.elementId);
|
||||
if (
|
||||
bindableElement &&
|
||||
isBindableElement(bindableElement) &&
|
||||
!bindableElement.isDeleted
|
||||
) {
|
||||
const focusPoint = getGlobalFixedPointForBindableElement(
|
||||
arrow.endBinding.fixedPoint,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
);
|
||||
if (
|
||||
isFocusPointVisible(
|
||||
focusPoint,
|
||||
arrow,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
appState,
|
||||
"end",
|
||||
) &&
|
||||
pointDistance(pointerPos, focusPoint) <= hitThreshold
|
||||
) {
|
||||
return "end";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { App } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { LinearElementEditor } from "../linearElementEditor";
|
||||
|
||||
import { handleFocusPointDrag } from "./focus";
|
||||
|
||||
export const maybeHandleArrowPointlikeDrag = ({
|
||||
app,
|
||||
event,
|
||||
}: {
|
||||
app: App;
|
||||
event: KeyboardEvent | React.KeyboardEvent<Element> | PointerEvent;
|
||||
}): boolean => {
|
||||
const appState = app.state;
|
||||
if (appState.selectedLinearElement && app.lastPointerMoveCoords) {
|
||||
// Update focus point status if the binding mode is changing
|
||||
if (appState.selectedLinearElement.draggedFocusPointBinding) {
|
||||
handleFocusPointDrag(
|
||||
appState.selectedLinearElement,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
app.lastPointerMoveCoords,
|
||||
app.scene,
|
||||
appState,
|
||||
app.getEffectiveGridSize(),
|
||||
event.altKey,
|
||||
);
|
||||
return true;
|
||||
} else if (
|
||||
appState.selectedLinearElement.hoverPointIndex !== null &&
|
||||
app.lastPointerMoveEvent &&
|
||||
appState.selectedLinearElement.initialState.lastClickedPoint >= 0 &&
|
||||
appState.selectedLinearElement.isDragging
|
||||
) {
|
||||
LinearElementEditor.handlePointDragging(
|
||||
app.lastPointerMoveEvent,
|
||||
app,
|
||||
app.lastPointerMoveCoords.x,
|
||||
app.lastPointerMoveCoords.y,
|
||||
appState.selectedLinearElement,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
+383
-216
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
KEYS,
|
||||
arrayToMap,
|
||||
getFeatureFlag,
|
||||
invariant,
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
pointFrom,
|
||||
pointFromVector,
|
||||
pointRotateRads,
|
||||
pointsEqual,
|
||||
vectorFromPoint,
|
||||
vectorNormalize,
|
||||
vectorScale,
|
||||
@@ -26,14 +26,11 @@ import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
import type { MapEntry, Mutable } from "@excalidraw/common/utility-types";
|
||||
import type { Bounds } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
doBoundsIntersect,
|
||||
getCenterForBounds,
|
||||
getElementBounds,
|
||||
} from "./bounds";
|
||||
import { getCenterForBounds } from "./bounds";
|
||||
import {
|
||||
getAllHoveredElementAtPoint,
|
||||
getHoveredElementForBinding,
|
||||
hitElementItself,
|
||||
intersectElementWithLineSegment,
|
||||
isBindableElementInsideOtherBindable,
|
||||
isPointInElement,
|
||||
@@ -112,8 +109,10 @@ export type BindingStrategy =
|
||||
*
|
||||
* IMPORTANT: currently must be > 0 (this also applies to the computed gap)
|
||||
*/
|
||||
export const BASE_BINDING_GAP = 10;
|
||||
export const BASE_BINDING_GAP = 5;
|
||||
export const BASE_BINDING_GAP_ELBOW = 5;
|
||||
export const BASE_ARROW_MIN_LENGTH = 10;
|
||||
export const FOCUS_POINT_SIZE = 10 / 1.5;
|
||||
|
||||
export const getBindingGap = (
|
||||
bindTarget: ExcalidrawBindableElement,
|
||||
@@ -137,13 +136,9 @@ export const maxBindingDistance_simple = (zoom?: AppState["zoom"]): number => {
|
||||
);
|
||||
};
|
||||
|
||||
export const shouldEnableBindingForPointerEvent = (
|
||||
event: React.PointerEvent<HTMLElement>,
|
||||
) => {
|
||||
return !event[KEYS.CTRL_OR_CMD];
|
||||
};
|
||||
|
||||
export const isBindingEnabled = (appState: AppState): boolean => {
|
||||
export const isBindingEnabled = (appState: {
|
||||
isBindingEnabled: AppState["isBindingEnabled"];
|
||||
}): boolean => {
|
||||
return appState.isBindingEnabled;
|
||||
};
|
||||
|
||||
@@ -175,8 +170,20 @@ export const bindOrUnbindBindingElement = (
|
||||
},
|
||||
);
|
||||
|
||||
bindOrUnbindBindingElementEdge(arrow, start, "start", scene);
|
||||
bindOrUnbindBindingElementEdge(arrow, end, "end", scene);
|
||||
bindOrUnbindBindingElementEdge(
|
||||
arrow,
|
||||
start,
|
||||
"start",
|
||||
scene,
|
||||
appState.isBindingEnabled,
|
||||
);
|
||||
bindOrUnbindBindingElementEdge(
|
||||
arrow,
|
||||
end,
|
||||
"end",
|
||||
scene,
|
||||
appState.isBindingEnabled,
|
||||
);
|
||||
if (start.focusPoint || end.focusPoint) {
|
||||
// If the strategy dictates a focus point override, then
|
||||
// update the arrow points to point to the focus point.
|
||||
@@ -219,12 +226,21 @@ const bindOrUnbindBindingElementEdge = (
|
||||
{ mode, element, focusPoint }: BindingStrategy,
|
||||
startOrEnd: "start" | "end",
|
||||
scene: Scene,
|
||||
shouldSnapToOutline = true,
|
||||
): void => {
|
||||
if (mode === null) {
|
||||
// null means break the binding
|
||||
unbindBindingElement(arrow, startOrEnd, scene);
|
||||
} else if (mode !== undefined) {
|
||||
bindBindingElement(arrow, element, mode, startOrEnd, scene, focusPoint);
|
||||
bindBindingElement(
|
||||
arrow,
|
||||
element,
|
||||
mode,
|
||||
startOrEnd,
|
||||
scene,
|
||||
focusPoint,
|
||||
shouldSnapToOutline,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -257,7 +273,7 @@ const bindingStrategyForElbowArrowEndpointDragging = (
|
||||
globalPoint,
|
||||
elements,
|
||||
elementsMap,
|
||||
(element) => maxBindingDistance_simple(zoom),
|
||||
maxBindingDistance_simple(zoom),
|
||||
);
|
||||
|
||||
const current = hit
|
||||
@@ -682,7 +698,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
|
||||
globalPoint,
|
||||
elements,
|
||||
elementsMap,
|
||||
(e) => maxBindingDistance_simple(appState.zoom),
|
||||
maxBindingDistance_simple(appState.zoom),
|
||||
);
|
||||
const pointInElement =
|
||||
hit &&
|
||||
@@ -709,7 +725,13 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
|
||||
const otherFocusPointIsInElement =
|
||||
otherBindableElement &&
|
||||
otherFocusPoint &&
|
||||
isPointInElement(otherFocusPoint, otherBindableElement, elementsMap);
|
||||
hitElementItself({
|
||||
point: otherFocusPoint,
|
||||
element: otherBindableElement,
|
||||
elementsMap,
|
||||
threshold: 0,
|
||||
overrideShouldTestInside: true,
|
||||
});
|
||||
|
||||
// Handle outside-outside binding to the same element
|
||||
if (otherBinding && otherBinding.elementId === hit?.id) {
|
||||
@@ -789,6 +811,8 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
|
||||
hit,
|
||||
startDragged ? "start" : "end",
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
appState.isMidpointSnappingEnabled,
|
||||
) || globalPoint,
|
||||
}
|
||||
: { mode: null };
|
||||
@@ -798,11 +822,24 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
|
||||
startDragged ? -1 : 0,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
const other: BindingStrategy =
|
||||
const pointIsCloseToOtherElement =
|
||||
otherFocusPoint &&
|
||||
otherBindableElement &&
|
||||
!otherFocusPointIsInElement &&
|
||||
appState.selectedLinearElement?.initialState.altFocusPoint
|
||||
hitElementItself({
|
||||
point: globalPoint,
|
||||
element: otherBindableElement,
|
||||
elementsMap,
|
||||
threshold: maxBindingDistance_simple(appState.zoom),
|
||||
overrideShouldTestInside: true,
|
||||
});
|
||||
const otherNeverOverride = opts?.newArrow
|
||||
? appState.selectedLinearElement?.initialState.arrowStartIsInside
|
||||
: otherBinding?.mode === "inside";
|
||||
const other: BindingStrategy = !otherNeverOverride
|
||||
? otherBindableElement &&
|
||||
!otherFocusPointIsInElement &&
|
||||
!pointIsCloseToOtherElement &&
|
||||
appState.selectedLinearElement?.initialState.altFocusPoint
|
||||
? {
|
||||
mode: "orbit",
|
||||
element: otherBindableElement,
|
||||
@@ -819,9 +856,12 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
|
||||
otherBindableElement,
|
||||
startDragged ? "end" : "start",
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
appState.isMidpointSnappingEnabled,
|
||||
) || otherEndpoint,
|
||||
}
|
||||
: { mode: undefined };
|
||||
: { mode: undefined }
|
||||
: { mode: undefined };
|
||||
|
||||
return {
|
||||
start: startDragged ? current : other,
|
||||
@@ -981,6 +1021,7 @@ export const bindBindingElement = (
|
||||
startOrEnd: "start" | "end",
|
||||
scene: Scene,
|
||||
focusPoint?: GlobalPoint,
|
||||
shouldSnapToOutline = true,
|
||||
): void => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
|
||||
@@ -995,6 +1036,7 @@ export const bindBindingElement = (
|
||||
hoveredElement,
|
||||
startOrEnd,
|
||||
elementsMap,
|
||||
shouldSnapToOutline,
|
||||
),
|
||||
};
|
||||
} else {
|
||||
@@ -1085,7 +1127,7 @@ export const updateBoundElements = (
|
||||
});
|
||||
}
|
||||
|
||||
boundElementsVisitor(elementsMap, changedElement, (element) => {
|
||||
const visitor = (element: ExcalidrawElement | undefined) => {
|
||||
if (!isArrowElement(element) || element.isDeleted) {
|
||||
return;
|
||||
}
|
||||
@@ -1157,7 +1199,71 @@ export const updateBoundElements = (
|
||||
if (boundText && !boundText.isDeleted) {
|
||||
handleBindTextResize(element, scene, false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
boundElementsVisitor(elementsMap, changedElement, visitor);
|
||||
};
|
||||
|
||||
const updateArrowBindings = (
|
||||
latestElement: ExcalidrawArrowElement,
|
||||
startOrEnd: "startBinding" | "endBinding",
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
scene: Scene,
|
||||
appState: AppState,
|
||||
) => {
|
||||
invariant(
|
||||
!isElbowArrow(latestElement),
|
||||
"Elbow arrows not supported for indirect updates",
|
||||
);
|
||||
|
||||
const binding = latestElement[startOrEnd];
|
||||
const bindableElement =
|
||||
binding &&
|
||||
(elementsMap.get(binding.elementId) as ExcalidrawBindableElement);
|
||||
const point = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
latestElement,
|
||||
startOrEnd === "startBinding" ? 0 : -1,
|
||||
elementsMap,
|
||||
);
|
||||
const hit =
|
||||
bindableElement &&
|
||||
hitElementItself({
|
||||
element: bindableElement,
|
||||
point,
|
||||
elementsMap,
|
||||
threshold: maxBindingDistance_simple(appState.zoom),
|
||||
});
|
||||
const strategyName = startOrEnd === "startBinding" ? "start" : "end";
|
||||
unbindBindingElement(latestElement, strategyName, scene);
|
||||
if (hit) {
|
||||
const pointIdx =
|
||||
startOrEnd === "startBinding" ? 0 : latestElement.points.length - 1;
|
||||
const localPoint = latestElement.points[pointIdx];
|
||||
const strategy =
|
||||
getBindingStrategyForDraggingBindingElementEndpoints_simple(
|
||||
latestElement,
|
||||
new Map([[pointIdx, { point: localPoint }]]),
|
||||
point[0],
|
||||
point[1],
|
||||
elementsMap,
|
||||
scene.getNonDeletedElements(),
|
||||
appState,
|
||||
);
|
||||
if (
|
||||
strategy[strategyName] &&
|
||||
strategy[strategyName].element?.id === bindableElement.id &&
|
||||
strategy[strategyName].mode
|
||||
) {
|
||||
bindBindingElement(
|
||||
latestElement,
|
||||
bindableElement,
|
||||
strategy[strategyName].mode,
|
||||
strategyName,
|
||||
scene,
|
||||
strategy[strategyName].focusPoint,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const updateBindings = (
|
||||
@@ -1170,14 +1276,27 @@ export const updateBindings = (
|
||||
},
|
||||
) => {
|
||||
if (isArrowElement(latestElement)) {
|
||||
bindOrUnbindBindingElement(
|
||||
latestElement,
|
||||
new Map(),
|
||||
Infinity,
|
||||
Infinity,
|
||||
scene,
|
||||
appState,
|
||||
);
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
|
||||
if (latestElement.startBinding) {
|
||||
updateArrowBindings(
|
||||
latestElement,
|
||||
"startBinding",
|
||||
elementsMap,
|
||||
scene,
|
||||
appState,
|
||||
);
|
||||
}
|
||||
|
||||
if (latestElement.endBinding) {
|
||||
updateArrowBindings(
|
||||
latestElement,
|
||||
"endBinding",
|
||||
elementsMap,
|
||||
scene,
|
||||
appState,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
updateBoundElements(latestElement, scene, {
|
||||
...options,
|
||||
@@ -1251,6 +1370,7 @@ export const bindPointToSnapToElementOutline = (
|
||||
startOrEnd: "start" | "end",
|
||||
elementsMap: ElementsMap,
|
||||
customIntersector?: LineSegment<GlobalPoint>,
|
||||
isMidpointSnappingEnabled = true,
|
||||
): GlobalPoint => {
|
||||
const elbowed = isElbowArrow(arrowElement);
|
||||
const point = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
@@ -1290,15 +1410,13 @@ export const bindPointToSnapToElementOutline = (
|
||||
const isHorizontal = headingIsHorizontal(
|
||||
headingForPointFromElement(bindableElement, aabb, point),
|
||||
);
|
||||
const snapPoint = snapToMid(
|
||||
arrowElement,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
edgePoint,
|
||||
);
|
||||
const snapPoint = isMidpointSnappingEnabled
|
||||
? snapToMid(bindableElement, elementsMap, edgePoint, 0.05, arrowElement)
|
||||
: undefined;
|
||||
const resolved = snapPoint || point;
|
||||
const otherPoint = pointFrom<GlobalPoint>(
|
||||
isHorizontal ? bindableCenter[0] : snapPoint[0],
|
||||
!isHorizontal ? bindableCenter[1] : snapPoint[1],
|
||||
isHorizontal ? bindableCenter[0] : resolved[0],
|
||||
!isHorizontal ? bindableCenter[1] : resolved[1],
|
||||
);
|
||||
const intersector =
|
||||
customIntersector ??
|
||||
@@ -1306,7 +1424,7 @@ export const bindPointToSnapToElementOutline = (
|
||||
otherPoint,
|
||||
pointFromVector(
|
||||
vectorScale(
|
||||
vectorNormalize(vectorFromPoint(snapPoint, otherPoint)),
|
||||
vectorNormalize(vectorFromPoint(resolved, otherPoint)),
|
||||
Math.max(bindableElement.width, bindableElement.height) * 2,
|
||||
),
|
||||
otherPoint,
|
||||
@@ -1321,14 +1439,14 @@ export const bindPointToSnapToElementOutline = (
|
||||
|
||||
if (!intersection) {
|
||||
const anotherPoint = pointFrom<GlobalPoint>(
|
||||
!isHorizontal ? bindableCenter[0] : snapPoint[0],
|
||||
isHorizontal ? bindableCenter[1] : snapPoint[1],
|
||||
!isHorizontal ? bindableCenter[0] : resolved[0],
|
||||
isHorizontal ? bindableCenter[1] : resolved[1],
|
||||
);
|
||||
const anotherIntersector = lineSegment(
|
||||
anotherPoint,
|
||||
pointFromVector(
|
||||
vectorScale(
|
||||
vectorNormalize(vectorFromPoint(snapPoint, anotherPoint)),
|
||||
vectorNormalize(vectorFromPoint(resolved, anotherPoint)),
|
||||
Math.max(bindableElement.width, bindableElement.height) * 2,
|
||||
),
|
||||
anotherPoint,
|
||||
@@ -1475,18 +1593,18 @@ export const avoidRectangularCorner = (
|
||||
return p;
|
||||
};
|
||||
|
||||
const snapToMid = (
|
||||
arrowElement: ExcalidrawArrowElement,
|
||||
export const snapToMid = (
|
||||
bindTarget: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
p: GlobalPoint,
|
||||
tolerance: number = 0.05,
|
||||
): GlobalPoint => {
|
||||
arrowElement?: ExcalidrawArrowElement,
|
||||
): GlobalPoint | undefined => {
|
||||
const { x, y, width, height, angle } = bindTarget;
|
||||
const center = elementCenterPoint(bindTarget, elementsMap, -0.1, -0.1);
|
||||
const nonRotated = pointRotateRads(p, center, -angle as Radians);
|
||||
|
||||
const bindingGap = getBindingGap(bindTarget, arrowElement);
|
||||
const bindingGap = arrowElement ? getBindingGap(bindTarget, arrowElement) : 0;
|
||||
|
||||
// snap-to-center point is adaptive to element size, but we don't want to go
|
||||
// above and below certain px distance
|
||||
@@ -1495,7 +1613,7 @@ const snapToMid = (
|
||||
|
||||
// Too close to the center makes it hard to resolve direction precisely
|
||||
if (pointDistance(center, nonRotated) < bindingGap) {
|
||||
return p;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -1504,8 +1622,8 @@ const snapToMid = (
|
||||
nonRotated[1] < center[1] + verticalThreshold
|
||||
) {
|
||||
// LEFT
|
||||
return pointRotateRads<GlobalPoint>(
|
||||
pointFrom(x - bindingGap, center[1]),
|
||||
return pointRotateRads(
|
||||
pointFrom<GlobalPoint>(x - bindingGap, center[1]),
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
@@ -1515,7 +1633,11 @@ const snapToMid = (
|
||||
nonRotated[0] < center[0] + horizontalThreshold
|
||||
) {
|
||||
// TOP
|
||||
return pointRotateRads(pointFrom(center[0], y - bindingGap), center, angle);
|
||||
return pointRotateRads(
|
||||
pointFrom<GlobalPoint>(center[0], y - bindingGap),
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
} else if (
|
||||
nonRotated[0] >= x + width / 2 &&
|
||||
nonRotated[1] > center[1] - verticalThreshold &&
|
||||
@@ -1523,7 +1645,7 @@ const snapToMid = (
|
||||
) {
|
||||
// RIGHT
|
||||
return pointRotateRads(
|
||||
pointFrom(x + width + bindingGap, center[1]),
|
||||
pointFrom<GlobalPoint>(x + width + bindingGap, center[1]),
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
@@ -1534,7 +1656,7 @@ const snapToMid = (
|
||||
) {
|
||||
// DOWN
|
||||
return pointRotateRads(
|
||||
pointFrom(center[0], y + height + bindingGap),
|
||||
pointFrom<GlobalPoint>(center[0], y + height + bindingGap),
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
@@ -1583,13 +1705,44 @@ const snapToMid = (
|
||||
}
|
||||
}
|
||||
|
||||
return p;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const compareElementArea = (
|
||||
a: ExcalidrawBindableElement,
|
||||
b: ExcalidrawBindableElement,
|
||||
) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2);
|
||||
const extractBinding = (
|
||||
arrow: ExcalidrawArrowElement,
|
||||
startOrEnd: "startBinding" | "endBinding",
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
const binding = arrow[startOrEnd];
|
||||
if (!binding) {
|
||||
return {
|
||||
element: null,
|
||||
fixedPoint: null,
|
||||
focusPoint: null,
|
||||
binding,
|
||||
mode: null,
|
||||
};
|
||||
}
|
||||
|
||||
const element = elementsMap.get(
|
||||
binding.elementId,
|
||||
) as ExcalidrawBindableElement;
|
||||
|
||||
return {
|
||||
element,
|
||||
fixedPoint: binding.fixedPoint,
|
||||
focusPoint: getGlobalFixedPointForBindableElement(
|
||||
normalizeFixedPoint(binding.fixedPoint),
|
||||
element,
|
||||
elementsMap,
|
||||
),
|
||||
binding,
|
||||
mode: binding.mode,
|
||||
};
|
||||
};
|
||||
|
||||
const elementArea = (element: ExcalidrawBindableElement) =>
|
||||
element.width * element.height;
|
||||
|
||||
export const updateBoundPoint = (
|
||||
arrow: NonDeleted<ExcalidrawArrowElement>,
|
||||
@@ -1597,162 +1750,154 @@ export const updateBoundPoint = (
|
||||
binding: FixedPointBinding | null | undefined,
|
||||
bindableElement: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
customIntersector?: LineSegment<GlobalPoint>,
|
||||
dragging?: boolean,
|
||||
): LocalPoint | null => {
|
||||
if (
|
||||
binding == null ||
|
||||
// We only need to update the other end if this is a 2 point line element
|
||||
(binding.elementId !== bindableElement.id && arrow.points.length > 2)
|
||||
(binding.elementId !== bindableElement.id && arrow.points.length > 2) ||
|
||||
// Initial arrow created on pointer down needs to not update the points
|
||||
pointsEqual(
|
||||
arrow.points[arrow.points.length - 1],
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const global = getGlobalFixedPointForBindableElement(
|
||||
const focusPoint = getGlobalFixedPointForBindableElement(
|
||||
normalizeFixedPoint(binding.fixedPoint),
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
);
|
||||
const pointIndex =
|
||||
startOrEnd === "startBinding" ? 0 : arrow.points.length - 1;
|
||||
const elbowed = isElbowArrow(arrow);
|
||||
const otherBinding =
|
||||
startOrEnd === "startBinding" ? arrow.endBinding : arrow.startBinding;
|
||||
const otherBindableElement =
|
||||
otherBinding &&
|
||||
(elementsMap.get(otherBinding.elementId)! as ExcalidrawBindableElement);
|
||||
const bounds = getElementBounds(bindableElement, elementsMap);
|
||||
const otherBounds =
|
||||
otherBindableElement && getElementBounds(otherBindableElement, elementsMap);
|
||||
const isLargerThanOther =
|
||||
otherBindableElement &&
|
||||
compareElementArea(bindableElement, otherBindableElement) <
|
||||
// if both shapes the same size, pretend the other is larger
|
||||
(startOrEnd === "endBinding" ? 1 : 0);
|
||||
const isOverlapping = otherBounds && doBoundsIntersect(bounds, otherBounds);
|
||||
|
||||
// GOAL: If the arrow becomes too short, we want to jump the arrow endpoints
|
||||
// to the exact focus points on the elements.
|
||||
// INTUITION: We're not interested in the exacts length of the arrow (which
|
||||
// will change if we change where we route it), we want to know the length of
|
||||
// the part which lies outside of both shapes and consider that as a trigger
|
||||
// to change where we point the arrow. Avoids jumping the arrow in and out
|
||||
// at every frame.
|
||||
let arrowTooShort = false;
|
||||
if (
|
||||
!isOverlapping &&
|
||||
!elbowed &&
|
||||
arrow.startBinding &&
|
||||
arrow.endBinding &&
|
||||
otherBindableElement &&
|
||||
arrow.points.length === 2
|
||||
) {
|
||||
const startFocusPoint = getGlobalFixedPointForBindableElement(
|
||||
arrow.startBinding.fixedPoint,
|
||||
startOrEnd === "startBinding" ? bindableElement : otherBindableElement,
|
||||
elementsMap,
|
||||
);
|
||||
const endFocusPoint = getGlobalFixedPointForBindableElement(
|
||||
arrow.endBinding.fixedPoint,
|
||||
startOrEnd === "endBinding" ? bindableElement : otherBindableElement,
|
||||
elementsMap,
|
||||
);
|
||||
const segment = lineSegment(startFocusPoint, endFocusPoint);
|
||||
const startIntersection = intersectElementWithLineSegment(
|
||||
startOrEnd === "endBinding" ? bindableElement : otherBindableElement,
|
||||
elementsMap,
|
||||
segment,
|
||||
0,
|
||||
true,
|
||||
);
|
||||
const endIntersection = intersectElementWithLineSegment(
|
||||
startOrEnd === "startBinding" ? bindableElement : otherBindableElement,
|
||||
elementsMap,
|
||||
segment,
|
||||
0,
|
||||
true,
|
||||
);
|
||||
if (startIntersection.length > 0 && endIntersection.length > 0) {
|
||||
const len = pointDistance(startIntersection[0], endIntersection[0]);
|
||||
arrowTooShort = len < 40;
|
||||
}
|
||||
}
|
||||
|
||||
const isNested = (arrowTooShort || isOverlapping) && isLargerThanOther;
|
||||
|
||||
let _customIntersector = customIntersector;
|
||||
if (!elbowed && !_customIntersector) {
|
||||
const [x1, y1, x2, y2] = LinearElementEditor.getElementAbsoluteCoords(
|
||||
// 0. Short-circuit for inside binding as it doesn't require any
|
||||
// calculations and is not affected by other bindings
|
||||
if (binding.mode === "inside") {
|
||||
return LinearElementEditor.createPointAt(
|
||||
arrow,
|
||||
elementsMap,
|
||||
);
|
||||
const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
|
||||
const edgePoint = isRectanguloidElement(bindableElement)
|
||||
? avoidRectangularCorner(arrow, bindableElement, elementsMap, global)
|
||||
: global;
|
||||
const adjacentPoint = pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
arrow.x +
|
||||
arrow.points[pointIndex === 0 ? 1 : arrow.points.length - 2][0],
|
||||
arrow.y +
|
||||
arrow.points[pointIndex === 0 ? 1 : arrow.points.length - 2][1],
|
||||
),
|
||||
center,
|
||||
arrow.angle as Radians,
|
||||
);
|
||||
const bindingGap = getBindingGap(bindableElement, arrow);
|
||||
const halfVector = vectorScale(
|
||||
vectorNormalize(vectorFromPoint(edgePoint, adjacentPoint)),
|
||||
pointDistance(edgePoint, adjacentPoint) +
|
||||
Math.max(bindableElement.width, bindableElement.height) +
|
||||
bindingGap * 2,
|
||||
);
|
||||
_customIntersector = lineSegment(
|
||||
pointFromVector(halfVector, adjacentPoint),
|
||||
pointFromVector(vectorScale(halfVector, -1), adjacentPoint),
|
||||
focusPoint[0],
|
||||
focusPoint[1],
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
const maybeOutlineGlobal =
|
||||
binding.mode === "orbit" && bindableElement
|
||||
? isNested
|
||||
? global
|
||||
: bindPointToSnapToElementOutline(
|
||||
{
|
||||
...arrow,
|
||||
points: [
|
||||
pointIndex === 0
|
||||
? LinearElementEditor.createPointAt(
|
||||
arrow,
|
||||
elementsMap,
|
||||
global[0],
|
||||
global[1],
|
||||
null,
|
||||
)
|
||||
: arrow.points[0],
|
||||
...arrow.points.slice(1, -1),
|
||||
pointIndex === arrow.points.length - 1
|
||||
? LinearElementEditor.createPointAt(
|
||||
arrow,
|
||||
elementsMap,
|
||||
global[0],
|
||||
global[1],
|
||||
null,
|
||||
)
|
||||
: arrow.points[arrow.points.length - 1],
|
||||
],
|
||||
},
|
||||
bindableElement,
|
||||
pointIndex === 0 ? "start" : "end",
|
||||
elementsMap,
|
||||
_customIntersector,
|
||||
)
|
||||
: global;
|
||||
const { element: otherBindable, focusPoint: otherFocusPoint } =
|
||||
extractBinding(
|
||||
arrow,
|
||||
startOrEnd === "startBinding" ? "endBinding" : "startBinding",
|
||||
elementsMap,
|
||||
);
|
||||
const otherArrowPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
arrow,
|
||||
startOrEnd === "startBinding" ? 1 : -2,
|
||||
elementsMap,
|
||||
);
|
||||
const otherFocusPointOrArrowPoint =
|
||||
arrow.points.length === 2
|
||||
? otherFocusPoint || otherArrowPoint
|
||||
: otherArrowPoint;
|
||||
const intersector =
|
||||
otherFocusPointOrArrowPoint &&
|
||||
lineSegment(focusPoint, otherFocusPointOrArrowPoint);
|
||||
const otherOutlinePoint =
|
||||
otherBindable &&
|
||||
intersector &&
|
||||
intersectElementWithLineSegment(
|
||||
otherBindable,
|
||||
elementsMap,
|
||||
intersector,
|
||||
getBindingGap(otherBindable, arrow),
|
||||
).sort(
|
||||
(a, b) => pointDistanceSq(a, focusPoint) - pointDistanceSq(b, focusPoint),
|
||||
)[0];
|
||||
const outlinePoint =
|
||||
intersector &&
|
||||
intersectElementWithLineSegment(
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
intersector,
|
||||
getBindingGap(bindableElement, arrow),
|
||||
).sort(
|
||||
(a, b) =>
|
||||
pointDistanceSq(a, otherFocusPointOrArrowPoint) -
|
||||
pointDistanceSq(b, otherFocusPointOrArrowPoint),
|
||||
)[0];
|
||||
const startHasArrowhead = arrow.startArrowhead !== null;
|
||||
const endHasArrowhead = arrow.endArrowhead !== null;
|
||||
const resolvedTarget =
|
||||
(!startHasArrowhead && !endHasArrowhead) ||
|
||||
(startOrEnd === "startBinding" && startHasArrowhead) ||
|
||||
(startOrEnd === "endBinding" && endHasArrowhead)
|
||||
? focusPoint
|
||||
: outlinePoint || focusPoint;
|
||||
|
||||
// 1. Handle case when the outline point (or focus point) is inside
|
||||
// the other shape by short-circuiting to the focus point, otherwise
|
||||
// the arrow would invert
|
||||
if (
|
||||
otherBindable &&
|
||||
outlinePoint &&
|
||||
!dragging &&
|
||||
// Arbitrary threshold to handle wireframing use cases
|
||||
elementArea(otherBindable) < elementArea(bindableElement) * 2 &&
|
||||
hitElementItself({
|
||||
element: otherBindable,
|
||||
point: outlinePoint,
|
||||
elementsMap,
|
||||
threshold: getBindingGap(otherBindable, arrow),
|
||||
overrideShouldTestInside: true,
|
||||
})
|
||||
) {
|
||||
return LinearElementEditor.createPointAt(
|
||||
arrow,
|
||||
elementsMap,
|
||||
resolvedTarget[0],
|
||||
resolvedTarget[1],
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
const otherTargetPoint = otherBindable
|
||||
? otherOutlinePoint || otherFocusPoint || otherArrowPoint
|
||||
: otherArrowPoint;
|
||||
const arrowTooShort =
|
||||
pointDistance(otherTargetPoint, outlinePoint || focusPoint) <=
|
||||
BASE_ARROW_MIN_LENGTH;
|
||||
|
||||
// 2. If the arrow is unconnected at the other end, just check arrow size
|
||||
// and short-circuit to the focus point if the arrow is too short to
|
||||
// avoid inversion
|
||||
if (!otherBindable) {
|
||||
return LinearElementEditor.createPointAt(
|
||||
arrow,
|
||||
elementsMap,
|
||||
arrowTooShort ? focusPoint[0] : outlinePoint?.[0] ?? focusPoint[0],
|
||||
arrowTooShort ? focusPoint[1] : outlinePoint?.[1] ?? focusPoint[1],
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. If the arrow is too short while connected on both ends and
|
||||
// the other arrow endpoint will not be inside the bindable, just
|
||||
// check the arrow size and make a decision based on that
|
||||
if (arrowTooShort) {
|
||||
return LinearElementEditor.createPointAt(
|
||||
arrow,
|
||||
elementsMap,
|
||||
resolvedTarget?.[0] || focusPoint[0],
|
||||
resolvedTarget?.[1] || focusPoint[1],
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. In the general case, snap to the outline if possible
|
||||
return LinearElementEditor.createPointAt(
|
||||
arrow,
|
||||
elementsMap,
|
||||
maybeOutlineGlobal[0],
|
||||
maybeOutlineGlobal[1],
|
||||
outlinePoint?.[0] || focusPoint[0],
|
||||
outlinePoint?.[1] || focusPoint[1],
|
||||
null,
|
||||
);
|
||||
};
|
||||
@@ -1762,6 +1907,8 @@ export const calculateFixedPointForElbowArrowBinding = (
|
||||
hoveredElement: ExcalidrawBindableElement,
|
||||
startOrEnd: "start" | "end",
|
||||
elementsMap: ElementsMap,
|
||||
shouldSnapToOutline = true,
|
||||
isMidpointSnappingEnabled = true,
|
||||
): { fixedPoint: FixedPoint } => {
|
||||
const bounds = [
|
||||
hoveredElement.x,
|
||||
@@ -1769,12 +1916,20 @@ export const calculateFixedPointForElbowArrowBinding = (
|
||||
hoveredElement.x + hoveredElement.width,
|
||||
hoveredElement.y + hoveredElement.height,
|
||||
] as Bounds;
|
||||
const snappedPoint = bindPointToSnapToElementOutline(
|
||||
linearElement,
|
||||
hoveredElement,
|
||||
startOrEnd,
|
||||
elementsMap,
|
||||
);
|
||||
const snappedPoint = shouldSnapToOutline
|
||||
? bindPointToSnapToElementOutline(
|
||||
linearElement,
|
||||
hoveredElement,
|
||||
startOrEnd,
|
||||
elementsMap,
|
||||
undefined,
|
||||
isMidpointSnappingEnabled,
|
||||
)
|
||||
: LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
linearElement,
|
||||
startOrEnd === "start" ? 0 : -1,
|
||||
elementsMap,
|
||||
);
|
||||
const globalMidPoint = pointFrom(
|
||||
bounds[0] + (bounds[2] - bounds[0]) / 2,
|
||||
bounds[1] + (bounds[3] - bounds[1]) / 2,
|
||||
@@ -1802,7 +1957,7 @@ export const calculateFixedPointForNonElbowArrowBinding = (
|
||||
elementsMap: ElementsMap,
|
||||
focusPoint?: GlobalPoint,
|
||||
): { fixedPoint: FixedPoint } => {
|
||||
const edgePoint = focusPoint
|
||||
const edgePoint: GlobalPoint = focusPoint
|
||||
? focusPoint
|
||||
: LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
linearElement,
|
||||
@@ -1810,11 +1965,7 @@ export const calculateFixedPointForNonElbowArrowBinding = (
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
// Convert the global point to element-local coordinates
|
||||
const elementCenter = pointFrom(
|
||||
hoveredElement.x + hoveredElement.width / 2,
|
||||
hoveredElement.y + hoveredElement.height / 2,
|
||||
);
|
||||
const elementCenter = elementCenterPoint(hoveredElement, elementsMap);
|
||||
|
||||
// Rotate the point to account for element rotation
|
||||
const nonRotatedPoint = pointRotateRads(
|
||||
@@ -2324,21 +2475,37 @@ export const getArrowLocalFixedPoints = (
|
||||
];
|
||||
};
|
||||
|
||||
export const normalizeFixedPoint = <T extends FixedPoint | null>(
|
||||
export const isFixedPoint = (
|
||||
fixedPoint: any,
|
||||
): fixedPoint is FixedPointBinding["fixedPoint"] => {
|
||||
return (
|
||||
Array.isArray(fixedPoint) &&
|
||||
fixedPoint.length === 2 &&
|
||||
fixedPoint.every((coord) => Number.isFinite(coord))
|
||||
);
|
||||
};
|
||||
|
||||
export const normalizeFixedPoint = <T extends FixedPoint>(
|
||||
fixedPoint: T,
|
||||
): T extends null ? null : FixedPoint => {
|
||||
): FixedPoint => {
|
||||
if (!isFixedPoint(fixedPoint)) {
|
||||
return [0.5001, 0.5001];
|
||||
}
|
||||
|
||||
const EPSILON = 0.0001;
|
||||
|
||||
// Do not allow a precise 0.5 for fixed point ratio
|
||||
// to avoid jumping arrow heading due to floating point imprecision
|
||||
if (
|
||||
fixedPoint &&
|
||||
(Math.abs(fixedPoint[0] - 0.5) < 0.0001 ||
|
||||
Math.abs(fixedPoint[1] - 0.5) < 0.0001)
|
||||
Math.abs(fixedPoint[0] - 0.5) < EPSILON ||
|
||||
Math.abs(fixedPoint[1] - 0.5) < EPSILON
|
||||
) {
|
||||
return fixedPoint.map((ratio) =>
|
||||
Math.abs(ratio - 0.5) < 0.0001 ? 0.5001 : ratio,
|
||||
) as T extends null ? null : FixedPoint;
|
||||
Math.abs(ratio - 0.5) < EPSILON ? 0.5001 : ratio,
|
||||
) as FixedPoint;
|
||||
}
|
||||
return fixedPoint as any as T extends null ? null : FixedPoint;
|
||||
|
||||
return fixedPoint;
|
||||
};
|
||||
|
||||
type Side =
|
||||
|
||||
@@ -709,6 +709,9 @@ const getFreeDrawElementAbsoluteCoords = (
|
||||
return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2];
|
||||
};
|
||||
|
||||
const CARDINALITY_MARKER_SIZE = 20;
|
||||
const CROWFOOT_ARROWHEAD_SIZE = 15;
|
||||
|
||||
/** @returns number in pixels */
|
||||
export const getArrowheadSize = (arrowhead: Arrowhead): number => {
|
||||
switch (arrowhead) {
|
||||
@@ -717,10 +720,14 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
|
||||
case "diamond":
|
||||
case "diamond_outline":
|
||||
return 12;
|
||||
case "crowfoot_many":
|
||||
case "crowfoot_one":
|
||||
case "crowfoot_one_or_many":
|
||||
return 20;
|
||||
case "cardinality_many":
|
||||
case "cardinality_one_or_many":
|
||||
case "cardinality_zero_or_many":
|
||||
return CROWFOOT_ARROWHEAD_SIZE;
|
||||
case "cardinality_one":
|
||||
case "cardinality_exactly_one":
|
||||
case "cardinality_zero_or_one":
|
||||
return CARDINALITY_MARKER_SIZE;
|
||||
default:
|
||||
return 15;
|
||||
}
|
||||
@@ -743,7 +750,12 @@ export const getArrowheadPoints = (
|
||||
shape: Drawable[],
|
||||
position: "start" | "end",
|
||||
arrowhead: Arrowhead,
|
||||
offsetMultiplier = 0,
|
||||
) => {
|
||||
if (arrowhead === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (shape.length < 1) {
|
||||
return null;
|
||||
}
|
||||
@@ -824,29 +836,30 @@ export const getArrowheadPoints = (
|
||||
const lengthMultiplier =
|
||||
arrowhead === "diamond" || arrowhead === "diamond_outline" ? 0.25 : 0.5;
|
||||
const minSize = Math.min(size, length * lengthMultiplier);
|
||||
const xs = x2 - nx * minSize;
|
||||
const ys = y2 - ny * minSize;
|
||||
const tx = x2 - nx * minSize * offsetMultiplier;
|
||||
const ty = y2 - ny * minSize * offsetMultiplier;
|
||||
const xs = tx - nx * minSize;
|
||||
const ys = ty - ny * minSize;
|
||||
|
||||
if (
|
||||
arrowhead === "dot" ||
|
||||
arrowhead === "circle" ||
|
||||
arrowhead === "circle_outline"
|
||||
) {
|
||||
const diameter = Math.hypot(ys - y2, xs - x2) + element.strokeWidth - 2;
|
||||
return [x2, y2, diameter];
|
||||
if (arrowhead === "circle" || arrowhead === "circle_outline") {
|
||||
const diameter = Math.hypot(ys - ty, xs - tx) + element.strokeWidth - 2;
|
||||
return [tx, ty, diameter];
|
||||
}
|
||||
|
||||
const angle = getArrowheadAngle(arrowhead);
|
||||
|
||||
if (arrowhead === "crowfoot_many" || arrowhead === "crowfoot_one_or_many") {
|
||||
if (
|
||||
arrowhead === "cardinality_many" ||
|
||||
arrowhead === "cardinality_one_or_many"
|
||||
) {
|
||||
// swap (xs, ys) with (x2, y2)
|
||||
const [x3, y3] = pointRotateRads(
|
||||
pointFrom(x2, y2),
|
||||
pointFrom(tx, ty),
|
||||
pointFrom(xs, ys),
|
||||
degreesToRadians(-angle as Degrees),
|
||||
);
|
||||
const [x4, y4] = pointRotateRads(
|
||||
pointFrom(x2, y2),
|
||||
pointFrom(tx, ty),
|
||||
pointFrom(xs, ys),
|
||||
degreesToRadians(angle),
|
||||
);
|
||||
@@ -856,12 +869,12 @@ export const getArrowheadPoints = (
|
||||
// Return points
|
||||
const [x3, y3] = pointRotateRads(
|
||||
pointFrom(xs, ys),
|
||||
pointFrom(x2, y2),
|
||||
pointFrom(tx, ty),
|
||||
((-angle * Math.PI) / 180) as Radians,
|
||||
);
|
||||
const [x4, y4] = pointRotateRads(
|
||||
pointFrom(xs, ys),
|
||||
pointFrom(x2, y2),
|
||||
pointFrom(tx, ty),
|
||||
degreesToRadians(angle),
|
||||
);
|
||||
|
||||
@@ -874,9 +887,9 @@ export const getArrowheadPoints = (
|
||||
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
|
||||
|
||||
[ox, oy] = pointRotateRads(
|
||||
pointFrom(x2 + minSize * 2, y2),
|
||||
pointFrom(x2, y2),
|
||||
Math.atan2(py - y2, px - x2) as Radians,
|
||||
pointFrom(tx + minSize * 2, ty),
|
||||
pointFrom(tx, ty),
|
||||
Math.atan2(py - ty, px - tx) as Radians,
|
||||
);
|
||||
} else {
|
||||
const [px, py] =
|
||||
@@ -885,18 +898,19 @@ export const getArrowheadPoints = (
|
||||
: [0, 0];
|
||||
|
||||
[ox, oy] = pointRotateRads(
|
||||
pointFrom(x2 - minSize * 2, y2),
|
||||
pointFrom(x2, y2),
|
||||
Math.atan2(y2 - py, x2 - px) as Radians,
|
||||
pointFrom(tx - minSize * 2, ty),
|
||||
pointFrom(tx, ty),
|
||||
Math.atan2(ty - py, tx - px) as Radians,
|
||||
);
|
||||
}
|
||||
|
||||
return [x2, y2, x3, y3, ox, oy, x4, y4];
|
||||
return [tx, ty, x3, y3, ox, oy, x4, y4];
|
||||
}
|
||||
|
||||
return [x2, y2, x3, y3, x4, y4];
|
||||
return [tx, ty, x3, y3, x4, y4];
|
||||
};
|
||||
|
||||
// TODO reuse shape.ts
|
||||
const generateLinearElementShape = (
|
||||
element: ExcalidrawLinearElement,
|
||||
): Drawable => {
|
||||
@@ -954,7 +968,7 @@ const getLinearElementRotatedBounds = (
|
||||
}
|
||||
|
||||
// first element is always the curve
|
||||
const cachedShape = ShapeCache.get(element)?.[0];
|
||||
const cachedShape = ShapeCache.get(element, null)?.[0];
|
||||
const shape = cachedShape ?? generateLinearElementShape(element);
|
||||
const ops = getCurvePathOps(shape);
|
||||
const transformXY = ([x, y]: GlobalPoint) =>
|
||||
|
||||
@@ -59,8 +59,11 @@ import { LinearElementEditor } from "./linearElementEditor";
|
||||
|
||||
import { distanceToElement } from "./distance";
|
||||
|
||||
import { getBindingGap } from "./binding";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
@@ -105,6 +108,12 @@ export type HitTestArgs = {
|
||||
overrideShouldTestInside?: boolean;
|
||||
};
|
||||
|
||||
let cachedPoint: GlobalPoint | null = null;
|
||||
let cachedElement: WeakRef<ExcalidrawElement> | null = null;
|
||||
let cachedThreshold: number = Infinity;
|
||||
let cachedHit: boolean = false;
|
||||
let cachedOverrideShouldTestInside = false;
|
||||
|
||||
export const hitElementItself = ({
|
||||
point,
|
||||
element,
|
||||
@@ -113,6 +122,24 @@ export const hitElementItself = ({
|
||||
frameNameBound = null,
|
||||
overrideShouldTestInside = false,
|
||||
}: HitTestArgs) => {
|
||||
// Return cached result if the same point and element version is tested again
|
||||
if (
|
||||
cachedPoint &&
|
||||
pointsEqual(point, cachedPoint) &&
|
||||
cachedThreshold <= threshold &&
|
||||
overrideShouldTestInside === cachedOverrideShouldTestInside
|
||||
) {
|
||||
const derefElement = cachedElement?.deref();
|
||||
if (
|
||||
derefElement &&
|
||||
derefElement.id === element.id &&
|
||||
derefElement.version === element.version &&
|
||||
derefElement.versionNonce === element.versionNonce
|
||||
) {
|
||||
return cachedHit;
|
||||
}
|
||||
}
|
||||
|
||||
// Hit test against a frame's name
|
||||
const hitFrameName = frameNameBound
|
||||
? isPointWithinBounds(
|
||||
@@ -153,7 +180,16 @@ export const hitElementItself = ({
|
||||
isPointOnElementOutline(point, element, elementsMap, threshold)
|
||||
: isPointOnElementOutline(point, element, elementsMap, threshold);
|
||||
|
||||
return hitElement || hitFrameName;
|
||||
const result = hitElement || hitFrameName;
|
||||
|
||||
// Cache end result
|
||||
cachedPoint = point;
|
||||
cachedElement = new WeakRef(element);
|
||||
cachedThreshold = threshold;
|
||||
cachedOverrideShouldTestInside = overrideShouldTestInside;
|
||||
cachedHit = result;
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const hitElementBoundingBox = (
|
||||
@@ -257,7 +293,7 @@ export const getAllHoveredElementAtPoint = (
|
||||
point: Readonly<GlobalPoint>,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
toleranceFn?: (element: ExcalidrawBindableElement) => number,
|
||||
tolerance?: number,
|
||||
): NonDeleted<ExcalidrawBindableElement>[] => {
|
||||
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
|
||||
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
||||
@@ -273,7 +309,7 @@ export const getAllHoveredElementAtPoint = (
|
||||
|
||||
if (
|
||||
isBindableElement(element, false) &&
|
||||
bindingBorderTest(element, point, elementsMap, toleranceFn?.(element))
|
||||
bindingBorderTest(element, point, elementsMap, tolerance)
|
||||
) {
|
||||
candidateElements.push(element);
|
||||
|
||||
@@ -290,13 +326,13 @@ export const getHoveredElementForBinding = (
|
||||
point: Readonly<GlobalPoint>,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
toleranceFn?: (element: ExcalidrawBindableElement) => number,
|
||||
tolerance?: number,
|
||||
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||
const candidateElements = getAllHoveredElementAtPoint(
|
||||
point,
|
||||
elements,
|
||||
elementsMap,
|
||||
toleranceFn,
|
||||
tolerance,
|
||||
);
|
||||
|
||||
if (!candidateElements || candidateElements.length === 0) {
|
||||
@@ -315,6 +351,56 @@ export const getHoveredElementForBinding = (
|
||||
.pop() as NonDeleted<ExcalidrawBindableElement>;
|
||||
};
|
||||
|
||||
export const getHoveredElementForFocusPoint = (
|
||||
point: GlobalPoint,
|
||||
arrow: ExcalidrawArrowElement,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
tolerance?: number,
|
||||
): ExcalidrawBindableElement | null => {
|
||||
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
|
||||
// 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];
|
||||
|
||||
invariant(
|
||||
!element.isDeleted,
|
||||
"Elements in the function parameter for getAllElementsAtPositionForBinding() should not contain deleted elements",
|
||||
);
|
||||
|
||||
if (
|
||||
isBindableElement(element, false) &&
|
||||
bindingBorderTest(element, point, elementsMap, tolerance)
|
||||
) {
|
||||
candidateElements.push(element);
|
||||
}
|
||||
}
|
||||
|
||||
if (!candidateElements || candidateElements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (candidateElements.length === 1) {
|
||||
return candidateElements[0];
|
||||
}
|
||||
|
||||
const distanceFilteredCandidateElements = candidateElements
|
||||
// Resolve by distance
|
||||
.filter(
|
||||
(el) =>
|
||||
distanceToElement(el, elementsMap, point) <= getBindingGap(el, arrow) ||
|
||||
isPointInElement(point, el, elementsMap),
|
||||
);
|
||||
|
||||
if (distanceFilteredCandidateElements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return distanceFilteredCandidateElements[0] as NonDeleted<ExcalidrawBindableElement>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Intersect a line with an element for binding test
|
||||
*
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { updateBoundElements } from "./binding";
|
||||
import { getCommonBoundingBox } from "./bounds";
|
||||
import { newElementWith } from "./mutateElement";
|
||||
|
||||
import { getSelectedElementsByGroup } from "./groups";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||
|
||||
export interface Distribution {
|
||||
@@ -17,6 +19,7 @@ export const distributeElements = (
|
||||
elementsMap: ElementsMap,
|
||||
distribution: Distribution,
|
||||
appState: Readonly<AppState>,
|
||||
scene: Scene,
|
||||
): ExcalidrawElement[] => {
|
||||
const [start, mid, end, extent] =
|
||||
distribution.axis === "x"
|
||||
@@ -66,12 +69,16 @@ export const distributeElements = (
|
||||
translation[distribution.axis] = pos - box[mid];
|
||||
}
|
||||
|
||||
return group.map((element) =>
|
||||
newElementWith(element, {
|
||||
return group.map((element) => {
|
||||
const updatedElement = scene.mutateElement(element, {
|
||||
x: element.x + translation.x,
|
||||
y: element.y + translation.y,
|
||||
}),
|
||||
);
|
||||
});
|
||||
updateBoundElements(element, scene, {
|
||||
simultaneouslyUpdated: group,
|
||||
});
|
||||
return updatedElement;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,11 +97,15 @@ export const distributeElements = (
|
||||
pos += step;
|
||||
pos += box[extent];
|
||||
|
||||
return group.map((element) =>
|
||||
newElementWith(element, {
|
||||
return group.map((element) => {
|
||||
const updatedElement = scene.mutateElement(element, {
|
||||
x: element.x + translation.x,
|
||||
y: element.y + translation.y,
|
||||
}),
|
||||
);
|
||||
});
|
||||
updateBoundElements(element, scene, {
|
||||
simultaneouslyUpdated: group,
|
||||
});
|
||||
return updatedElement;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -915,6 +915,8 @@ export const updateElbowArrowPoints = (
|
||||
},
|
||||
options?: {
|
||||
isDragging?: boolean;
|
||||
isBindingEnabled?: boolean;
|
||||
isMidpointSnappingEnabled?: boolean;
|
||||
},
|
||||
): ElementUpdate<ExcalidrawElbowArrowElement> => {
|
||||
if (arrow.points.length < 2) {
|
||||
@@ -1202,6 +1204,8 @@ const getElbowArrowData = (
|
||||
options?: {
|
||||
isDragging?: boolean;
|
||||
zoom?: AppState["zoom"];
|
||||
isBindingEnabled?: boolean;
|
||||
isMidpointSnappingEnabled?: boolean;
|
||||
},
|
||||
) => {
|
||||
const origStartGlobalPoint: GlobalPoint = pointTranslate<
|
||||
@@ -1215,7 +1219,7 @@ const getElbowArrowData = (
|
||||
|
||||
let hoveredStartElement = null;
|
||||
let hoveredEndElement = null;
|
||||
if (options?.isDragging) {
|
||||
if (options?.isDragging && options?.isBindingEnabled !== false) {
|
||||
const elements = Array.from(elementsMap.values());
|
||||
hoveredStartElement =
|
||||
getHoveredElement(
|
||||
@@ -1255,6 +1259,8 @@ const getElbowArrowData = (
|
||||
hoveredStartElement,
|
||||
elementsMap,
|
||||
options?.isDragging,
|
||||
options?.isBindingEnabled,
|
||||
options?.isMidpointSnappingEnabled,
|
||||
);
|
||||
const endGlobalPoint = getGlobalPoint(
|
||||
{
|
||||
@@ -1270,6 +1276,8 @@ const getElbowArrowData = (
|
||||
hoveredEndElement,
|
||||
elementsMap,
|
||||
options?.isDragging,
|
||||
options?.isBindingEnabled,
|
||||
options?.isMidpointSnappingEnabled,
|
||||
);
|
||||
const startHeading = getBindPointHeading(
|
||||
startGlobalPoint,
|
||||
@@ -2213,14 +2221,18 @@ const getGlobalPoint = (
|
||||
element?: ExcalidrawBindableElement | null,
|
||||
elementsMap?: ElementsMap,
|
||||
isDragging?: boolean,
|
||||
isBindingEnabled = true,
|
||||
isMidpointSnappingEnabled = true,
|
||||
): GlobalPoint => {
|
||||
if (isDragging) {
|
||||
if (element && elementsMap) {
|
||||
if (isBindingEnabled && element && elementsMap) {
|
||||
return bindPointToSnapToElementOutline(
|
||||
arrow,
|
||||
element,
|
||||
startOrEnd,
|
||||
elementsMap,
|
||||
undefined,
|
||||
isMidpointSnappingEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2276,7 +2288,7 @@ const getHoveredElement = (
|
||||
origPoint,
|
||||
elements,
|
||||
elementsMap,
|
||||
(element) => maxBindingDistance_simple(zoom),
|
||||
maxBindingDistance_simple(zoom),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ const RE_REDDIT =
|
||||
const RE_REDDIT_EMBED =
|
||||
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
|
||||
|
||||
const parseYouTubeTimestamp = (url: string): number => {
|
||||
const parseYouTubeLikeTimestamp = (url: string): number => {
|
||||
let timeParam: string | null | undefined;
|
||||
|
||||
try {
|
||||
@@ -85,11 +85,57 @@ const parseYouTubeTimestamp = (url: string): number => {
|
||||
return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds);
|
||||
};
|
||||
|
||||
const parseGoogleDriveVideoLink = (
|
||||
url: string,
|
||||
): { fileId: string; resourceKey?: string; timestamp?: number } | null => {
|
||||
try {
|
||||
const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`);
|
||||
const hostname = urlObj.hostname.replace(/^www\./, "");
|
||||
if (hostname !== "drive.google.com") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let fileId: string | null = null;
|
||||
const pathMatch = urlObj.pathname.match(/^\/file\/d\/([^/]+)(?:\/|$)/);
|
||||
if (pathMatch?.[1]) {
|
||||
fileId = pathMatch[1];
|
||||
} else if (urlObj.pathname === "/open" || urlObj.pathname === "/uc") {
|
||||
// Shared Drive links can be emitted as:
|
||||
// - /open?id=<fileId> (common "open in Drive" format)
|
||||
// - /uc?...&id=<fileId> (download/export endpoint often seen in copied links)
|
||||
fileId = urlObj.searchParams.get("id");
|
||||
}
|
||||
|
||||
if (!fileId || !/^[a-zA-Z0-9_-]+$/.test(fileId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Some Drive share links include `resourcekey` for access to link-shared
|
||||
// files; preserve it in the preview URL so embeds keep working.
|
||||
const resourceKey = urlObj.searchParams.get("resourcekey");
|
||||
const timestamp = parseYouTubeLikeTimestamp(urlObj.toString());
|
||||
|
||||
return {
|
||||
fileId,
|
||||
resourceKey:
|
||||
resourceKey && /^[a-zA-Z0-9_-]+$/.test(resourceKey)
|
||||
? resourceKey
|
||||
: undefined,
|
||||
// Drive accepts YouTube-like `t` formats (e.g. `t=90`, `t=1m30s`);
|
||||
// normalize to seconds for a stable preview URL.
|
||||
timestamp: timestamp > 0 ? timestamp : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const ALLOWED_DOMAINS = new Set([
|
||||
"youtube.com",
|
||||
"youtu.be",
|
||||
"vimeo.com",
|
||||
"player.vimeo.com",
|
||||
"drive.google.com",
|
||||
"figma.com",
|
||||
"link.excalidraw.com",
|
||||
"gist.github.com",
|
||||
@@ -108,6 +154,7 @@ const ALLOW_SAME_ORIGIN = new Set([
|
||||
"youtu.be",
|
||||
"vimeo.com",
|
||||
"player.vimeo.com",
|
||||
"drive.google.com",
|
||||
"figma.com",
|
||||
"twitter.com",
|
||||
"x.com",
|
||||
@@ -142,7 +189,7 @@ export const getEmbedLink = (
|
||||
let aspectRatio = { w: 560, h: 840 };
|
||||
const ytLink = link.match(RE_YOUTUBE);
|
||||
if (ytLink?.[2]) {
|
||||
const startTime = parseYouTubeTimestamp(originalLink);
|
||||
const startTime = parseYouTubeLikeTimestamp(originalLink);
|
||||
const time = startTime > 0 ? `&start=${startTime}` : ``;
|
||||
const isPortrait = link.includes("shorts");
|
||||
type = "video";
|
||||
@@ -201,6 +248,36 @@ export const getEmbedLink = (
|
||||
};
|
||||
}
|
||||
|
||||
const googleDriveVideo = parseGoogleDriveVideoLink(link);
|
||||
if (googleDriveVideo) {
|
||||
type = "video";
|
||||
const searchParams = new URLSearchParams();
|
||||
if (googleDriveVideo.resourceKey) {
|
||||
searchParams.set("resourcekey", googleDriveVideo.resourceKey);
|
||||
}
|
||||
if (googleDriveVideo.timestamp) {
|
||||
searchParams.set("t", `${googleDriveVideo.timestamp}`);
|
||||
}
|
||||
|
||||
const search = searchParams.toString();
|
||||
link = `https://drive.google.com/file/d/${googleDriveVideo.fileId}/preview${
|
||||
search ? `?${search}` : ""
|
||||
}`;
|
||||
aspectRatio = { w: 560, h: 315 };
|
||||
embeddedLinkCache.set(originalLink, {
|
||||
link,
|
||||
intrinsicSize: aspectRatio,
|
||||
type,
|
||||
sandbox: { allowSameOrigin },
|
||||
});
|
||||
return {
|
||||
link,
|
||||
intrinsicSize: aspectRatio,
|
||||
type,
|
||||
sandbox: { allowSameOrigin },
|
||||
};
|
||||
}
|
||||
|
||||
const figmaLink = link.match(RE_FIGMA);
|
||||
if (figmaLink) {
|
||||
type = "generic";
|
||||
|
||||
@@ -872,6 +872,19 @@ export const shouldApplyFrameClip = (
|
||||
return true;
|
||||
}
|
||||
|
||||
// Elements that belong to a frame should still render through that frame's
|
||||
// clip, even when fully outside the frame bounds (e.g. generated content).
|
||||
if (
|
||||
!appState.selectedElementsAreBeingDragged &&
|
||||
element.frameId === frame.id
|
||||
) {
|
||||
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 (
|
||||
|
||||
@@ -70,6 +70,7 @@ export * from "./elbowArrow";
|
||||
export * from "./elementLink";
|
||||
export * from "./embeddable";
|
||||
export * from "./flowchart";
|
||||
export * from "./arrows/focus";
|
||||
export * from "./fractionalIndex";
|
||||
export * from "./frame";
|
||||
export * from "./groups";
|
||||
@@ -97,3 +98,5 @@ export * from "./transformHandles";
|
||||
export * from "./typeChecks";
|
||||
export * from "./utils";
|
||||
export * from "./zindex";
|
||||
export * from "./arrows/helpers";
|
||||
export * from "./arrowheads";
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
vectorFromPoint,
|
||||
curveLength,
|
||||
curvePointAtLength,
|
||||
lineSegment,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { getCurvePathOps } from "@excalidraw/utils/shape";
|
||||
@@ -26,6 +25,7 @@ import {
|
||||
|
||||
import {
|
||||
deconstructLinearOrFreeDrawElement,
|
||||
getSnapOutlineMidPoint,
|
||||
isPathALoop,
|
||||
moveArrowAboveBindable,
|
||||
projectFixedPointOntoDiagonal,
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
calculateFixedPointForNonElbowArrowBinding,
|
||||
getBindingStrategyForDraggingBindingElementEndpoints,
|
||||
isBindingEnabled,
|
||||
snapToMid,
|
||||
updateBoundPoint,
|
||||
} from "./binding";
|
||||
import {
|
||||
@@ -149,6 +150,8 @@ export class LinearElementEditor {
|
||||
public readonly pointerOffset: Readonly<{ x: number; y: number }>;
|
||||
public readonly hoverPointIndex: number;
|
||||
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
|
||||
public readonly hoveredFocusPointBinding: "start" | "end" | null;
|
||||
public readonly draggedFocusPointBinding: "start" | "end" | null;
|
||||
public readonly elbowed: boolean;
|
||||
public readonly customLineAngle: number | null;
|
||||
public readonly isEditing: boolean;
|
||||
@@ -194,6 +197,8 @@ export class LinearElementEditor {
|
||||
};
|
||||
this.hoverPointIndex = -1;
|
||||
this.segmentMidPointHoveredCoords = null;
|
||||
this.hoveredFocusPointBinding = null;
|
||||
this.draggedFocusPointBinding = null;
|
||||
this.elbowed = isElbowArrow(element) && element.elbowed;
|
||||
this.customLineAngle = null;
|
||||
this.isEditing = isEditing;
|
||||
@@ -351,13 +356,23 @@ export class LinearElementEditor {
|
||||
app,
|
||||
shouldRotateWithDiscreteAngle(event),
|
||||
event.altKey,
|
||||
linearElementEditor,
|
||||
);
|
||||
|
||||
LinearElementEditor.movePoints(element, app.scene, positions, {
|
||||
startBinding: updates?.startBinding,
|
||||
endBinding: updates?.endBinding,
|
||||
moveMidPointsWithElement: updates?.moveMidPointsWithElement,
|
||||
});
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
app.scene,
|
||||
positions,
|
||||
{
|
||||
startBinding: updates?.startBinding,
|
||||
endBinding: updates?.endBinding,
|
||||
moveMidPointsWithElement: updates?.moveMidPointsWithElement,
|
||||
},
|
||||
{
|
||||
isBindingEnabled: app.state.isBindingEnabled,
|
||||
isMidpointSnappingEnabled: app.state.isMidpointSnappingEnabled,
|
||||
},
|
||||
);
|
||||
// Set the suggested binding from the updates if available
|
||||
if (isBindingElement(element, false)) {
|
||||
if (isBindingEnabled(app.state)) {
|
||||
@@ -404,13 +419,15 @@ export class LinearElementEditor {
|
||||
altFocusPoint:
|
||||
!linearElementEditor.initialState.altFocusPoint &&
|
||||
startBindingElement &&
|
||||
updates?.suggestedBinding?.id !== startBindingElement.id
|
||||
updates?.suggestedBinding?.element.id !== startBindingElement.id
|
||||
? projectFixedPointOntoDiagonal(
|
||||
element,
|
||||
pointFrom<GlobalPoint>(element.x, element.y),
|
||||
startBindingElement,
|
||||
"start",
|
||||
elementsMap,
|
||||
app.state.zoom,
|
||||
app.state.isMidpointSnappingEnabled,
|
||||
)
|
||||
: linearElementEditor.initialState.altFocusPoint,
|
||||
},
|
||||
@@ -528,13 +545,23 @@ export class LinearElementEditor {
|
||||
app,
|
||||
shouldRotateWithDiscreteAngle(event) && singlePointDragged,
|
||||
event.altKey,
|
||||
linearElementEditor,
|
||||
);
|
||||
|
||||
LinearElementEditor.movePoints(element, app.scene, positions, {
|
||||
startBinding: updates?.startBinding,
|
||||
endBinding: updates?.endBinding,
|
||||
moveMidPointsWithElement: updates?.moveMidPointsWithElement,
|
||||
});
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
app.scene,
|
||||
positions,
|
||||
{
|
||||
startBinding: updates?.startBinding,
|
||||
endBinding: updates?.endBinding,
|
||||
moveMidPointsWithElement: updates?.moveMidPointsWithElement,
|
||||
},
|
||||
{
|
||||
isBindingEnabled: app.state.isBindingEnabled,
|
||||
isMidpointSnappingEnabled: app.state.isMidpointSnappingEnabled,
|
||||
},
|
||||
);
|
||||
|
||||
// Set the suggested binding from the updates if available
|
||||
if (isBindingElement(element, false)) {
|
||||
@@ -603,11 +630,11 @@ export class LinearElementEditor {
|
||||
const altFocusPointBindableElement =
|
||||
endIsSelected && // The "other" end (i.e. "end") is dragged
|
||||
startBindingElement &&
|
||||
updates?.suggestedBinding?.id !== startBindingElement.id // The end point is not hovering the start bindable + it's binding gap
|
||||
updates?.suggestedBinding?.element.id !== startBindingElement.id // The end point is not hovering the start bindable + it's binding gap
|
||||
? startBindingElement
|
||||
: startIsSelected && // The "other" end (i.e. "start") is dragged
|
||||
endBindingElement &&
|
||||
updates?.suggestedBinding?.id !== endBindingElement.id // The start point is not hovering the end bindable + it's binding gap
|
||||
updates?.suggestedBinding?.element.id !== endBindingElement.id // The start point is not hovering the end bindable + it's binding gap
|
||||
? endBindingElement
|
||||
: null;
|
||||
|
||||
@@ -627,6 +654,8 @@ export class LinearElementEditor {
|
||||
altFocusPointBindableElement,
|
||||
"start",
|
||||
elementsMap,
|
||||
app.state.zoom,
|
||||
app.state.isMidpointSnappingEnabled,
|
||||
)
|
||||
: linearElementEditor.initialState.altFocusPoint,
|
||||
},
|
||||
@@ -724,7 +753,6 @@ export class LinearElementEditor {
|
||||
? [pointerDownState.lastClickedPoint]
|
||||
: selectedPointsIndices,
|
||||
isDragging: false,
|
||||
pointerOffset: { x: 0, y: 0 },
|
||||
customLineAngle: null,
|
||||
initialState: {
|
||||
...editingLinearElement.initialState,
|
||||
@@ -1516,6 +1544,10 @@ export class LinearElementEditor {
|
||||
endBinding?: FixedPointBinding | null;
|
||||
moveMidPointsWithElement?: boolean | null;
|
||||
},
|
||||
options?: {
|
||||
isBindingEnabled?: boolean;
|
||||
isMidpointSnappingEnabled?: boolean;
|
||||
},
|
||||
) {
|
||||
const { points } = element;
|
||||
|
||||
@@ -1584,6 +1616,8 @@ export class LinearElementEditor {
|
||||
otherUpdates,
|
||||
{
|
||||
isDragging: Array.from(pointUpdates.values()).some((t) => t.isDragging),
|
||||
isBindingEnabled: options?.isBindingEnabled,
|
||||
isMidpointSnappingEnabled: options?.isMidpointSnappingEnabled,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1698,6 +1732,8 @@ export class LinearElementEditor {
|
||||
isDragging?: boolean;
|
||||
zoom?: AppState["zoom"];
|
||||
sceneElementsMap?: NonDeletedSceneElementsMap;
|
||||
isBindingEnabled?: boolean;
|
||||
isMidpointSnappingEnabled?: boolean;
|
||||
},
|
||||
) {
|
||||
if (isElbowArrow(element)) {
|
||||
@@ -1718,6 +1754,8 @@ export class LinearElementEditor {
|
||||
scene.mutateElement(element, updates, {
|
||||
informMutation: true,
|
||||
isDragging: options?.isDragging ?? false,
|
||||
isBindingEnabled: options?.isBindingEnabled,
|
||||
isMidpointSnappingEnabled: options?.isMidpointSnappingEnabled,
|
||||
});
|
||||
} else {
|
||||
// TODO do we need to get precise coords here just to calc centers?
|
||||
@@ -2077,6 +2115,7 @@ const pointDraggingUpdates = (
|
||||
app: AppClassProperties,
|
||||
angleLocked: boolean,
|
||||
altKey: boolean,
|
||||
linearElementEditor: LinearElementEditor,
|
||||
): {
|
||||
positions: PointsPositionUpdates;
|
||||
updates?: PointMoveOtherUpdates;
|
||||
@@ -2124,18 +2163,91 @@ const pointDraggingUpdates = (
|
||||
);
|
||||
|
||||
if (isElbowArrow(element)) {
|
||||
const suggestedBindingElement = startIsDragged
|
||||
? start.element
|
||||
: endIsDragged
|
||||
? end.element
|
||||
: null;
|
||||
|
||||
return {
|
||||
positions: naiveDraggingPoints,
|
||||
updates: {
|
||||
suggestedBinding: startIsDragged
|
||||
? start.element
|
||||
: endIsDragged
|
||||
? end.element
|
||||
suggestedBinding: suggestedBindingElement
|
||||
? {
|
||||
element: suggestedBindingElement,
|
||||
midPoint: app.state.isMidpointSnappingEnabled
|
||||
? snapToMid(
|
||||
suggestedBindingElement,
|
||||
elementsMap,
|
||||
pointFrom<GlobalPoint>(
|
||||
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Handle the case where neither endpoint is being dragged
|
||||
// but we need to update bound endpoints
|
||||
if (!startIsDragged && !endIsDragged) {
|
||||
const nextArrow = {
|
||||
...element,
|
||||
points: element.points.map((p, idx) => {
|
||||
return naiveDraggingPoints.get(idx)?.point ?? p;
|
||||
}),
|
||||
};
|
||||
const positions = new Map(naiveDraggingPoints);
|
||||
|
||||
if (element.startBinding) {
|
||||
const startBindable = elementsMap.get(element.startBinding.elementId) as
|
||||
| ExcalidrawBindableElement
|
||||
| undefined;
|
||||
if (startBindable) {
|
||||
const startPoint =
|
||||
updateBoundPoint(
|
||||
nextArrow,
|
||||
"startBinding",
|
||||
element.startBinding,
|
||||
startBindable,
|
||||
elementsMap,
|
||||
) ?? null;
|
||||
if (startPoint) {
|
||||
positions.set(0, { point: startPoint, isDragging: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (element.endBinding) {
|
||||
const endBindable = elementsMap.get(element.endBinding.elementId) as
|
||||
| ExcalidrawBindableElement
|
||||
| undefined;
|
||||
if (endBindable) {
|
||||
const endPoint =
|
||||
updateBoundPoint(
|
||||
nextArrow,
|
||||
"endBinding",
|
||||
element.endBinding,
|
||||
endBindable,
|
||||
elementsMap,
|
||||
) ?? null;
|
||||
if (endPoint) {
|
||||
positions.set(element.points.length - 1, {
|
||||
point: endPoint,
|
||||
isDragging: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
positions,
|
||||
};
|
||||
}
|
||||
|
||||
if (startIsDragged === endIsDragged) {
|
||||
return {
|
||||
positions: naiveDraggingPoints,
|
||||
@@ -2166,7 +2278,20 @@ const pointDraggingUpdates = (
|
||||
(updates.startBinding.mode === "orbit" ||
|
||||
!getFeatureFlag("COMPLEX_BINDINGS"))
|
||||
) {
|
||||
updates.suggestedBinding = start.element;
|
||||
updates.suggestedBinding = start.element
|
||||
? {
|
||||
element: start.element,
|
||||
midPoint: getSnapOutlineMidPoint(
|
||||
pointFrom<GlobalPoint>(
|
||||
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||
),
|
||||
start.element,
|
||||
elementsMap,
|
||||
app.state.zoom,
|
||||
),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
} else if (startIsDragged) {
|
||||
updates.suggestedBinding = app.state.suggestedBinding;
|
||||
@@ -2192,7 +2317,20 @@ const pointDraggingUpdates = (
|
||||
(updates.endBinding.mode === "orbit" ||
|
||||
!getFeatureFlag("COMPLEX_BINDINGS"))
|
||||
) {
|
||||
updates.suggestedBinding = end.element;
|
||||
updates.suggestedBinding = end.element
|
||||
? {
|
||||
element: end.element,
|
||||
midPoint: getSnapOutlineMidPoint(
|
||||
pointFrom<GlobalPoint>(
|
||||
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||
),
|
||||
end.element,
|
||||
elementsMap,
|
||||
app.state.zoom,
|
||||
),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
} else if (endIsDragged) {
|
||||
updates.suggestedBinding = app.state.suggestedBinding;
|
||||
@@ -2232,19 +2370,6 @@ const pointDraggingUpdates = (
|
||||
: updates.endBinding,
|
||||
};
|
||||
|
||||
// We need to use a custom intersector to ensure that if there is a big "jump"
|
||||
// in the arrow's position, we can position it with outline avoidance
|
||||
// pixel-perfectly and avoid "dancing" arrows.
|
||||
// NOTE: Direction matters here, so we create two intersectors
|
||||
const startCustomIntersector =
|
||||
start.focusPoint && end.focusPoint
|
||||
? lineSegment(start.focusPoint, end.focusPoint)
|
||||
: undefined;
|
||||
const endCustomIntersector =
|
||||
start.focusPoint && end.focusPoint
|
||||
? lineSegment(end.focusPoint, start.focusPoint)
|
||||
: undefined;
|
||||
|
||||
// Needed to handle a special case where an existing arrow is dragged over
|
||||
// the same element it is bound to on the other side
|
||||
const startIsDraggingOverEndElement =
|
||||
@@ -2280,7 +2405,7 @@ const pointDraggingUpdates = (
|
||||
nextArrow.endBinding,
|
||||
endBindable,
|
||||
elementsMap,
|
||||
endCustomIntersector,
|
||||
endIsDragged,
|
||||
) || nextArrow.points[nextArrow.points.length - 1]
|
||||
: nextArrow.points[nextArrow.points.length - 1];
|
||||
|
||||
@@ -2303,7 +2428,7 @@ const pointDraggingUpdates = (
|
||||
: startIsDraggingOverEndElement &&
|
||||
app.state.bindMode !== "inside" &&
|
||||
getFeatureFlag("COMPLEX_BINDINGS")
|
||||
? nextArrow.points[nextArrow.points.length - 1]
|
||||
? endLocalPoint
|
||||
: startBindable
|
||||
? updateBoundPoint(
|
||||
element,
|
||||
@@ -2311,15 +2436,18 @@ const pointDraggingUpdates = (
|
||||
nextArrow.startBinding,
|
||||
startBindable,
|
||||
elementsMap,
|
||||
startCustomIntersector,
|
||||
startIsDragged,
|
||||
) || nextArrow.points[0]
|
||||
: nextArrow.points[0];
|
||||
|
||||
const endChanged =
|
||||
pointDistance(
|
||||
endLocalPoint,
|
||||
nextArrow.points[nextArrow.points.length - 1],
|
||||
) !== 0;
|
||||
!startIsDraggingOverEndElement &&
|
||||
!(
|
||||
endIsDraggingOverStartElement &&
|
||||
app.state.bindMode !== "inside" &&
|
||||
getFeatureFlag("COMPLEX_BINDINGS")
|
||||
) &&
|
||||
!!endBindable;
|
||||
const startChanged =
|
||||
pointDistance(startLocalPoint, nextArrow.points[0]) !== 0;
|
||||
|
||||
@@ -2333,13 +2461,7 @@ const pointDraggingUpdates = (
|
||||
const indices = Array.from(indicesSet);
|
||||
|
||||
return {
|
||||
updates:
|
||||
updates.startBinding || updates.suggestedBinding
|
||||
? {
|
||||
startBinding: updates.startBinding,
|
||||
suggestedBinding: updates.suggestedBinding,
|
||||
}
|
||||
: undefined,
|
||||
updates,
|
||||
positions: new Map(
|
||||
indices.map((idx) => {
|
||||
return [
|
||||
|
||||
@@ -40,6 +40,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
updates: ElementUpdate<TElement>,
|
||||
options?: {
|
||||
isDragging?: boolean;
|
||||
isBindingEnabled?: boolean;
|
||||
isMidpointSnappingEnabled?: boolean;
|
||||
},
|
||||
) => {
|
||||
let didChange = false;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import rough from "roughjs/bin/rough";
|
||||
import { getStroke } from "perfect-freehand";
|
||||
|
||||
import {
|
||||
type GlobalPoint,
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
DEFAULT_REDUCED_GLOBAL_ALPHA,
|
||||
ELEMENT_READY_TO_ERASE_OPACITY,
|
||||
FRAME_STYLE,
|
||||
DARK_THEME_FILTER,
|
||||
MIME_TYPES,
|
||||
THEME,
|
||||
distance,
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
isRTL,
|
||||
getVerticalOffset,
|
||||
invariant,
|
||||
applyDarkModeFilter,
|
||||
isSafari,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
@@ -78,16 +80,8 @@ import type {
|
||||
ElementsMap,
|
||||
} from "./types";
|
||||
|
||||
import type { StrokeOptions } from "perfect-freehand";
|
||||
import type { RoughCanvas } from "roughjs/bin/canvas";
|
||||
|
||||
// using a stronger invert (100% vs our regular 93%) and saturate
|
||||
// as a temp hack to make images in dark theme look closer to original
|
||||
// color scheme (it's still not quite there and the colors look slightly
|
||||
// desatured, alas...)
|
||||
export const IMAGE_INVERT_FILTER =
|
||||
"invert(100%) hue-rotate(180deg) saturate(1.25)";
|
||||
|
||||
const isPendingImageElement = (
|
||||
element: ExcalidrawElement,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
@@ -95,19 +89,6 @@ const isPendingImageElement = (
|
||||
isInitializedImageElement(element) &&
|
||||
!renderConfig.imageCache.has(element.fileId);
|
||||
|
||||
const shouldResetImageFilter = (
|
||||
element: ExcalidrawElement,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||
) => {
|
||||
return (
|
||||
appState.theme === THEME.DARK &&
|
||||
isInitializedImageElement(element) &&
|
||||
!isPendingImageElement(element, renderConfig) &&
|
||||
renderConfig.imageCache.get(element.fileId)?.mimeType !== MIME_TYPES.svg
|
||||
);
|
||||
};
|
||||
|
||||
const getCanvasPadding = (element: ExcalidrawElement) => {
|
||||
switch (element.type) {
|
||||
case "freedraw":
|
||||
@@ -272,11 +253,6 @@ const generateElementCanvas = (
|
||||
|
||||
const rc = rough.canvas(canvas);
|
||||
|
||||
// in dark theme, revert the image color filter
|
||||
if (shouldResetImageFilter(element, renderConfig, appState)) {
|
||||
context.filter = IMAGE_INVERT_FILTER;
|
||||
}
|
||||
|
||||
drawElementOnCanvas(element, rc, context, renderConfig);
|
||||
|
||||
context.restore();
|
||||
@@ -385,8 +361,9 @@ IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
|
||||
const drawImagePlaceholder = (
|
||||
element: ExcalidrawImageElement,
|
||||
context: CanvasRenderingContext2D,
|
||||
theme: StaticCanvasRenderConfig["theme"],
|
||||
) => {
|
||||
context.fillStyle = "#E7E7E7";
|
||||
context.fillStyle = theme === THEME.DARK ? "#2E2E2E" : "#E7E7E7";
|
||||
context.fillRect(0, 0, element.width, element.height);
|
||||
|
||||
const imageMinWidthOrHeight = Math.min(element.width, element.height);
|
||||
@@ -421,7 +398,8 @@ const drawElementOnCanvas = (
|
||||
case "ellipse": {
|
||||
context.lineJoin = "round";
|
||||
context.lineCap = "round";
|
||||
rc.draw(ShapeCache.get(element)!);
|
||||
|
||||
rc.draw(ShapeCache.generateElementShape(element, renderConfig));
|
||||
break;
|
||||
}
|
||||
case "arrow":
|
||||
@@ -429,33 +407,44 @@ const drawElementOnCanvas = (
|
||||
context.lineJoin = "round";
|
||||
context.lineCap = "round";
|
||||
|
||||
ShapeCache.get(element)!.forEach((shape) => {
|
||||
rc.draw(shape);
|
||||
});
|
||||
ShapeCache.generateElementShape(element, renderConfig).forEach(
|
||||
(shape) => {
|
||||
rc.draw(shape);
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "freedraw": {
|
||||
// Draw directly to canvas
|
||||
context.save();
|
||||
context.fillStyle = element.strokeColor;
|
||||
|
||||
const path = getFreeDrawPath2D(element) as Path2D;
|
||||
const fillShape = ShapeCache.get(element);
|
||||
const shapes = ShapeCache.generateElementShape(element, renderConfig);
|
||||
|
||||
if (fillShape) {
|
||||
rc.draw(fillShape);
|
||||
for (const shape of shapes) {
|
||||
if (typeof shape === "string") {
|
||||
context.fillStyle =
|
||||
renderConfig.theme === THEME.DARK
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
: element.strokeColor;
|
||||
context.fill(new Path2D(shape));
|
||||
} else {
|
||||
rc.draw(shape);
|
||||
}
|
||||
}
|
||||
|
||||
context.fillStyle = element.strokeColor;
|
||||
context.fill(path);
|
||||
|
||||
context.restore();
|
||||
break;
|
||||
}
|
||||
case "image": {
|
||||
context.save();
|
||||
const cacheEntry =
|
||||
element.fileId !== null
|
||||
? renderConfig.imageCache.get(element.fileId)
|
||||
: null;
|
||||
const img = isInitializedImageElement(element)
|
||||
? renderConfig.imageCache.get(element.fileId)?.image
|
||||
? cacheEntry?.image
|
||||
: undefined;
|
||||
|
||||
if (img != null && !(img instanceof Promise)) {
|
||||
if (element.roundness && context.roundRect) {
|
||||
context.beginPath();
|
||||
@@ -478,20 +467,80 @@ const drawElementOnCanvas = (
|
||||
height: img.naturalHeight,
|
||||
};
|
||||
|
||||
context.drawImage(
|
||||
img,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
0 /* hardcoded for the selection box*/,
|
||||
0,
|
||||
element.width,
|
||||
element.height,
|
||||
);
|
||||
const shouldInvertImage =
|
||||
renderConfig.theme === THEME.DARK &&
|
||||
cacheEntry?.mimeType === MIME_TYPES.svg;
|
||||
|
||||
if (shouldInvertImage && isSafari) {
|
||||
const devicePixelRatio = window.devicePixelRatio || 1;
|
||||
const tempCanvas = document.createElement("canvas");
|
||||
tempCanvas.width = element.width * devicePixelRatio;
|
||||
tempCanvas.height = element.height * devicePixelRatio;
|
||||
const tempContext = tempCanvas.getContext("2d");
|
||||
|
||||
if (tempContext) {
|
||||
tempContext.scale(devicePixelRatio, devicePixelRatio);
|
||||
tempContext.drawImage(
|
||||
img,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
0,
|
||||
0,
|
||||
element.width,
|
||||
element.height,
|
||||
);
|
||||
|
||||
const imageData = tempContext.getImageData(
|
||||
0,
|
||||
0,
|
||||
tempCanvas.width,
|
||||
tempCanvas.height,
|
||||
);
|
||||
|
||||
const data = imageData.data;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
data[i] = 255 - data[i];
|
||||
data[i + 1] = 255 - data[i + 1];
|
||||
data[i + 2] = 255 - data[i + 2];
|
||||
}
|
||||
|
||||
tempContext.putImageData(imageData, 0, 0);
|
||||
context.drawImage(
|
||||
tempCanvas,
|
||||
0,
|
||||
0,
|
||||
tempCanvas.width,
|
||||
tempCanvas.height,
|
||||
0,
|
||||
0,
|
||||
element.width,
|
||||
element.height,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (shouldInvertImage) {
|
||||
context.filter = DARK_THEME_FILTER;
|
||||
}
|
||||
|
||||
context.drawImage(
|
||||
img,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
0 /* hardcoded for the selection box*/,
|
||||
0,
|
||||
element.width,
|
||||
element.height,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
drawImagePlaceholder(element, context);
|
||||
drawImagePlaceholder(element, context, renderConfig.theme);
|
||||
}
|
||||
context.restore();
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
@@ -506,7 +555,10 @@ const drawElementOnCanvas = (
|
||||
context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr");
|
||||
context.save();
|
||||
context.font = getFontString(element);
|
||||
context.fillStyle = element.strokeColor;
|
||||
context.fillStyle =
|
||||
renderConfig.theme === THEME.DARK
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
: element.strokeColor;
|
||||
context.textAlign = element.textAlign as CanvasTextAlign;
|
||||
|
||||
// Canvas does not support multiline text by default
|
||||
@@ -759,12 +811,17 @@ export const renderElement = (
|
||||
context.fillStyle = "rgba(0, 0, 200, 0.04)";
|
||||
|
||||
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
|
||||
context.strokeStyle = FRAME_STYLE.strokeColor;
|
||||
context.strokeStyle =
|
||||
appState.theme === THEME.DARK
|
||||
? applyDarkModeFilter(FRAME_STYLE.strokeColor)
|
||||
: FRAME_STYLE.strokeColor;
|
||||
|
||||
// TODO change later to only affect AI frames
|
||||
if (isMagicFrameElement(element)) {
|
||||
context.strokeStyle =
|
||||
appState.theme === THEME.LIGHT ? "#7affd7" : "#1d8264";
|
||||
appState.theme === THEME.LIGHT
|
||||
? "#7affd7"
|
||||
: applyDarkModeFilter("#1d8264");
|
||||
}
|
||||
|
||||
if (FRAME_STYLE.radius && context.roundRect) {
|
||||
@@ -787,11 +844,6 @@ export const renderElement = (
|
||||
break;
|
||||
}
|
||||
case "freedraw": {
|
||||
// TODO investigate if we can do this in situ. Right now we need to call
|
||||
// beforehand because math helpers (such as getElementAbsoluteCoords)
|
||||
// rely on existing shapes
|
||||
ShapeCache.generateElementShape(element, null);
|
||||
|
||||
if (renderConfig.isExporting) {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
const cx = (x1 + x2) / 2 + appState.scrollX;
|
||||
@@ -835,10 +887,6 @@ export const renderElement = (
|
||||
case "text":
|
||||
case "iframe":
|
||||
case "embeddable": {
|
||||
// TODO investigate if we can do this in situ. Right now we need to call
|
||||
// beforehand because math helpers (such as getElementAbsoluteCoords)
|
||||
// rely on existing shapes
|
||||
ShapeCache.generateElementShape(element, renderConfig);
|
||||
if (renderConfig.isExporting) {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
const cx = (x1 + x2) / 2 + appState.scrollX;
|
||||
@@ -861,9 +909,6 @@ export const renderElement = (
|
||||
context.save();
|
||||
context.translate(cx, cy);
|
||||
|
||||
if (shouldResetImageFilter(element, renderConfig, appState)) {
|
||||
context.filter = "none";
|
||||
}
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
|
||||
if (isArrowElement(element) && boundTextElement) {
|
||||
@@ -1026,23 +1071,6 @@ export const renderElement = (
|
||||
context.globalAlpha = 1;
|
||||
};
|
||||
|
||||
export const pathsCache = new WeakMap<ExcalidrawFreeDrawElement, Path2D>([]);
|
||||
|
||||
export function generateFreeDrawShape(element: ExcalidrawFreeDrawElement) {
|
||||
const svgPathData = getFreeDrawSvgPath(element);
|
||||
const path = new Path2D(svgPathData);
|
||||
pathsCache.set(element, path);
|
||||
return path;
|
||||
}
|
||||
|
||||
export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
|
||||
return pathsCache.get(element);
|
||||
}
|
||||
|
||||
export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
|
||||
return getSvgPathFromStroke(getFreedrawOutlinePoints(element));
|
||||
}
|
||||
|
||||
export function getFreedrawOutlineAsSegments(
|
||||
element: ExcalidrawFreeDrawElement,
|
||||
points: [number, number][],
|
||||
@@ -1098,57 +1126,3 @@ export function getFreedrawOutlineAsSegments(
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export function getFreedrawOutlinePoints(element: ExcalidrawFreeDrawElement) {
|
||||
// If input points are empty (should they ever be?) return a dot
|
||||
const inputPoints = element.simulatePressure
|
||||
? element.points
|
||||
: element.points.length
|
||||
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
|
||||
: [[0, 0, 0.5]];
|
||||
|
||||
// Consider changing the options for simulated pressure vs real pressure
|
||||
const options: StrokeOptions = {
|
||||
simulatePressure: element.simulatePressure,
|
||||
size: element.strokeWidth * 4.25,
|
||||
thinning: 0.6,
|
||||
smoothing: 0.5,
|
||||
streamline: 0.5,
|
||||
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
|
||||
last: true,
|
||||
};
|
||||
|
||||
return getStroke(inputPoints as number[][], options) as [number, number][];
|
||||
}
|
||||
|
||||
function med(A: number[], B: number[]) {
|
||||
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
|
||||
}
|
||||
|
||||
// Trim SVG path data so number are each two decimal points. This
|
||||
// improves SVG exports, and prevents rendering errors on points
|
||||
// with long decimals.
|
||||
const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g;
|
||||
|
||||
function getSvgPathFromStroke(points: number[][]): string {
|
||||
if (!points.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const max = points.length - 1;
|
||||
|
||||
return points
|
||||
.reduce(
|
||||
(acc, point, i, arr) => {
|
||||
if (i === max) {
|
||||
acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
|
||||
} else {
|
||||
acc.push(point, med(point, arr[i + 1]));
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
["M", points[0], "Q"],
|
||||
)
|
||||
.join(" ")
|
||||
.replace(TO_FIXED_PRECISION, "$1");
|
||||
}
|
||||
|
||||
+362
-124
@@ -1,4 +1,5 @@
|
||||
import { simplify } from "points-on-curve";
|
||||
import { getStroke } from "perfect-freehand";
|
||||
|
||||
import {
|
||||
type GeometricShape,
|
||||
@@ -17,10 +18,12 @@ import {
|
||||
} from "@excalidraw/math";
|
||||
import {
|
||||
ROUGHNESS,
|
||||
THEME,
|
||||
isTransparent,
|
||||
assertNever,
|
||||
COLOR_PALETTE,
|
||||
LINE_POLYGON_POINT_MERGE_DISTANCE,
|
||||
applyDarkModeFilter,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { RoughGenerator } from "roughjs/bin/generator";
|
||||
@@ -36,6 +39,7 @@ import type {
|
||||
import type {
|
||||
ElementShape,
|
||||
ElementShapes,
|
||||
SVGPathString,
|
||||
} from "@excalidraw/excalidraw/scene/types";
|
||||
|
||||
import { elementWithCanvasCache } from "./renderElement";
|
||||
@@ -52,7 +56,6 @@ import { getCornerRadius, isPathALoop } from "./utils";
|
||||
import { headingForPointIsHorizontal } from "./heading";
|
||||
|
||||
import { canChangeRoundness } from "./comparisons";
|
||||
import { generateFreeDrawShape } from "./renderElement";
|
||||
import {
|
||||
getArrowheadPoints,
|
||||
getCenterForBounds,
|
||||
@@ -66,10 +69,10 @@ import type {
|
||||
NonDeletedExcalidrawElement,
|
||||
ExcalidrawSelectionElement,
|
||||
ExcalidrawLinearElement,
|
||||
Arrowhead,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ElementsMap,
|
||||
ExcalidrawLineElement,
|
||||
Arrowhead,
|
||||
} from "./types";
|
||||
|
||||
import type { Drawable, Options } from "roughjs/bin/core";
|
||||
@@ -77,29 +80,32 @@ import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||
|
||||
export class ShapeCache {
|
||||
private static rg = new RoughGenerator();
|
||||
private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
|
||||
private static cache = new WeakMap<
|
||||
ExcalidrawElement,
|
||||
{ shape: ElementShape; theme: AppState["theme"] }
|
||||
>();
|
||||
|
||||
/**
|
||||
* Retrieves shape from cache if available. Use this only if shape
|
||||
* is optional and you have a fallback in case it's not cached.
|
||||
*/
|
||||
public static get = <T extends ExcalidrawElement>(element: T) => {
|
||||
return ShapeCache.cache.get(
|
||||
element,
|
||||
) as T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]] | undefined
|
||||
: ElementShape | undefined;
|
||||
public static get = <T extends ExcalidrawElement>(
|
||||
element: T,
|
||||
theme: AppState["theme"] | null,
|
||||
) => {
|
||||
const cached = ShapeCache.cache.get(element);
|
||||
if (cached && (theme === null || cached.theme === theme)) {
|
||||
return cached.shape as T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]] | undefined
|
||||
: ElementShape | undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
public static set = <T extends ExcalidrawElement>(
|
||||
element: T,
|
||||
shape: T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]]
|
||||
: Drawable,
|
||||
) => ShapeCache.cache.set(element, shape);
|
||||
|
||||
public static delete = (element: ExcalidrawElement) =>
|
||||
public static delete = (element: ExcalidrawElement) => {
|
||||
ShapeCache.cache.delete(element);
|
||||
elementWithCanvasCache.delete(element);
|
||||
};
|
||||
|
||||
public static destroy = () => {
|
||||
ShapeCache.cache = new WeakMap();
|
||||
@@ -117,12 +123,13 @@ export class ShapeCache {
|
||||
isExporting: boolean;
|
||||
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
||||
embedsValidationStatus: EmbedsValidationStatus;
|
||||
theme: AppState["theme"];
|
||||
} | null,
|
||||
) => {
|
||||
// when exporting, always regenerated to guarantee the latest shape
|
||||
const cachedShape = renderConfig?.isExporting
|
||||
? undefined
|
||||
: ShapeCache.get(element);
|
||||
: ShapeCache.get(element, renderConfig ? renderConfig.theme : null);
|
||||
|
||||
// `null` indicates no rc shape applicable for this element type,
|
||||
// but it's considered a valid cache value (= do not regenerate)
|
||||
@@ -132,19 +139,25 @@ export class ShapeCache {
|
||||
|
||||
elementWithCanvasCache.delete(element);
|
||||
|
||||
const shape = generateElementShape(
|
||||
const shape = _generateElementShape(
|
||||
element,
|
||||
ShapeCache.rg,
|
||||
renderConfig || {
|
||||
isExporting: false,
|
||||
canvasBackgroundColor: COLOR_PALETTE.white,
|
||||
embedsValidationStatus: null,
|
||||
theme: THEME.LIGHT,
|
||||
},
|
||||
) as T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]]
|
||||
: Drawable | null;
|
||||
|
||||
ShapeCache.cache.set(element, shape);
|
||||
if (!renderConfig?.isExporting) {
|
||||
ShapeCache.cache.set(element, {
|
||||
shape,
|
||||
theme: renderConfig?.theme || THEME.LIGHT,
|
||||
});
|
||||
}
|
||||
|
||||
return shape;
|
||||
};
|
||||
@@ -180,6 +193,7 @@ function adjustRoughness(element: ExcalidrawElement): number {
|
||||
export const generateRoughOptions = (
|
||||
element: ExcalidrawElement,
|
||||
continuousPath = false,
|
||||
isDarkMode: boolean = false,
|
||||
): Options => {
|
||||
const options: Options = {
|
||||
seed: element.seed,
|
||||
@@ -204,7 +218,9 @@ export const generateRoughOptions = (
|
||||
fillWeight: element.strokeWidth / 2,
|
||||
hachureGap: element.strokeWidth * 4,
|
||||
roughness: adjustRoughness(element),
|
||||
stroke: element.strokeColor,
|
||||
stroke: isDarkMode
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
: element.strokeColor,
|
||||
preserveVertices:
|
||||
continuousPath || element.roughness < ROUGHNESS.cartoonist,
|
||||
};
|
||||
@@ -218,6 +234,8 @@ export const generateRoughOptions = (
|
||||
options.fillStyle = element.fillStyle;
|
||||
options.fill = isTransparent(element.backgroundColor)
|
||||
? undefined
|
||||
: isDarkMode
|
||||
? applyDarkModeFilter(element.backgroundColor)
|
||||
: element.backgroundColor;
|
||||
if (element.type === "ellipse") {
|
||||
options.curveFitting = 1;
|
||||
@@ -231,6 +249,8 @@ export const generateRoughOptions = (
|
||||
options.fill =
|
||||
element.backgroundColor === "transparent"
|
||||
? undefined
|
||||
: isDarkMode
|
||||
? applyDarkModeFilter(element.backgroundColor)
|
||||
: element.backgroundColor;
|
||||
}
|
||||
return options;
|
||||
@@ -276,6 +296,82 @@ const modifyIframeLikeForRoughOptions = (
|
||||
return element;
|
||||
};
|
||||
|
||||
const generateArrowheadCardinalityOne = (
|
||||
generator: RoughGenerator,
|
||||
arrowheadPoints: number[] | null,
|
||||
lineOptions: Options,
|
||||
) => {
|
||||
if (arrowheadPoints === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [, , x3, y3, x4, y4] = arrowheadPoints;
|
||||
|
||||
return [generator.line(x3, y3, x4, y4, lineOptions)];
|
||||
};
|
||||
|
||||
const generateArrowheadLinesToTip = (
|
||||
generator: RoughGenerator,
|
||||
arrowheadPoints: number[] | null,
|
||||
lineOptions: Options,
|
||||
) => {
|
||||
if (arrowheadPoints === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
|
||||
|
||||
return [
|
||||
generator.line(x3, y3, x2, y2, lineOptions),
|
||||
generator.line(x4, y4, x2, y2, lineOptions),
|
||||
];
|
||||
};
|
||||
|
||||
const getArrowheadLineOptions = (
|
||||
element: ExcalidrawLinearElement,
|
||||
options: Options,
|
||||
) => {
|
||||
const lineOptions = { ...options };
|
||||
|
||||
if (element.strokeStyle === "dotted") {
|
||||
// for dotted arrows caps, reduce gap to make it more legible
|
||||
const dash = getDashArrayDotted(element.strokeWidth - 1);
|
||||
lineOptions.strokeLineDash = [dash[0], dash[1] - 1];
|
||||
} else {
|
||||
// for solid/dashed, keep solid arrow cap
|
||||
delete lineOptions.strokeLineDash;
|
||||
}
|
||||
lineOptions.roughness = Math.min(1, lineOptions.roughness || 0);
|
||||
|
||||
return lineOptions;
|
||||
};
|
||||
|
||||
const generateArrowheadOutlineCircle = (
|
||||
generator: RoughGenerator,
|
||||
options: Options,
|
||||
strokeColor: string,
|
||||
arrowheadPoints: number[] | null,
|
||||
fill: string,
|
||||
diameterScale = 1,
|
||||
) => {
|
||||
if (arrowheadPoints === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [x, y, diameter] = arrowheadPoints;
|
||||
const circleOptions = {
|
||||
...options,
|
||||
fill,
|
||||
fillStyle: "solid" as const,
|
||||
stroke: strokeColor,
|
||||
roughness: Math.min(0.5, options.roughness || 0),
|
||||
};
|
||||
|
||||
delete circleOptions.strokeLineDash;
|
||||
|
||||
return [generator.circle(x, y, diameter * diameterScale, circleOptions)];
|
||||
};
|
||||
|
||||
const getArrowheadShapes = (
|
||||
element: ExcalidrawLinearElement,
|
||||
shape: Drawable[],
|
||||
@@ -284,60 +380,56 @@ const getArrowheadShapes = (
|
||||
generator: RoughGenerator,
|
||||
options: Options,
|
||||
canvasBackgroundColor: string,
|
||||
isDarkMode: boolean,
|
||||
) => {
|
||||
const arrowheadPoints = getArrowheadPoints(
|
||||
element,
|
||||
shape,
|
||||
position,
|
||||
arrowhead,
|
||||
);
|
||||
|
||||
if (arrowheadPoints === null) {
|
||||
if (arrowhead === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const generateCrowfootOne = (
|
||||
arrowheadPoints: number[] | null,
|
||||
options: Options,
|
||||
) => {
|
||||
if (arrowheadPoints === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [, , x3, y3, x4, y4] = arrowheadPoints;
|
||||
|
||||
return [generator.line(x3, y3, x4, y4, options)];
|
||||
};
|
||||
const strokeColor = isDarkMode
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
: element.strokeColor;
|
||||
const backgroundFillColor = isDarkMode
|
||||
? applyDarkModeFilter(canvasBackgroundColor)
|
||||
: canvasBackgroundColor;
|
||||
const cardinalityOneOrManyOffset = -0.25;
|
||||
const cardinalityZeroCircleScale = 0.8;
|
||||
|
||||
switch (arrowhead) {
|
||||
case "dot":
|
||||
case "circle":
|
||||
case "circle_outline": {
|
||||
const [x, y, diameter] = arrowheadPoints;
|
||||
|
||||
// always use solid stroke for arrowhead
|
||||
delete options.strokeLineDash;
|
||||
|
||||
return [
|
||||
generator.circle(x, y, diameter, {
|
||||
...options,
|
||||
fill:
|
||||
arrowhead === "circle_outline"
|
||||
? canvasBackgroundColor
|
||||
: element.strokeColor,
|
||||
|
||||
fillStyle: "solid",
|
||||
stroke: element.strokeColor,
|
||||
roughness: Math.min(0.5, options.roughness || 0),
|
||||
}),
|
||||
];
|
||||
return generateArrowheadOutlineCircle(
|
||||
generator,
|
||||
options,
|
||||
strokeColor,
|
||||
getArrowheadPoints(element, shape, position, arrowhead),
|
||||
arrowhead === "circle_outline" ? backgroundFillColor : strokeColor,
|
||||
);
|
||||
}
|
||||
case "triangle":
|
||||
case "triangle_outline": {
|
||||
const arrowheadPoints = getArrowheadPoints(
|
||||
element,
|
||||
shape,
|
||||
position,
|
||||
arrowhead,
|
||||
);
|
||||
|
||||
if (arrowheadPoints === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [x, y, x2, y2, x3, y3] = arrowheadPoints;
|
||||
const triangleOptions = {
|
||||
...options,
|
||||
fill:
|
||||
arrowhead === "triangle_outline" ? backgroundFillColor : strokeColor,
|
||||
fillStyle: "solid" as const,
|
||||
roughness: Math.min(1, options.roughness || 0),
|
||||
};
|
||||
|
||||
// always use solid stroke for arrowhead
|
||||
delete options.strokeLineDash;
|
||||
delete triangleOptions.strokeLineDash;
|
||||
|
||||
return [
|
||||
generator.polygon(
|
||||
@@ -347,24 +439,34 @@ const getArrowheadShapes = (
|
||||
[x3, y3],
|
||||
[x, y],
|
||||
],
|
||||
{
|
||||
...options,
|
||||
fill:
|
||||
arrowhead === "triangle_outline"
|
||||
? canvasBackgroundColor
|
||||
: element.strokeColor,
|
||||
fillStyle: "solid",
|
||||
roughness: Math.min(1, options.roughness || 0),
|
||||
},
|
||||
triangleOptions,
|
||||
),
|
||||
];
|
||||
}
|
||||
case "diamond":
|
||||
case "diamond_outline": {
|
||||
const arrowheadPoints = getArrowheadPoints(
|
||||
element,
|
||||
shape,
|
||||
position,
|
||||
arrowhead,
|
||||
);
|
||||
|
||||
if (arrowheadPoints === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [x, y, x2, y2, x3, y3, x4, y4] = arrowheadPoints;
|
||||
const diamondOptions = {
|
||||
...options,
|
||||
fill:
|
||||
arrowhead === "diamond_outline" ? backgroundFillColor : strokeColor,
|
||||
fillStyle: "solid" as const,
|
||||
roughness: Math.min(1, options.roughness || 0),
|
||||
};
|
||||
|
||||
// always use solid stroke for arrowhead
|
||||
delete options.strokeLineDash;
|
||||
delete diamondOptions.strokeLineDash;
|
||||
|
||||
return [
|
||||
generator.polygon(
|
||||
@@ -375,46 +477,106 @@ const getArrowheadShapes = (
|
||||
[x4, y4],
|
||||
[x, y],
|
||||
],
|
||||
{
|
||||
...options,
|
||||
fill:
|
||||
arrowhead === "diamond_outline"
|
||||
? canvasBackgroundColor
|
||||
: element.strokeColor,
|
||||
fillStyle: "solid",
|
||||
roughness: Math.min(1, options.roughness || 0),
|
||||
},
|
||||
diamondOptions,
|
||||
),
|
||||
];
|
||||
}
|
||||
case "cardinality_one":
|
||||
return generateArrowheadCardinalityOne(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, arrowhead),
|
||||
getArrowheadLineOptions(element, options),
|
||||
);
|
||||
case "cardinality_many":
|
||||
return generateArrowheadLinesToTip(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, arrowhead),
|
||||
getArrowheadLineOptions(element, options),
|
||||
);
|
||||
case "cardinality_one_or_many": {
|
||||
const lineOptions = getArrowheadLineOptions(element, options);
|
||||
|
||||
return [
|
||||
...generateArrowheadLinesToTip(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, "cardinality_many"),
|
||||
lineOptions,
|
||||
),
|
||||
...generateArrowheadCardinalityOne(
|
||||
generator,
|
||||
getArrowheadPoints(
|
||||
element,
|
||||
shape,
|
||||
position,
|
||||
"cardinality_one",
|
||||
cardinalityOneOrManyOffset,
|
||||
),
|
||||
lineOptions,
|
||||
),
|
||||
];
|
||||
}
|
||||
case "cardinality_exactly_one": {
|
||||
const lineOptions = getArrowheadLineOptions(element, options);
|
||||
|
||||
return [
|
||||
...generateArrowheadCardinalityOne(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, "cardinality_one", -0.5),
|
||||
lineOptions,
|
||||
),
|
||||
...generateArrowheadCardinalityOne(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, "cardinality_one"),
|
||||
lineOptions,
|
||||
),
|
||||
];
|
||||
}
|
||||
case "cardinality_zero_or_one": {
|
||||
const lineOptions = getArrowheadLineOptions(element, options);
|
||||
|
||||
return [
|
||||
...generateArrowheadOutlineCircle(
|
||||
generator,
|
||||
options,
|
||||
strokeColor,
|
||||
getArrowheadPoints(element, shape, position, "circle_outline", 1.5),
|
||||
backgroundFillColor,
|
||||
cardinalityZeroCircleScale,
|
||||
),
|
||||
...generateArrowheadCardinalityOne(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, "cardinality_one", -0.5),
|
||||
lineOptions,
|
||||
),
|
||||
];
|
||||
}
|
||||
case "cardinality_zero_or_many": {
|
||||
const lineOptions = getArrowheadLineOptions(element, options);
|
||||
|
||||
return [
|
||||
...generateArrowheadLinesToTip(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, "cardinality_many"),
|
||||
lineOptions,
|
||||
),
|
||||
...generateArrowheadOutlineCircle(
|
||||
generator,
|
||||
options,
|
||||
strokeColor,
|
||||
getArrowheadPoints(element, shape, position, "circle_outline", 1.5),
|
||||
backgroundFillColor,
|
||||
cardinalityZeroCircleScale,
|
||||
),
|
||||
];
|
||||
}
|
||||
case "crowfoot_one":
|
||||
return generateCrowfootOne(arrowheadPoints, options);
|
||||
case "bar":
|
||||
case "arrow":
|
||||
case "crowfoot_many":
|
||||
case "crowfoot_one_or_many":
|
||||
default: {
|
||||
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
|
||||
|
||||
if (element.strokeStyle === "dotted") {
|
||||
// for dotted arrows caps, reduce gap to make it more legible
|
||||
const dash = getDashArrayDotted(element.strokeWidth - 1);
|
||||
options.strokeLineDash = [dash[0], dash[1] - 1];
|
||||
} else {
|
||||
// for solid/dashed, keep solid arrow cap
|
||||
delete options.strokeLineDash;
|
||||
}
|
||||
options.roughness = Math.min(1, options.roughness || 0);
|
||||
return [
|
||||
generator.line(x3, y3, x2, y2, options),
|
||||
generator.line(x4, y4, x2, y2, options),
|
||||
...(arrowhead === "crowfoot_one_or_many"
|
||||
? generateCrowfootOne(
|
||||
getArrowheadPoints(element, shape, position, "crowfoot_one"),
|
||||
options,
|
||||
)
|
||||
: []),
|
||||
];
|
||||
return generateArrowheadLinesToTip(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, arrowhead),
|
||||
getArrowheadLineOptions(element, options),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -602,19 +764,22 @@ export const generateLinearCollisionShape = (
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
const generateElementShape = (
|
||||
const _generateElementShape = (
|
||||
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
generator: RoughGenerator,
|
||||
{
|
||||
isExporting,
|
||||
canvasBackgroundColor,
|
||||
embedsValidationStatus,
|
||||
theme,
|
||||
}: {
|
||||
isExporting: boolean;
|
||||
canvasBackgroundColor: string;
|
||||
embedsValidationStatus: EmbedsValidationStatus | null;
|
||||
theme?: AppState["theme"];
|
||||
},
|
||||
): Drawable | Drawable[] | null => {
|
||||
): ElementShape => {
|
||||
const isDarkMode = theme === THEME.DARK;
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "iframe":
|
||||
@@ -640,6 +805,7 @@ const generateElementShape = (
|
||||
embedsValidationStatus,
|
||||
),
|
||||
true,
|
||||
isDarkMode,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@@ -655,6 +821,7 @@ const generateElementShape = (
|
||||
embedsValidationStatus,
|
||||
),
|
||||
false,
|
||||
isDarkMode,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -692,7 +859,7 @@ const generateElementShape = (
|
||||
C ${topX} ${topY}, ${topX} ${topY}, ${topX + verticalRadius} ${
|
||||
topY + horizontalRadius
|
||||
}`,
|
||||
generateRoughOptions(element, true),
|
||||
generateRoughOptions(element, true, isDarkMode),
|
||||
);
|
||||
} else {
|
||||
shape = generator.polygon(
|
||||
@@ -702,7 +869,7 @@ const generateElementShape = (
|
||||
[bottomX, bottomY],
|
||||
[leftX, leftY],
|
||||
],
|
||||
generateRoughOptions(element),
|
||||
generateRoughOptions(element, false, isDarkMode),
|
||||
);
|
||||
}
|
||||
return shape;
|
||||
@@ -713,14 +880,14 @@ const generateElementShape = (
|
||||
element.height / 2,
|
||||
element.width,
|
||||
element.height,
|
||||
generateRoughOptions(element),
|
||||
generateRoughOptions(element, false, isDarkMode),
|
||||
);
|
||||
return shape;
|
||||
}
|
||||
case "line":
|
||||
case "arrow": {
|
||||
let shape: ElementShapes[typeof element.type];
|
||||
const options = generateRoughOptions(element);
|
||||
const options = generateRoughOptions(element, false, isDarkMode);
|
||||
|
||||
// points array can be empty in the beginning, so it is important to add
|
||||
// initial position to it
|
||||
@@ -745,7 +912,7 @@ const generateElementShape = (
|
||||
shape = [
|
||||
generator.path(
|
||||
generateElbowArrowShape(points, 16),
|
||||
generateRoughOptions(element, true),
|
||||
generateRoughOptions(element, true, isDarkMode),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -778,6 +945,7 @@ const generateElementShape = (
|
||||
generator,
|
||||
options,
|
||||
canvasBackgroundColor,
|
||||
isDarkMode,
|
||||
);
|
||||
shape.push(...shapes);
|
||||
}
|
||||
@@ -795,6 +963,7 @@ const generateElementShape = (
|
||||
generator,
|
||||
options,
|
||||
canvasBackgroundColor,
|
||||
isDarkMode,
|
||||
);
|
||||
shape.push(...shapes);
|
||||
}
|
||||
@@ -802,23 +971,28 @@ const generateElementShape = (
|
||||
return shape;
|
||||
}
|
||||
case "freedraw": {
|
||||
let shape: ElementShapes[typeof element.type];
|
||||
generateFreeDrawShape(element);
|
||||
// oredered in terms of z-index [background, stroke]
|
||||
const shapes: ElementShapes[typeof element.type] = [];
|
||||
|
||||
// (1) background fill (rc shape), optional
|
||||
if (isPathALoop(element.points)) {
|
||||
// generate rough polygon to fill freedraw shape
|
||||
const simplifiedPoints = simplify(
|
||||
element.points as Mutable<LocalPoint[]>,
|
||||
0.75,
|
||||
);
|
||||
shape = generator.curve(simplifiedPoints as [number, number][], {
|
||||
...generateRoughOptions(element),
|
||||
stroke: "none",
|
||||
});
|
||||
} else {
|
||||
shape = null;
|
||||
shapes.push(
|
||||
generator.curve(simplifiedPoints as [number, number][], {
|
||||
...generateRoughOptions(element, false, isDarkMode),
|
||||
stroke: "none",
|
||||
}),
|
||||
);
|
||||
}
|
||||
return shape;
|
||||
|
||||
// (2) stroke
|
||||
shapes.push(getFreeDrawSvgPath(element));
|
||||
|
||||
return shapes;
|
||||
}
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
@@ -925,9 +1099,7 @@ export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
return getPolygonShape(element);
|
||||
case "arrow":
|
||||
case "line": {
|
||||
const roughShape =
|
||||
ShapeCache.get(element)?.[0] ??
|
||||
ShapeCache.generateElementShape(element, null)[0];
|
||||
const roughShape = ShapeCache.generateElementShape(element, null)[0];
|
||||
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
||||
|
||||
return shouldTestInside(element)
|
||||
@@ -1003,3 +1175,69 @@ export const toggleLinePolygonState = (
|
||||
|
||||
return ret;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// freedraw shape helper
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// NOTE not cached (-> for SVG export)
|
||||
const getFreeDrawSvgPath = (element: ExcalidrawFreeDrawElement) => {
|
||||
return getSvgPathFromStroke(
|
||||
getFreedrawOutlinePoints(element),
|
||||
) as SVGPathString;
|
||||
};
|
||||
|
||||
export const getFreedrawOutlinePoints = (
|
||||
element: ExcalidrawFreeDrawElement,
|
||||
) => {
|
||||
// If input points are empty (should they ever be?) return a dot
|
||||
const inputPoints = element.simulatePressure
|
||||
? element.points
|
||||
: element.points.length
|
||||
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
|
||||
: [[0, 0, 0.5]];
|
||||
|
||||
return getStroke(inputPoints as number[][], {
|
||||
simulatePressure: element.simulatePressure,
|
||||
size: element.strokeWidth * 4.25,
|
||||
thinning: 0.6,
|
||||
smoothing: 0.5,
|
||||
streamline: 0.5,
|
||||
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
|
||||
last: true,
|
||||
}) as [number, number][];
|
||||
};
|
||||
|
||||
const med = (A: number[], B: number[]) => {
|
||||
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
|
||||
};
|
||||
|
||||
// Trim SVG path data so number are each two decimal points. This
|
||||
// improves SVG exports, and prevents rendering errors on points
|
||||
// with long decimals.
|
||||
const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g;
|
||||
|
||||
const getSvgPathFromStroke = (points: number[][]): string => {
|
||||
if (!points.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const max = points.length - 1;
|
||||
|
||||
return points
|
||||
.reduce(
|
||||
(acc, point, i, arr) => {
|
||||
if (i === max) {
|
||||
acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
|
||||
} else {
|
||||
acc.push(point, med(point, arr[i + 1]));
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
["M", points[0], "Q"],
|
||||
)
|
||||
.join(" ")
|
||||
.replace(TO_FIXED_PRECISION, "$1");
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
ValueOf,
|
||||
} from "@excalidraw/common/utility-types";
|
||||
|
||||
export type ChartType = "bar" | "line";
|
||||
export type ChartType = "bar" | "line" | "radar";
|
||||
export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
|
||||
export type FontFamilyKeys = keyof typeof FONT_FAMILY;
|
||||
export type FontFamilyValues = typeof FONT_FAMILY[FontFamilyKeys];
|
||||
@@ -303,19 +303,32 @@ export type PointsPositionUpdates = Map<
|
||||
{ point: LocalPoint; isDragging?: boolean }
|
||||
>;
|
||||
|
||||
export type CardinalityArrowhead =
|
||||
| "cardinality_one"
|
||||
| "cardinality_many"
|
||||
| "cardinality_one_or_many"
|
||||
| "cardinality_exactly_one"
|
||||
| "cardinality_zero_or_one"
|
||||
| "cardinality_zero_or_many";
|
||||
|
||||
export type ArrowheadLegacy =
|
||||
| "dot"
|
||||
| "crowfoot_one"
|
||||
| "crowfoot_many"
|
||||
| "crowfoot_one_or_many";
|
||||
|
||||
export type Arrowhead =
|
||||
| "arrow"
|
||||
| "bar"
|
||||
| "dot" // legacy. Do not use for new elements.
|
||||
| "circle"
|
||||
| "circle_outline"
|
||||
| "triangle"
|
||||
| "triangle_outline"
|
||||
| "diamond"
|
||||
| "diamond_outline"
|
||||
| "crowfoot_one"
|
||||
| "crowfoot_many"
|
||||
| "crowfoot_one_or_many";
|
||||
| CardinalityArrowhead;
|
||||
|
||||
export type AnyArrowhead = Arrowhead | ArrowheadLegacy;
|
||||
|
||||
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
|
||||
+152
-26
@@ -7,6 +7,7 @@ import {
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
bezierEquation,
|
||||
curve,
|
||||
curveCatmullRomCubicApproxPoints,
|
||||
curveOffsetPoints,
|
||||
@@ -27,19 +28,30 @@ import {
|
||||
|
||||
import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
|
||||
import type {
|
||||
AppState,
|
||||
NormalizedZoomValue,
|
||||
Zoom,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { elementCenterPoint, getDiamondPoints } from "./bounds";
|
||||
|
||||
import { generateLinearCollisionShape } from "./shape";
|
||||
|
||||
import { isPointInElement } from "./collision";
|
||||
import { hitElementItself, isPointInElement } from "./collision";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { isRectangularElement } from "./typeChecks";
|
||||
import { maxBindingDistance_simple } from "./binding";
|
||||
|
||||
import {
|
||||
getGlobalFixedPointForBindableElement,
|
||||
normalizeFixedPoint,
|
||||
} from "./binding";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
@@ -329,24 +341,10 @@ export function deconstructRectanguloidElement(
|
||||
return shape;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the **unrotated** building components of a diamond element
|
||||
* in the form of line segments and curves as a tuple, in this order.
|
||||
*
|
||||
* @param element The element to deconstruct
|
||||
* @param offset An optional offset
|
||||
* @returns Tuple of line **unrotated** segments (0) and curves (1)
|
||||
*/
|
||||
export function deconstructDiamondElement(
|
||||
export function getDiamondBaseCorners(
|
||||
element: ExcalidrawDiamondElement,
|
||||
offset: number = 0,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
const cachedShape = getElementShapesCacheEntry(element, offset);
|
||||
|
||||
if (cachedShape) {
|
||||
return cachedShape;
|
||||
}
|
||||
|
||||
): Curve<GlobalPoint>[] {
|
||||
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
||||
getDiamondPoints(element);
|
||||
const verticalRadius = element.roundness
|
||||
@@ -363,7 +361,7 @@ export function deconstructDiamondElement(
|
||||
pointFrom(element.x + leftX, element.y + leftY),
|
||||
];
|
||||
|
||||
const baseCorners = [
|
||||
return [
|
||||
curve(
|
||||
pointFrom<GlobalPoint>(
|
||||
right[0] - verticalRadius,
|
||||
@@ -413,6 +411,27 @@ export function deconstructDiamondElement(
|
||||
),
|
||||
), // TOP
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the **unrotated** building components of a diamond element
|
||||
* in the form of line segments and curves as a tuple, in this order.
|
||||
*
|
||||
* @param element The element to deconstruct
|
||||
* @param offset An optional offset
|
||||
* @returns Tuple of line **unrotated** segments (0) and curves (1)
|
||||
*/
|
||||
export function deconstructDiamondElement(
|
||||
element: ExcalidrawDiamondElement,
|
||||
offset: number = 0,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
const cachedShape = getElementShapesCacheEntry(element, offset);
|
||||
|
||||
if (cachedShape) {
|
||||
return cachedShape;
|
||||
}
|
||||
|
||||
const baseCorners = getDiamondBaseCorners(element, offset);
|
||||
|
||||
const corners = baseCorners.map(
|
||||
(corner) =>
|
||||
@@ -570,28 +589,131 @@ const getDiagonalsForBindableElement = (
|
||||
return [diagonalOne, diagonalTwo];
|
||||
};
|
||||
|
||||
export const getSnapOutlineMidPoint = (
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
zoom: AppState["zoom"],
|
||||
) => {
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
const sideMidpoints =
|
||||
element.type === "diamond"
|
||||
? getDiamondBaseCorners(element).map((curve) => {
|
||||
const point = bezierEquation(curve, 0.5);
|
||||
const rotatedPoint = pointRotateRads(point, center, element.angle);
|
||||
|
||||
return pointFrom<GlobalPoint>(rotatedPoint[0], rotatedPoint[1]);
|
||||
})
|
||||
: [
|
||||
// RIGHT midpoint
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.width,
|
||||
element.y + element.height / 2,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
// BOTTOM midpoint
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
// LEFT midpoint
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x, element.y + element.height / 2),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
// TOP midpoint
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x + element.width / 2, element.y),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
];
|
||||
const candidate = sideMidpoints.find(
|
||||
(midpoint) =>
|
||||
pointDistance(point, midpoint) <=
|
||||
maxBindingDistance_simple(zoom) + element.strokeWidth / 2 &&
|
||||
!hitElementItself({
|
||||
point,
|
||||
element,
|
||||
threshold: 0,
|
||||
elementsMap,
|
||||
overrideShouldTestInside: true,
|
||||
}),
|
||||
);
|
||||
|
||||
return candidate;
|
||||
};
|
||||
|
||||
export const projectFixedPointOntoDiagonal = (
|
||||
arrow: ExcalidrawArrowElement,
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawElement,
|
||||
element: ExcalidrawBindableElement,
|
||||
startOrEnd: "start" | "end",
|
||||
elementsMap: ElementsMap,
|
||||
zoom: AppState["zoom"],
|
||||
isMidpointSnappingEnabled: boolean = true,
|
||||
): GlobalPoint | null => {
|
||||
invariant(arrow.points.length >= 2, "Arrow must have at least two points");
|
||||
if (arrow.width < 3 && arrow.height < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isMidpointSnappingEnabled) {
|
||||
const sideMidPoint = getSnapOutlineMidPoint(
|
||||
point,
|
||||
element,
|
||||
elementsMap,
|
||||
zoom,
|
||||
);
|
||||
if (sideMidPoint) {
|
||||
return sideMidPoint;
|
||||
}
|
||||
}
|
||||
|
||||
// Do the projection onto the diagonals (or center lines
|
||||
// for non-rectangular shapes)
|
||||
const [diagonalOne, diagonalTwo] = getDiagonalsForBindableElement(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
const a = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
// To avoid working with stale arrow state, we use the opposite focus point
|
||||
// of the current endpoint, which will always be unchanged during moving of
|
||||
// the endpoint. This is only needed when the arrow has only two points.
|
||||
let a = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
arrow,
|
||||
startOrEnd === "start" ? 1 : arrow.points.length - 2,
|
||||
elementsMap,
|
||||
);
|
||||
if (arrow.points.length === 2) {
|
||||
const otherBinding =
|
||||
startOrEnd === "start" ? arrow.endBinding : arrow.startBinding;
|
||||
const otherBindable =
|
||||
otherBinding &&
|
||||
(elementsMap.get(otherBinding.elementId) as
|
||||
| ExcalidrawBindableElement
|
||||
| undefined);
|
||||
const otherFocusPoint =
|
||||
otherBinding &&
|
||||
otherBindable &&
|
||||
getGlobalFixedPointForBindableElement(
|
||||
normalizeFixedPoint(otherBinding.fixedPoint),
|
||||
otherBindable,
|
||||
elementsMap,
|
||||
);
|
||||
if (otherFocusPoint) {
|
||||
a = otherFocusPoint;
|
||||
}
|
||||
}
|
||||
|
||||
const b = pointFromVector<GlobalPoint>(
|
||||
vectorScale(
|
||||
vectorFromPoint(point, a),
|
||||
@@ -603,18 +725,22 @@ export const projectFixedPointOntoDiagonal = (
|
||||
),
|
||||
a,
|
||||
);
|
||||
const intersector = lineSegment<GlobalPoint>(point, b);
|
||||
const intersector = lineSegment<GlobalPoint>(b, a);
|
||||
const p1 = lineSegmentIntersectionPoints(diagonalOne, intersector);
|
||||
const p2 = lineSegmentIntersectionPoints(diagonalTwo, intersector);
|
||||
const d1 = p1 && pointDistance(a, p1);
|
||||
const d2 = p2 && pointDistance(a, p2);
|
||||
|
||||
let p = null;
|
||||
let projection = null;
|
||||
if (d1 != null && d2 != null) {
|
||||
p = d1 < d2 ? p1 : p2;
|
||||
projection = d1 < d2 ? p1 : p2;
|
||||
} else {
|
||||
p = p1 || p2 || null;
|
||||
projection = p1 || p2 || null;
|
||||
}
|
||||
|
||||
return p && isPointInElement(p, element, elementsMap) ? p : null;
|
||||
if (projection && isPointInElement(projection, element, elementsMap)) {
|
||||
return projection;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import {
|
||||
isLineSegment,
|
||||
lineSegment,
|
||||
pointDistanceSq,
|
||||
pointFrom,
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
} from "@excalidraw/math";
|
||||
import { type Bounds, isBounds } from "@excalidraw/common";
|
||||
import {
|
||||
getElementBounds,
|
||||
intersectElementWithLineSegment,
|
||||
isFreeDrawElement,
|
||||
isLinearElement,
|
||||
isPathALoop,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
|
||||
import type { Curve } from "@excalidraw/math";
|
||||
import type { LineSegment } from "@excalidraw/utils";
|
||||
|
||||
import { type Bounds, isBounds } from "./bounds";
|
||||
|
||||
// The global data holder to collect the debug operations
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -23,10 +31,69 @@ declare global {
|
||||
|
||||
export type DebugElement = {
|
||||
color: string;
|
||||
data: LineSegment<GlobalPoint> | Curve<GlobalPoint>;
|
||||
data: LineSegment<GlobalPoint> | Curve<GlobalPoint> | DebugPolygon;
|
||||
permanent: boolean;
|
||||
};
|
||||
|
||||
export type DebugPolygon = {
|
||||
type: "polygon";
|
||||
points: GlobalPoint[];
|
||||
fill?: boolean;
|
||||
close?: boolean;
|
||||
};
|
||||
|
||||
export const debugDrawHitVolume = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
options?: {
|
||||
rays?: number;
|
||||
color?: string;
|
||||
fill?: boolean;
|
||||
},
|
||||
) => {
|
||||
if (
|
||||
(isLinearElement(element) || isFreeDrawElement(element)) &&
|
||||
!isPathALoop(element.points)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
|
||||
const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
|
||||
const rays = options?.rays ?? 100;
|
||||
const radius = Math.max(x2 - x1, y2 - y1) * 2;
|
||||
const points: GlobalPoint[] = [];
|
||||
|
||||
for (let i = 0; i < rays; i += 1) {
|
||||
const angle = (i / rays) * Math.PI * 2;
|
||||
const end = pointFrom<GlobalPoint>(
|
||||
center[0] + Math.cos(angle) * radius,
|
||||
center[1] + Math.sin(angle) * radius,
|
||||
);
|
||||
const hits = intersectElementWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
lineSegment(center, end),
|
||||
);
|
||||
if (hits.length === 0) {
|
||||
continue;
|
||||
}
|
||||
hits.sort(pointDistanceSq);
|
||||
points.push(hits[0]);
|
||||
}
|
||||
|
||||
if (points.length >= 3) {
|
||||
debugDrawPolygon(points, {
|
||||
color: options?.color ?? "orange",
|
||||
fill: options?.fill ?? true,
|
||||
});
|
||||
} else {
|
||||
console.warn(
|
||||
`debugDrawHitVolume: could not compute hit volume for element ${element.id}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const debugDrawCubicBezier = (
|
||||
c: Curve<GlobalPoint>,
|
||||
opts?: {
|
||||
@@ -61,6 +128,31 @@ export const debugDrawLine = (
|
||||
);
|
||||
};
|
||||
|
||||
export const debugDrawPolygon = (
|
||||
points: GlobalPoint[],
|
||||
opts?: {
|
||||
color?: string;
|
||||
permanent?: boolean;
|
||||
fill?: boolean;
|
||||
close?: boolean;
|
||||
},
|
||||
) => {
|
||||
if (points.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
addToCurrentFrame({
|
||||
color: opts?.color ?? "orange",
|
||||
permanent: !!opts?.permanent,
|
||||
data: {
|
||||
type: "polygon",
|
||||
points,
|
||||
fill: opts?.fill,
|
||||
close: opts?.close,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const debugDrawPoint = (
|
||||
p: GlobalPoint,
|
||||
opts?: {
|
||||
@@ -101,7 +193,7 @@ export const debugDrawBounds = (
|
||||
permanent?: boolean;
|
||||
},
|
||||
) => {
|
||||
(isBounds(box) ? [box] : box).forEach((bbox) =>
|
||||
(isBounds(box) ? [box] : box).forEach((bbox: Bounds) =>
|
||||
debugDrawLine(
|
||||
[
|
||||
lineSegment(
|
||||
@@ -156,12 +156,11 @@ export const moveArrowAboveBindable = (
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
scene: Scene,
|
||||
hit?: NonDeletedExcalidrawElement,
|
||||
): readonly OrderedExcalidrawElement[] => {
|
||||
const hoveredElement = getHoveredElementForBinding(
|
||||
point,
|
||||
elements,
|
||||
elementsMap,
|
||||
);
|
||||
const hoveredElement = hit
|
||||
? hit
|
||||
: getHoveredElementForBinding(point, elements, elementsMap);
|
||||
|
||||
if (!hoveredElement) {
|
||||
return elements;
|
||||
|
||||
@@ -17,7 +17,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
|
||||
class="excalidraw-wysiwyg"
|
||||
data-type="wysiwyg"
|
||||
dir="auto"
|
||||
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, sans-serif, Segoe UI Emoji;"
|
||||
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, sans-serif, Segoe UI Emoji;"
|
||||
tabindex="0"
|
||||
wrap="off"
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { arrayToMap } from "@excalidraw/common";
|
||||
import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
import "@excalidraw/utils/test-utils";
|
||||
import { render } from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
import * as distance from "../src/distance";
|
||||
import { hitElementItself } from "../src/collision";
|
||||
|
||||
describe("check rotated elements can be hit:", () => {
|
||||
@@ -25,8 +28,6 @@ describe("check rotated elements can be hit:", () => {
|
||||
[-4, -302],
|
||||
] as LocalPoint[],
|
||||
});
|
||||
//const p = [120, -211];
|
||||
//const p = [0, 13];
|
||||
const hit = hitElementItself({
|
||||
point: pointFrom<GlobalPoint>(88, -68),
|
||||
element: window.h.elements[0],
|
||||
@@ -36,3 +37,182 @@ describe("check rotated elements can be hit:", () => {
|
||||
expect(hit).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hitElementItself cache", () => {
|
||||
beforeEach(async () => {
|
||||
// reset cache
|
||||
hitElementItself({
|
||||
point: pointFrom<GlobalPoint>(50, 50),
|
||||
element: API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
backgroundColor: "#ffffff",
|
||||
}),
|
||||
threshold: Infinity,
|
||||
elementsMap: new Map([]),
|
||||
});
|
||||
|
||||
localStorage.clear();
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
it("reuses cached result when threshold increases", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
backgroundColor: "#ffffff",
|
||||
});
|
||||
const elementsMap = arrayToMap([element]);
|
||||
const point = pointFrom<GlobalPoint>(100.5, 50);
|
||||
|
||||
const distanceSpy = jest.spyOn(distance, "distanceToElement");
|
||||
|
||||
expect(
|
||||
hitElementItself({
|
||||
point,
|
||||
element,
|
||||
threshold: 1,
|
||||
elementsMap,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(distanceSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(
|
||||
hitElementItself({
|
||||
point,
|
||||
element,
|
||||
threshold: 10,
|
||||
elementsMap,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(distanceSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
distanceSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not reuse cache when threshold decreases", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
backgroundColor: "transparent",
|
||||
});
|
||||
const elementsMap = arrayToMap([element]);
|
||||
const point = pointFrom<GlobalPoint>(105, 50);
|
||||
|
||||
const distanceSpy = jest.spyOn(distance, "distanceToElement");
|
||||
|
||||
expect(
|
||||
hitElementItself({
|
||||
point,
|
||||
element,
|
||||
threshold: 10,
|
||||
elementsMap,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(distanceSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(
|
||||
hitElementItself({
|
||||
point,
|
||||
element,
|
||||
threshold: 6,
|
||||
elementsMap,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(distanceSpy).toHaveBeenCalledTimes(2);
|
||||
distanceSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("invalidates cache when element version changes", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
backgroundColor: "#ffffff",
|
||||
});
|
||||
const elementsMap = arrayToMap([element]);
|
||||
const point = pointFrom<GlobalPoint>(100.5, 50);
|
||||
|
||||
const distanceSpy = jest.spyOn(distance, "distanceToElement");
|
||||
|
||||
expect(
|
||||
hitElementItself({
|
||||
point,
|
||||
element,
|
||||
threshold: 1,
|
||||
elementsMap,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(distanceSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
const movedElement = {
|
||||
...element,
|
||||
version: element.version + 1,
|
||||
versionNonce: element.versionNonce + 1,
|
||||
};
|
||||
|
||||
expect(
|
||||
hitElementItself({
|
||||
point,
|
||||
element: movedElement,
|
||||
threshold: 1,
|
||||
elementsMap,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(distanceSpy).toHaveBeenCalledTimes(2);
|
||||
distanceSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("override does not affect caching", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
backgroundColor: "transparent",
|
||||
});
|
||||
const elementsMap = arrayToMap([element]);
|
||||
const point = pointFrom<GlobalPoint>(50, 50);
|
||||
|
||||
const distanceSpy = jest.spyOn(distance, "distanceToElement");
|
||||
|
||||
expect(
|
||||
hitElementItself({
|
||||
point,
|
||||
element,
|
||||
threshold: 10,
|
||||
elementsMap,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(distanceSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(
|
||||
hitElementItself({
|
||||
point,
|
||||
element,
|
||||
threshold: 10,
|
||||
elementsMap,
|
||||
overrideShouldTestInside: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getEmbedLink } from "../src/embeddable";
|
||||
import { embeddableURLValidator, getEmbedLink } from "../src/embeddable";
|
||||
|
||||
describe("YouTube timestamp parsing", () => {
|
||||
it("should parse YouTube URLs with timestamp in seconds", () => {
|
||||
@@ -151,3 +151,83 @@ describe("YouTube timestamp parsing", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Google Drive video embedding", () => {
|
||||
it.each([
|
||||
{
|
||||
url: "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?usp=sharing",
|
||||
expectedLink:
|
||||
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview",
|
||||
},
|
||||
{
|
||||
url: "https://drive.google.com/open?id=1AbCdEfGhIjKlMnOpQrStUvWxYz123456",
|
||||
expectedLink:
|
||||
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview",
|
||||
},
|
||||
{
|
||||
url: "https://drive.google.com/uc?export=download&id=1AbCdEfGhIjKlMnOpQrStUvWxYz123456",
|
||||
expectedLink:
|
||||
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview",
|
||||
},
|
||||
])("should normalize Google Drive link: $url", ({ url, expectedLink }) => {
|
||||
const result = getEmbedLink(url);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("video");
|
||||
if (result?.type === "video" || result?.type === "generic") {
|
||||
expect(result.link).toBe(expectedLink);
|
||||
}
|
||||
expect(result?.intrinsicSize).toEqual({ w: 560, h: 315 });
|
||||
});
|
||||
|
||||
it("should preserve resourcekey when available", () => {
|
||||
const url =
|
||||
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?resourcekey=0-abcdef123456";
|
||||
const result = getEmbedLink(url);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("video");
|
||||
if (result?.type === "video" || result?.type === "generic") {
|
||||
expect(result.link).toBe(
|
||||
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?resourcekey=0-abcdef123456",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should preserve timestamp when available", () => {
|
||||
const url =
|
||||
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?t=9";
|
||||
const result = getEmbedLink(url);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("video");
|
||||
if (result?.type === "video" || result?.type === "generic") {
|
||||
expect(result.link).toBe(
|
||||
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?t=9",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should preserve resourcekey and timestamp together", () => {
|
||||
const url =
|
||||
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?resourcekey=0-abcdef123456&t=9";
|
||||
const result = getEmbedLink(url);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("video");
|
||||
if (result?.type === "video" || result?.type === "generic") {
|
||||
expect(result.link).toBe(
|
||||
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?resourcekey=0-abcdef123456&t=9",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should validate Google Drive domain by default", () => {
|
||||
expect(
|
||||
embeddableURLValidator(
|
||||
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view",
|
||||
undefined,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
convertToExcalidrawElements,
|
||||
Excalidraw,
|
||||
} from "@excalidraw/excalidraw";
|
||||
import { arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
import { Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
@@ -10,6 +11,8 @@ import {
|
||||
render,
|
||||
} from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
import { shouldApplyFrameClip } from "../src/frame";
|
||||
|
||||
import type { ExcalidrawElement } from "../src/types";
|
||||
|
||||
const { h } = window;
|
||||
@@ -561,3 +564,78 @@ describe("adding elements to frames", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("frame clipping", () => {
|
||||
const getAppStateForFrameClip = () =>
|
||||
({
|
||||
frameRendering: {
|
||||
enabled: true,
|
||||
clip: true,
|
||||
},
|
||||
selectedElementsAreBeingDragged: false,
|
||||
selectedElementIds: {},
|
||||
frameToHighlight: null,
|
||||
editingGroupId: null,
|
||||
} as any);
|
||||
|
||||
it("clips a frame child even when fully outside the frame bounds", () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
id: "frame",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
const outsideChild = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "outside-child",
|
||||
x: 250,
|
||||
y: 250,
|
||||
width: 50,
|
||||
height: 50,
|
||||
frameId: frame.id,
|
||||
});
|
||||
|
||||
const elementsMap = arrayToMap([outsideChild, frame]);
|
||||
|
||||
expect(
|
||||
shouldApplyFrameClip(
|
||||
outsideChild,
|
||||
frame,
|
||||
getAppStateForFrameClip(),
|
||||
elementsMap,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not clip an outside element that does not belong to the frame", () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
id: "frame",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
const outsideElement = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "outside",
|
||||
x: 250,
|
||||
y: 250,
|
||||
width: 50,
|
||||
height: 50,
|
||||
});
|
||||
|
||||
const elementsMap = arrayToMap([outsideElement, frame]);
|
||||
|
||||
expect(
|
||||
shouldApplyFrameClip(
|
||||
outsideElement,
|
||||
frame,
|
||||
getAppStateForFrameClip(),
|
||||
elementsMap,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -218,7 +218,7 @@ describe("Test Linear Elements", () => {
|
||||
// drag line from midpoint
|
||||
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`8`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
expect(line.points.length).toEqual(3);
|
||||
expect(line.points).toMatchInlineSnapshot(`
|
||||
[
|
||||
@@ -378,7 +378,7 @@ describe("Test Linear Elements", () => {
|
||||
// drag line from midpoint
|
||||
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`11`,
|
||||
`12`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
@@ -419,7 +419,7 @@ describe("Test Linear Elements", () => {
|
||||
fireEvent.click(screen.getByTitle("Round"));
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`9`,
|
||||
`10`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
|
||||
@@ -480,7 +480,7 @@ describe("Test Linear Elements", () => {
|
||||
drag(startPoint, endPoint);
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`11`,
|
||||
`12`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
@@ -548,7 +548,7 @@ describe("Test Linear Elements", () => {
|
||||
);
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`14`,
|
||||
`15`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
|
||||
|
||||
@@ -599,7 +599,7 @@ describe("Test Linear Elements", () => {
|
||||
drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta));
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`11`,
|
||||
`12`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
@@ -640,7 +640,7 @@ describe("Test Linear Elements", () => {
|
||||
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`11`,
|
||||
`12`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
@@ -688,7 +688,7 @@ describe("Test Linear Elements", () => {
|
||||
deletePoint(points[2]);
|
||||
expect(line.points.length).toEqual(3);
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`17`,
|
||||
`18`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`10`);
|
||||
|
||||
@@ -746,7 +746,7 @@ describe("Test Linear Elements", () => {
|
||||
),
|
||||
);
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`14`,
|
||||
`15`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
|
||||
expect(line.points.length).toEqual(5);
|
||||
@@ -844,7 +844,7 @@ describe("Test Linear Elements", () => {
|
||||
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`11`,
|
||||
`12`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
@@ -1317,7 +1317,7 @@ describe("Test Linear Elements", () => {
|
||||
const textElement = h.elements[2] as ExcalidrawTextElementWithContainer;
|
||||
|
||||
expect(arrow.endBinding?.elementId).toBe(rect.id);
|
||||
expect(arrow.width).toBeCloseTo(399);
|
||||
expect(arrow.width).toBeCloseTo(404);
|
||||
expect(rect.x).toBe(400);
|
||||
expect(rect.y).toBe(0);
|
||||
expect(
|
||||
@@ -1336,7 +1336,7 @@ describe("Test Linear Elements", () => {
|
||||
mouse.downAt(rect.x, rect.y);
|
||||
mouse.moveTo(200, 0);
|
||||
mouse.upAt(200, 0);
|
||||
expect(arrow.width).toBeCloseTo(199);
|
||||
expect(arrow.width).toBeCloseTo(204);
|
||||
expect(rect.x).toBe(200);
|
||||
expect(rect.y).toBe(0);
|
||||
expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
|
||||
|
||||
@@ -1350,8 +1350,8 @@ describe("multiple selection", () => {
|
||||
|
||||
expect(boundArrow.x).toBeCloseTo(380 * scaleX);
|
||||
expect(boundArrow.y).toBeCloseTo(240 * scaleY);
|
||||
expect(boundArrow.points[1][0]).toBeCloseTo(59.7979);
|
||||
expect(boundArrow.points[1][1]).toBeCloseTo(-79.7305);
|
||||
expect(boundArrow.points[1][0]).toBeCloseTo(63.40354208105561);
|
||||
expect(boundArrow.points[1][1]).toBeCloseTo(-84.53805610807356);
|
||||
|
||||
expect(arrowLabelPos.x + arrowLabel.width / 2).toBeCloseTo(
|
||||
boundArrow.x + boundArrow.points[1][0] / 2,
|
||||
|
||||
@@ -11,6 +11,87 @@ The change should be grouped under one of the below section and must contain PR
|
||||
Please add the latest change on the top under the correct section.
|
||||
-->
|
||||
|
||||
## Unreleased
|
||||
|
||||
## Excalidraw API
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- Renamed the `excalidrawAPI` prop to `onExcalidrawAPI`.
|
||||
- `onExcalidrawAPI` is now called on mount (instead of during constructor), and later on unmount (with `null` value). The API may be removed altogether in the future (you can use `onMount` & `onUmount` to manage the `ExcalidrawAPI` object (e.g. to cache it to a global state), already).
|
||||
|
||||
### Features
|
||||
|
||||
- Added `ExcalidrawAPI.isDestroyed` flag. Set to `true` once the editor unmounts. Calling any `get*` method, `onStateChange`, or `onEvent` on a destroyed API instance will throw in development and `console.error` in production. The `ExcalidrawAPI` will be reset to `null` on umount, but to be extra safe, you should check `ExcalidrawAPI.isDestroyed` before calling these methods to guard against subtle race conditions in your code.
|
||||
|
||||
- Added `onMount`, `onInitialize`, and `onUnmount` props. `onMount` receives `{ excalidrawAPI, container }` once the editor root is mounted. `onInitialize` fires once the initial scene has loaded. `onUnmount` fires just before unmounting.
|
||||
|
||||
- Same events are also accessible imperatively through `api.onEvent(...)`.
|
||||
|
||||
```tsx
|
||||
<Excalidraw
|
||||
onExcalidrawAPI={(api) => {
|
||||
api.onEvent("editor:mount", ({ excalidrawAPI, container }) => {
|
||||
console.log(container);
|
||||
});
|
||||
|
||||
api.onEvent("editor:initialize").then((readyApi) => {
|
||||
readyApi.scrollToContent();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
Note that in future releases, most, if not all, `excalidrawAPI.on*` subscriptions will be removed in favor of `excalidrawAPI.onEvent(name)`.
|
||||
|
||||
- Also added `"editor:unmount"` lifecycle event, only accessible via `api.onEvent("editor:unmount")`.
|
||||
|
||||
- Exported `<ExcalidrawAPIProvider/>`, `useExcalidrawAPI()`, `useAppStateValue(prop | props | selectorFunction)`, and `useOnExcalidrawStateChange(prop | props | selectorFunction, callback)` from the package. The imperative API also now exposes `onStateChange(prop | props | selectorFunction, callback?)`, and `onEvent(name, callback)`.
|
||||
|
||||
```tsx
|
||||
<ExcalidrawAPIProvider>
|
||||
<Excalidraw />
|
||||
<Logger />
|
||||
</ExcalidrawAPIProvider>;
|
||||
|
||||
function Logger() {
|
||||
// initially null before the ExcalidrawAPIProvider initializes ater
|
||||
// <Excalidraw/> renders
|
||||
// When <Excalidraw/> unmounts, is reset back to null
|
||||
const api = useExcalidrawAPI();
|
||||
|
||||
useAppStateValue("viewModeEnabled", (viewModeEnabled) => {
|
||||
console.log("view mode changed:", viewModeEnabled);
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (api) {
|
||||
console.log("editor instance id:", api.id);
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
- Added `onExport` so host apps can delay JSON export until async work completes. The handler receives the export data plus an `AbortSignal`, and may return a `Promise` or an async generator that yields progress updates for the built-in toast UI.
|
||||
|
||||
```tsx
|
||||
<Excalidraw
|
||||
onExport={async function* (_type, { files }, { signal }) {
|
||||
yield { type: "progress", message: "Waiting for images..." };
|
||||
|
||||
await waitForImagesToLoad(files, signal);
|
||||
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
yield { type: "progress", message: "Export ready", progress: 1 };
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Excalidraw Library
|
||||
|
||||
## 0.18.0 (2025-03-11)
|
||||
|
||||
+111
-14
@@ -1,10 +1,10 @@
|
||||
# Excalidraw
|
||||
|
||||
**Excalidraw** is exported as a component to be directly embedded in your project.
|
||||
**Excalidraw** is exported as a React component that you can embed directly in your app.
|
||||
|
||||
## Installation
|
||||
|
||||
Use `npm` or `yarn` to install the package.
|
||||
Install the package together with its React peer dependencies.
|
||||
|
||||
```bash
|
||||
npm install react react-dom @excalidraw/excalidraw
|
||||
@@ -12,34 +12,131 @@ npm install react react-dom @excalidraw/excalidraw
|
||||
yarn add react react-dom @excalidraw/excalidraw
|
||||
```
|
||||
|
||||
> **Note**: If you don't want to wait for the next stable release and try out the unreleased changes, use `@excalidraw/excalidraw@next`.
|
||||
> **Note**: If you want to try unreleased changes, use `@excalidraw/excalidraw@next`.
|
||||
|
||||
#### Self-hosting fonts
|
||||
## Quick start
|
||||
|
||||
By default, Excalidraw will try to download all the used fonts from the [CDN](https://esm.run/@excalidraw/excalidraw/dist/prod).
|
||||
The minimum working setup has two easy-to-miss requirements:
|
||||
|
||||
For self-hosting purposes, you'll have to copy the content of the folder `node_modules/@excalidraw/excalidraw/dist/prod/fonts` to the path where your assets should be served from (i.e. `public/` directory in your project). In that case, you should also set `window.EXCALIDRAW_ASSET_PATH` to the very same path, i.e. `/` in case it's in the root:
|
||||
1. Import the package CSS:
|
||||
|
||||
```js
|
||||
<script>window.EXCALIDRAW_ASSET_PATH = "/";</script>
|
||||
```ts
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
```
|
||||
|
||||
### Dimensions of Excalidraw
|
||||
2. Render Excalidraw inside a container with a non-zero height.
|
||||
|
||||
Excalidraw takes _100%_ of `width` and `height` of the containing block so make sure the container in which you render Excalidraw has non zero dimensions.
|
||||
```tsx
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ height: "100vh" }}>
|
||||
<Excalidraw />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Excalidraw fills `100%` of the width and height of its parent. If the parent has no height, the canvas will not be visible.
|
||||
|
||||
## Next.js / SSR frameworks
|
||||
|
||||
Excalidraw should be rendered on the client. In SSR frameworks such as Next.js, use a client component and load it dynamically with SSR disabled.
|
||||
|
||||
```tsx
|
||||
// app/components/ExcalidrawClient.tsx
|
||||
"use client";
|
||||
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
|
||||
export default function ExcalidrawClient() {
|
||||
return (
|
||||
<div style={{ height: "100vh" }}>
|
||||
<Excalidraw />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// app/page.tsx
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const ExcalidrawClient = dynamic(
|
||||
() => import("./components/ExcalidrawClient"),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
export default function Page() {
|
||||
return <ExcalidrawClient />;
|
||||
}
|
||||
```
|
||||
|
||||
See the local examples for complete setups:
|
||||
|
||||
- [examples/with-nextjs](https://github.com/excalidraw/excalidraw/tree/master/examples/with-nextjs)
|
||||
- [examples/with-script-in-browser](https://github.com/excalidraw/excalidraw/tree/master/examples/with-script-in-browser)
|
||||
|
||||
## LLM / agent tips
|
||||
|
||||
If an LLM or coding agent is setting up Excalidraw, these shortcuts usually save more time than re-prompting:
|
||||
|
||||
- Start with a plain `<Excalidraw />` in a `100vh` container. Add refs, `initialData`, persistence, or custom UI only after the base embed works.
|
||||
- If the canvas is blank, check the CSS import and parent height first. Those are the two most common integration failures.
|
||||
- In Next.js or other SSR frameworks, assume client-only rendering first. Use `"use client"` and `dynamic(..., { ssr: false })` before debugging hydration or `window is not defined` errors.
|
||||
- If imports or entrypoints are unclear, inspect `node_modules/@excalidraw/excalidraw/package.json`. The installed package exports are the source of truth.
|
||||
- Do not set `window.EXCALIDRAW_ASSET_PATH` unless you are intentionally self-hosting fonts/assets.
|
||||
- When docs and generated code drift, copy the nearest working example from this repo, especially `examples/with-nextjs` or `examples/with-script-in-browser`.
|
||||
|
||||
## Migrating to `@excalidraw/excalidraw@0.18.x`
|
||||
|
||||
Version `0.18.x` removes the old `types/`-prefixed deep import paths. If you were importing types from `@excalidraw/excalidraw/types/...`, switch to the new type-only subpaths below.
|
||||
|
||||
| Old path | New path |
|
||||
| --- | --- |
|
||||
| `@excalidraw/excalidraw/types/data/transform.js` | `@excalidraw/excalidraw/element/transform` |
|
||||
| `@excalidraw/excalidraw/types/data/types.js` | `@excalidraw/excalidraw/data/types` |
|
||||
| `@excalidraw/excalidraw/types/element/types.js` | `@excalidraw/excalidraw/element/types` |
|
||||
| `@excalidraw/excalidraw/types/utility-types.js` | `@excalidraw/excalidraw/common/utility-types` |
|
||||
| `@excalidraw/excalidraw/types/types.js` | `@excalidraw/excalidraw/types` |
|
||||
|
||||
Drop the `.js` extension. The new package `exports` map resolves these paths without it.
|
||||
|
||||
These deep subpaths are for `import type` only. Runtime imports should come from the package root, plus `@excalidraw/excalidraw/index.css` for styles.
|
||||
|
||||
For example:
|
||||
|
||||
```ts
|
||||
import { exportToSvg } from "@excalidraw/excalidraw";
|
||||
```
|
||||
|
||||
## Self-hosting fonts
|
||||
|
||||
By default, Excalidraw downloads the fonts it needs from the [CDN](https://esm.run/@excalidraw/excalidraw/dist/prod).
|
||||
|
||||
For self-hosting, copy the contents of `node_modules/@excalidraw/excalidraw/dist/prod/fonts` into the path where your app serves static assets, for example `public/`. Then set `window.EXCALIDRAW_ASSET_PATH` to that same path:
|
||||
|
||||
```html
|
||||
<script>
|
||||
window.EXCALIDRAW_ASSET_PATH = "/";
|
||||
</script>
|
||||
```
|
||||
|
||||
## Demo
|
||||
|
||||
Go to [CodeSandbox](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser) example.
|
||||
Try the [CodeSandbox example](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser).
|
||||
|
||||
## Integration
|
||||
|
||||
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration).
|
||||
Read the [integration docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration).
|
||||
|
||||
## API
|
||||
|
||||
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api).
|
||||
Read the [API docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api).
|
||||
|
||||
## Contributing
|
||||
|
||||
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing).
|
||||
Read the [contributing docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing).
|
||||
|
||||
@@ -118,7 +118,6 @@ export const actionClearCanvas = register({
|
||||
gridStep: appState.gridStep,
|
||||
gridModeEnabled: appState.gridModeEnabled,
|
||||
stats: appState.stats,
|
||||
pasteDialog: appState.pasteDialog,
|
||||
activeTool:
|
||||
appState.activeTool.type === "image"
|
||||
? {
|
||||
|
||||
@@ -30,7 +30,7 @@ import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { TrashIcon } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
|
||||
import { useStylesPanelMode } from "..";
|
||||
import { useStylesPanelMode } from "../components/App";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ const distributeSelectedElements = (
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
distribution,
|
||||
appState,
|
||||
app.scene,
|
||||
);
|
||||
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
|
||||
@@ -27,7 +27,7 @@ import { t } from "../i18n";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { getShortcutKey } from "../shortcut";
|
||||
|
||||
import { useStylesPanelMode } from "..";
|
||||
import { useStylesPanelMode } from "../components/App";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
||||
@@ -9,18 +9,20 @@ import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { Theme } from "@excalidraw/element/types";
|
||||
import type { ExcalidrawElement, Theme } from "@excalidraw/element/types";
|
||||
|
||||
import { useEditorInterface } from "../components/App";
|
||||
import { CheckboxItem } from "../components/CheckboxItem";
|
||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||
import { ProjectName } from "../components/ProjectName";
|
||||
import { Toast } from "../components/Toast";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { ExportIcon, questionCircle, saveAs } from "../components/icons";
|
||||
import { loadFromJSON, saveAsJSON } from "../data";
|
||||
import { isImageFileHandle } from "../data/blob";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
|
||||
import { resaveAsImageWithScene } from "../data/resave";
|
||||
|
||||
import { t } from "../i18n";
|
||||
@@ -31,7 +33,15 @@ import "../components/ToolIcon.scss";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppState } from "../types";
|
||||
import type { JSONExportData } from "../data/json";
|
||||
|
||||
import type {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
BinaryFiles,
|
||||
ExcalidrawProps,
|
||||
OnExportProgress,
|
||||
} from "../types";
|
||||
|
||||
export const actionChangeProjectName = register<AppState["name"]>({
|
||||
name: "changeProjectName",
|
||||
@@ -150,6 +160,143 @@ export const actionChangeExportEmbedScene = register<
|
||||
),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// onExport interception helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let onExportInProgress = false;
|
||||
|
||||
const onProgressToast = (
|
||||
app: AppClassProperties,
|
||||
progress: {
|
||||
message?: OnExportProgress["message"];
|
||||
progress?: number | null;
|
||||
},
|
||||
) => {
|
||||
const message = progress.message ?? t("progressDialog.defaultMessage");
|
||||
app.setAppState({
|
||||
toast: {
|
||||
message:
|
||||
progress.progress != null ? (
|
||||
<>
|
||||
{message}
|
||||
<Toast.ProgressBar progress={progress.progress} />
|
||||
</>
|
||||
) : (
|
||||
message
|
||||
),
|
||||
duration: Infinity,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** awaits host app's onExport result, and renders progress to the UI */
|
||||
async function handleOnExportResult(
|
||||
onExportResult: ReturnType<NonNullable<ExcalidrawProps["onExport"]>>,
|
||||
opts: {
|
||||
signal: AbortSignal;
|
||||
app: AppClassProperties;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (opts.app.state.isLoading) {
|
||||
onProgressToast(opts.app, { progress: null });
|
||||
await opts.app.onStateChange({ predicate: (state) => !state.isLoading });
|
||||
}
|
||||
|
||||
if (
|
||||
onExportResult != null &&
|
||||
typeof onExportResult === "object" &&
|
||||
Symbol.asyncIterator in onExportResult
|
||||
) {
|
||||
for await (const value of onExportResult) {
|
||||
if (opts.signal.aborted) {
|
||||
onExportResult.return();
|
||||
return;
|
||||
}
|
||||
if (value.type === "progress") {
|
||||
onProgressToast(opts.app, {
|
||||
message: value.message,
|
||||
progress: value.progress ?? null,
|
||||
});
|
||||
} else if (value.type === "done") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Generator completed without explicit "done" message
|
||||
return;
|
||||
}
|
||||
|
||||
if (onExportResult instanceof Promise) {
|
||||
onProgressToast(opts.app, { progress: null });
|
||||
await onExportResult;
|
||||
}
|
||||
}
|
||||
|
||||
function prepareDataForJSONExport(
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
app: AppClassProperties,
|
||||
): { abortController: AbortController; data: Promise<JSONExportData> } {
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
const dataPromise = new Promise<JSONExportData>(async (resolve) => {
|
||||
try {
|
||||
if (app.props.onExport) {
|
||||
await handleOnExportResult(
|
||||
app.props.onExport(
|
||||
"json",
|
||||
{
|
||||
elements,
|
||||
appState,
|
||||
files,
|
||||
},
|
||||
{
|
||||
signal,
|
||||
},
|
||||
),
|
||||
{
|
||||
app,
|
||||
signal,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.name === "AbortError") {
|
||||
// if abort error, assume it's a reaction on the signal being aborted
|
||||
console.warn(
|
||||
`onExport() aborted by host app (signal aborted: ${signal.aborted})`,
|
||||
);
|
||||
} else {
|
||||
// non-abort error
|
||||
//
|
||||
console.error("Error during props.onExport() handling", error);
|
||||
}
|
||||
|
||||
// either way, we currently don't allow host apps to cancel save actions
|
||||
// so we resolve to orig data
|
||||
}
|
||||
|
||||
resolve({
|
||||
elements,
|
||||
appState,
|
||||
// return latest files in case they finished loading during onExport
|
||||
files: app.files,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
abortController,
|
||||
data: dataPromise,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Save actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const actionSaveToActiveFile = register({
|
||||
name: "saveToActiveFile",
|
||||
label: "buttons.save",
|
||||
@@ -163,42 +310,62 @@ export const actionSaveToActiveFile = register({
|
||||
);
|
||||
},
|
||||
perform: async (elements, appState, value, app) => {
|
||||
const fileHandleExists = !!appState.fileHandle;
|
||||
if (onExportInProgress) {
|
||||
return false;
|
||||
}
|
||||
onExportInProgress = true;
|
||||
|
||||
const previousFileHandle = appState.fileHandle;
|
||||
const filename = app.getName();
|
||||
|
||||
const { abortController, data: exportedDataPromise } =
|
||||
prepareDataForJSONExport(elements, appState, app.files, app);
|
||||
|
||||
try {
|
||||
const { fileHandle } = isImageFileHandle(appState.fileHandle)
|
||||
const { fileHandle } = isImageFileHandle(previousFileHandle)
|
||||
? await resaveAsImageWithScene(
|
||||
elements,
|
||||
appState,
|
||||
app.files,
|
||||
app.getName(),
|
||||
exportedDataPromise,
|
||||
previousFileHandle,
|
||||
filename,
|
||||
)
|
||||
: await saveAsJSON(elements, appState, app.files, app.getName());
|
||||
: await saveAsJSON({
|
||||
data: exportedDataPromise,
|
||||
filename,
|
||||
fileHandle: previousFileHandle,
|
||||
});
|
||||
|
||||
return {
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
appState: {
|
||||
...appState,
|
||||
fileHandle,
|
||||
toast: fileHandleExists
|
||||
? {
|
||||
message: fileHandle?.name
|
||||
? t("toast.fileSavedToFilename").replace(
|
||||
"{filename}",
|
||||
`"${fileHandle.name}"`,
|
||||
)
|
||||
: t("toast.fileSaved"),
|
||||
}
|
||||
: null,
|
||||
toast: {
|
||||
message:
|
||||
previousFileHandle && fileHandle?.name
|
||||
? t("toast.fileSavedToFilename").replace(
|
||||
"{filename}",
|
||||
`"${fileHandle.name}"`,
|
||||
)
|
||||
: t("toast.fileSaved"),
|
||||
duration: 1500,
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
abortController.abort();
|
||||
|
||||
if (error?.name !== "AbortError") {
|
||||
console.error(error);
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
|
||||
return {
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
appState: {
|
||||
toast: null,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
onExportInProgress = false;
|
||||
}
|
||||
},
|
||||
keyTest: (event) =>
|
||||
@@ -212,36 +379,50 @@ export const actionSaveFileToDisk = register({
|
||||
viewMode: true,
|
||||
trackEvent: { category: "export" },
|
||||
perform: async (elements, appState, value, app) => {
|
||||
if (onExportInProgress) {
|
||||
return false;
|
||||
}
|
||||
onExportInProgress = true;
|
||||
|
||||
const { abortController, data: exportedDataPromise } =
|
||||
prepareDataForJSONExport(elements, appState, app.files, app);
|
||||
|
||||
try {
|
||||
const { fileHandle } = await saveAsJSON(
|
||||
elements,
|
||||
{
|
||||
...appState,
|
||||
fileHandle: null,
|
||||
},
|
||||
app.files,
|
||||
app.getName(),
|
||||
);
|
||||
const { fileHandle: savedFileHandle } = await saveAsJSON({
|
||||
data: exportedDataPromise,
|
||||
filename: app.getName(),
|
||||
fileHandle: null,
|
||||
});
|
||||
|
||||
return {
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
appState: {
|
||||
...appState,
|
||||
openDialog: null,
|
||||
fileHandle,
|
||||
toast: { message: t("toast.fileSaved") },
|
||||
fileHandle: savedFileHandle,
|
||||
toast: { message: t("toast.fileSaved"), duration: 3000 },
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
abortController.abort();
|
||||
if (error?.name !== "AbortError") {
|
||||
console.error(error);
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
|
||||
return {
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
appState: {
|
||||
toast: null,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
onExportInProgress = false;
|
||||
}
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event.key === KEYS.S && event.shiftKey && event[KEYS.CTRL_OR_CMD],
|
||||
event.key.toLowerCase() === KEYS.S &&
|
||||
event.shiftKey &&
|
||||
event[KEYS.CTRL_OR_CMD],
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
@@ -300,7 +481,8 @@ export const actionExportWithDarkMode = register<
|
||||
name: "exportWithDarkMode",
|
||||
label: "imageExportDialog.label.darkMode",
|
||||
trackEvent: { category: "export", action: "toggleTheme" },
|
||||
perform: (_elements, appState, value) => {
|
||||
perform: (_elements, appState, value, app) => {
|
||||
app.sessionExportThemeOverride = value ? THEME.DARK : THEME.LIGHT;
|
||||
return {
|
||||
appState: { ...appState, exportWithDarkMode: value },
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
|
||||
@@ -82,7 +82,10 @@ export const actionFinalize = register<FormData>({
|
||||
app.scene,
|
||||
);
|
||||
|
||||
if (isBindingElement(element)) {
|
||||
if (
|
||||
isBindingElement(element) &&
|
||||
!appState.selectedLinearElement.segmentMidPointHoveredCoords
|
||||
) {
|
||||
const newArrow = !!appState.newElement;
|
||||
|
||||
const selectedPointsIndices =
|
||||
@@ -95,19 +98,21 @@ export const actionFinalize = register<FormData>({
|
||||
map.set(index, {
|
||||
point: LinearElementEditor.pointFromAbsoluteCoords(
|
||||
element,
|
||||
pointFrom<GlobalPoint>(sceneCoords.x, sceneCoords.y),
|
||||
pointFrom<GlobalPoint>(
|
||||
sceneCoords.x - linearElementEditor.pointerOffset.x,
|
||||
sceneCoords.y - linearElementEditor.pointerOffset.y,
|
||||
),
|
||||
elementsMap,
|
||||
),
|
||||
});
|
||||
|
||||
return map;
|
||||
}, new Map()) ?? new Map();
|
||||
|
||||
bindOrUnbindBindingElement(
|
||||
element,
|
||||
draggedPoints,
|
||||
sceneCoords.x,
|
||||
sceneCoords.y,
|
||||
sceneCoords.x - linearElementEditor.pointerOffset.x,
|
||||
sceneCoords.y - linearElementEditor.pointerOffset.y,
|
||||
scene,
|
||||
appState,
|
||||
{
|
||||
@@ -170,6 +175,7 @@ export const actionFinalize = register<FormData>({
|
||||
...linearElementEditor.initialState,
|
||||
lastClickedPoint: -1,
|
||||
},
|
||||
pointerOffset: { x: 0, y: 0 },
|
||||
},
|
||||
selectionElement: null,
|
||||
suggestedBinding: null,
|
||||
|
||||
@@ -18,7 +18,7 @@ import { HistoryChangedEvent } from "../history";
|
||||
import { useEmitter } from "../hooks/useEmitter";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { useStylesPanelMode } from "..";
|
||||
import { useStylesPanelMode } from "../components/App";
|
||||
|
||||
import type { History } from "../history";
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
import { LinearElementEditor } from "@excalidraw/element";
|
||||
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { getArrowheadForPicker } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
getBoundTextElement,
|
||||
@@ -124,9 +125,12 @@ import {
|
||||
sharpArrowIcon,
|
||||
roundArrowIcon,
|
||||
elbowArrowIcon,
|
||||
ArrowheadCrowfootIcon,
|
||||
ArrowheadCrowfootOneIcon,
|
||||
ArrowheadCrowfootOneOrManyIcon,
|
||||
ArrowheadCardinalityExactlyOneIcon,
|
||||
ArrowheadCardinalityManyIcon,
|
||||
ArrowheadCardinalityOneIcon,
|
||||
ArrowheadCardinalityOneOrManyIcon,
|
||||
ArrowheadCardinalityZeroOrManyIcon,
|
||||
ArrowheadCardinalityZeroOrOneIcon,
|
||||
} from "../components/icons";
|
||||
|
||||
import { Fonts } from "../fonts";
|
||||
@@ -1550,80 +1554,117 @@ export const actionChangeRoundness = register<"sharp" | "round">({
|
||||
});
|
||||
|
||||
const getArrowheadOptions = (flip: boolean) => {
|
||||
return [
|
||||
{
|
||||
value: null,
|
||||
text: t("labels.arrowhead_none"),
|
||||
keyBinding: "q",
|
||||
icon: ArrowheadNoneIcon,
|
||||
},
|
||||
{
|
||||
value: "arrow",
|
||||
text: t("labels.arrowhead_arrow"),
|
||||
keyBinding: "w",
|
||||
icon: <ArrowheadArrowIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "triangle",
|
||||
text: t("labels.arrowhead_triangle"),
|
||||
icon: <ArrowheadTriangleIcon flip={flip} />,
|
||||
keyBinding: "e",
|
||||
},
|
||||
{
|
||||
value: "triangle_outline",
|
||||
text: t("labels.arrowhead_triangle_outline"),
|
||||
icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
|
||||
keyBinding: "r",
|
||||
},
|
||||
{
|
||||
value: "circle",
|
||||
text: t("labels.arrowhead_circle"),
|
||||
keyBinding: "a",
|
||||
icon: <ArrowheadCircleIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "circle_outline",
|
||||
text: t("labels.arrowhead_circle_outline"),
|
||||
keyBinding: "s",
|
||||
icon: <ArrowheadCircleOutlineIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "diamond",
|
||||
text: t("labels.arrowhead_diamond"),
|
||||
icon: <ArrowheadDiamondIcon flip={flip} />,
|
||||
keyBinding: "d",
|
||||
},
|
||||
{
|
||||
value: "diamond_outline",
|
||||
text: t("labels.arrowhead_diamond_outline"),
|
||||
icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
|
||||
keyBinding: "f",
|
||||
},
|
||||
{
|
||||
value: "bar",
|
||||
text: t("labels.arrowhead_bar"),
|
||||
keyBinding: "z",
|
||||
icon: <ArrowheadBarIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "crowfoot_one",
|
||||
text: t("labels.arrowhead_crowfoot_one"),
|
||||
icon: <ArrowheadCrowfootOneIcon flip={flip} />,
|
||||
keyBinding: "x",
|
||||
},
|
||||
{
|
||||
value: "crowfoot_many",
|
||||
text: t("labels.arrowhead_crowfoot_many"),
|
||||
icon: <ArrowheadCrowfootIcon flip={flip} />,
|
||||
keyBinding: "c",
|
||||
},
|
||||
{
|
||||
value: "crowfoot_one_or_many",
|
||||
text: t("labels.arrowhead_crowfoot_one_or_many"),
|
||||
icon: <ArrowheadCrowfootOneOrManyIcon flip={flip} />,
|
||||
keyBinding: "v",
|
||||
},
|
||||
] as const;
|
||||
return {
|
||||
visibleSections: [
|
||||
{
|
||||
name: "default",
|
||||
options: [
|
||||
{
|
||||
value: null,
|
||||
text: t("labels.arrowhead_none"),
|
||||
keyBinding: "q",
|
||||
icon: <ArrowheadNoneIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "arrow",
|
||||
text: t("labels.arrowhead_arrow"),
|
||||
keyBinding: "w",
|
||||
icon: <ArrowheadArrowIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "triangle",
|
||||
text: t("labels.arrowhead_triangle"),
|
||||
icon: <ArrowheadTriangleIcon flip={flip} />,
|
||||
keyBinding: "e",
|
||||
},
|
||||
{
|
||||
value: "triangle_outline",
|
||||
text: t("labels.arrowhead_triangle_outline"),
|
||||
icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
|
||||
keyBinding: "r",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
hiddenSections: [
|
||||
{
|
||||
name: "default",
|
||||
options: [
|
||||
{
|
||||
value: "circle",
|
||||
text: t("labels.arrowhead_circle"),
|
||||
keyBinding: "a",
|
||||
icon: <ArrowheadCircleIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "circle_outline",
|
||||
text: t("labels.arrowhead_circle_outline"),
|
||||
keyBinding: "s",
|
||||
icon: <ArrowheadCircleOutlineIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "diamond",
|
||||
text: t("labels.arrowhead_diamond"),
|
||||
icon: <ArrowheadDiamondIcon flip={flip} />,
|
||||
keyBinding: "d",
|
||||
},
|
||||
{
|
||||
value: "diamond_outline",
|
||||
text: t("labels.arrowhead_diamond_outline"),
|
||||
icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
|
||||
keyBinding: "f",
|
||||
},
|
||||
{
|
||||
value: "bar",
|
||||
text: t("labels.arrowhead_bar"),
|
||||
keyBinding: "z",
|
||||
icon: <ArrowheadBarIcon flip={flip} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t("labels.cardinality"),
|
||||
options: [
|
||||
{
|
||||
value: "cardinality_one",
|
||||
text: t("labels.arrowhead_cardinality_one"),
|
||||
icon: <ArrowheadCardinalityOneIcon flip={flip} />,
|
||||
keyBinding: "x",
|
||||
},
|
||||
{
|
||||
value: "cardinality_many",
|
||||
text: t("labels.arrowhead_cardinality_many"),
|
||||
icon: <ArrowheadCardinalityManyIcon flip={flip} />,
|
||||
keyBinding: "c",
|
||||
},
|
||||
{
|
||||
value: "cardinality_one_or_many",
|
||||
text: t("labels.arrowhead_cardinality_one_or_many"),
|
||||
icon: <ArrowheadCardinalityOneOrManyIcon flip={flip} />,
|
||||
keyBinding: "v",
|
||||
},
|
||||
{
|
||||
value: "cardinality_exactly_one",
|
||||
text: t("labels.arrowhead_cardinality_exactly_one"),
|
||||
icon: <ArrowheadCardinalityExactlyOneIcon flip={flip} />,
|
||||
keyBinding: null,
|
||||
},
|
||||
{
|
||||
value: "cardinality_zero_or_one",
|
||||
text: t("labels.arrowhead_cardinality_zero_or_one"),
|
||||
icon: <ArrowheadCardinalityZeroOrOneIcon flip={flip} />,
|
||||
keyBinding: null,
|
||||
},
|
||||
{
|
||||
value: "cardinality_zero_or_many",
|
||||
text: t("labels.arrowhead_cardinality_zero_or_many"),
|
||||
icon: <ArrowheadCardinalityZeroOrManyIcon flip={flip} />,
|
||||
keyBinding: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
};
|
||||
|
||||
export const actionChangeArrowhead = register<{
|
||||
@@ -1667,43 +1708,52 @@ export const actionChangeArrowhead = register<{
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
const isRTL = getLanguage().rtl;
|
||||
const startArrowheadOptions = useMemo(
|
||||
() => getArrowheadOptions(!isRTL),
|
||||
[isRTL],
|
||||
);
|
||||
const endArrowheadOptions = useMemo(
|
||||
() => getArrowheadOptions(!!isRTL),
|
||||
[isRTL],
|
||||
);
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>{t("labels.arrowheads")}</legend>
|
||||
<div className="iconSelectList buttonList">
|
||||
<IconPicker
|
||||
visibleSections={startArrowheadOptions.visibleSections}
|
||||
hiddenSections={startArrowheadOptions.hiddenSections}
|
||||
label="arrowhead_start"
|
||||
options={getArrowheadOptions(!isRTL)}
|
||||
value={getFormValue<Arrowhead | null>(
|
||||
elements,
|
||||
app,
|
||||
(element) =>
|
||||
isLinearElement(element) && canHaveArrowheads(element.type)
|
||||
? element.startArrowhead
|
||||
? getArrowheadForPicker(element.startArrowhead)
|
||||
: appState.currentItemStartArrowhead,
|
||||
true,
|
||||
appState.currentItemStartArrowhead,
|
||||
(hasSelection) =>
|
||||
hasSelection ? null : appState.currentItemStartArrowhead,
|
||||
)}
|
||||
onChange={(value) => updateData({ position: "start", type: value })}
|
||||
numberOfOptionsToAlwaysShow={4}
|
||||
/>
|
||||
<IconPicker
|
||||
visibleSections={endArrowheadOptions.visibleSections}
|
||||
hiddenSections={endArrowheadOptions.hiddenSections}
|
||||
label="arrowhead_end"
|
||||
group="arrowheads"
|
||||
options={getArrowheadOptions(!!isRTL)}
|
||||
value={getFormValue<Arrowhead | null>(
|
||||
elements,
|
||||
app,
|
||||
(element) =>
|
||||
isLinearElement(element) && canHaveArrowheads(element.type)
|
||||
? element.endArrowhead
|
||||
? getArrowheadForPicker(element.endArrowhead)
|
||||
: appState.currentItemEndArrowhead,
|
||||
true,
|
||||
appState.currentItemEndArrowhead,
|
||||
(hasSelection) =>
|
||||
hasSelection ? null : appState.currentItemEndArrowhead,
|
||||
)}
|
||||
onChange={(value) => updateData({ position: "end", type: value })}
|
||||
numberOfOptionsToAlwaysShow={4}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -1828,6 +1878,7 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
|
||||
startElement,
|
||||
"start",
|
||||
elementsMap,
|
||||
appState.isBindingEnabled,
|
||||
),
|
||||
}
|
||||
: null;
|
||||
@@ -1841,6 +1892,7 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
|
||||
endElement,
|
||||
"end",
|
||||
elementsMap,
|
||||
appState.isBindingEnabled,
|
||||
),
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleArrowBinding = register({
|
||||
name: "arrowBinding",
|
||||
label: "labels.arrowBinding",
|
||||
viewMode: false,
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => appState.bindingPreference === "disabled",
|
||||
},
|
||||
perform(elements, appState) {
|
||||
const newPreference =
|
||||
appState.bindingPreference === "enabled" ? "disabled" : "enabled";
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
bindingPreference: newPreference,
|
||||
isBindingEnabled: newPreference === "enabled",
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.bindingPreference === "enabled",
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleMidpointSnapping = register({
|
||||
name: "midpointSnapping",
|
||||
label: "labels.midpointSnapping",
|
||||
viewMode: false,
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => !appState.isMidpointSnappingEnabled,
|
||||
},
|
||||
perform(elements, appState) {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
isMidpointSnappingEnabled: !this.checked!(appState),
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.isMidpointSnappingEnabled,
|
||||
});
|
||||
@@ -79,6 +79,8 @@ export {
|
||||
export { actionToggleGridMode } from "./actionToggleGridMode";
|
||||
export { actionToggleZenMode } from "./actionToggleZenMode";
|
||||
export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode";
|
||||
export { actionToggleArrowBinding } from "./actionToggleArrowBinding";
|
||||
export { actionToggleMidpointSnapping } from "./actionToggleMidpointSnapping";
|
||||
|
||||
export { actionToggleStats } from "./actionToggleStats";
|
||||
export { actionUnbindText, actionBindText } from "./actionBoundText";
|
||||
|
||||
@@ -55,7 +55,8 @@ export type ShortcutName =
|
||||
| "saveScene"
|
||||
| "imageExport"
|
||||
| "commandPalette"
|
||||
| "searchMenu";
|
||||
| "searchMenu"
|
||||
| "toolLock";
|
||||
|
||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
toggleTheme: [getShortcutKey("Shift+Alt+D")],
|
||||
@@ -117,6 +118,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
toggleShortcuts: [getShortcutKey("?")],
|
||||
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
|
||||
wrapSelectionInFrame: [],
|
||||
toolLock: [getShortcutKey("Q")],
|
||||
};
|
||||
|
||||
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
|
||||
|
||||
@@ -59,6 +59,8 @@ export type ActionName =
|
||||
| "gridMode"
|
||||
| "zenMode"
|
||||
| "objectsSnapMode"
|
||||
| "arrowBinding"
|
||||
| "midpointSnapping"
|
||||
| "stats"
|
||||
| "changeStrokeColor"
|
||||
| "changeBackgroundColor"
|
||||
|
||||
@@ -27,7 +27,6 @@ export const getDefaultAppState = (): Omit<
|
||||
showWelcomeScreen: false,
|
||||
theme: THEME.LIGHT,
|
||||
collaborators: new Map(),
|
||||
currentChartType: "bar",
|
||||
currentItemBackgroundColor: DEFAULT_ELEMENT_PROPS.backgroundColor,
|
||||
currentItemEndArrowhead: "arrow",
|
||||
currentItemFillStyle: DEFAULT_ELEMENT_PROPS.fillStyle,
|
||||
@@ -71,6 +70,8 @@ export const getDefaultAppState = (): Omit<
|
||||
gridStep: DEFAULT_GRID_STEP,
|
||||
gridModeEnabled: false,
|
||||
isBindingEnabled: true,
|
||||
bindingPreference: "enabled",
|
||||
isMidpointSnappingEnabled: true,
|
||||
defaultSidebarDockedPreference: false,
|
||||
isLoading: false,
|
||||
isResizing: false,
|
||||
@@ -83,7 +84,6 @@ export const getDefaultAppState = (): Omit<
|
||||
openPopup: null,
|
||||
openSidebar: null,
|
||||
openDialog: null,
|
||||
pasteDialog: { shown: false, data: null },
|
||||
previousSelectedElementIds: {},
|
||||
resizingElement: null,
|
||||
scrolledOutside: false,
|
||||
@@ -150,7 +150,6 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
showWelcomeScreen: { browser: true, export: false, server: false },
|
||||
theme: { browser: true, export: false, server: false },
|
||||
collaborators: { browser: false, export: false, server: false },
|
||||
currentChartType: { browser: true, export: false, server: false },
|
||||
currentItemBackgroundColor: { browser: true, export: false, server: false },
|
||||
currentItemEndArrowhead: { browser: true, export: false, server: false },
|
||||
currentItemFillStyle: { browser: true, export: false, server: false },
|
||||
@@ -193,7 +192,9 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
gridStep: { browser: true, export: true, server: true },
|
||||
gridModeEnabled: { browser: true, export: true, server: true },
|
||||
height: { browser: false, export: false, server: false },
|
||||
isBindingEnabled: { browser: false, export: false, server: false },
|
||||
isBindingEnabled: { browser: true, export: false, server: false },
|
||||
bindingPreference: { browser: true, export: false, server: false },
|
||||
isMidpointSnappingEnabled: { browser: true, export: false, server: false },
|
||||
defaultSidebarDockedPreference: {
|
||||
browser: true,
|
||||
export: false,
|
||||
@@ -212,7 +213,6 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
openPopup: { browser: false, export: false, server: false },
|
||||
openSidebar: { browser: true, export: false, server: false },
|
||||
openDialog: { browser: false, export: false, server: false },
|
||||
pasteDialog: { browser: false, export: false, server: false },
|
||||
previousSelectedElementIds: { browser: true, export: false, server: false },
|
||||
resizingElement: { browser: false, export: false, server: false },
|
||||
scrolledOutside: { browser: true, export: false, server: false },
|
||||
|
||||
+1063
-16
File diff suppressed because it is too large
Load Diff
@@ -1,481 +0,0 @@
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
COLOR_PALETTE,
|
||||
DEFAULT_CHART_COLOR_INDEX,
|
||||
getAllColorsSpecificShade,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
VERTICAL_ALIGN,
|
||||
randomId,
|
||||
isDevEnv,
|
||||
FONT_SIZES,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
newTextElement,
|
||||
newLinearElement,
|
||||
newElement,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
export type ChartElements = readonly NonDeletedExcalidrawElement[];
|
||||
|
||||
const BAR_WIDTH = 32;
|
||||
const BAR_GAP = 12;
|
||||
const BAR_HEIGHT = 256;
|
||||
const GRID_OPACITY = 50;
|
||||
|
||||
export interface Spreadsheet {
|
||||
title: string | null;
|
||||
labels: string[] | null;
|
||||
values: number[];
|
||||
}
|
||||
|
||||
export const NOT_SPREADSHEET = "NOT_SPREADSHEET";
|
||||
export const VALID_SPREADSHEET = "VALID_SPREADSHEET";
|
||||
|
||||
type ParseSpreadsheetResult =
|
||||
| { type: typeof NOT_SPREADSHEET; reason: string }
|
||||
| { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
|
||||
|
||||
/**
|
||||
* @private exported for testing
|
||||
*/
|
||||
export const tryParseNumber = (s: string): number | null => {
|
||||
const match = /^([-+]?)[$€£¥₩]?([-+]?)([\d.,]+)[%]?$/.exec(s);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return parseFloat(`${(match[1] || match[2]) + match[3]}`.replace(/,/g, ""));
|
||||
};
|
||||
|
||||
const isNumericColumn = (lines: string[][], columnIndex: number) =>
|
||||
lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null);
|
||||
|
||||
/**
|
||||
* @private exported for testing
|
||||
*/
|
||||
export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
||||
const numCols = cells[0].length;
|
||||
|
||||
if (numCols > 2) {
|
||||
return { type: NOT_SPREADSHEET, reason: "More than 2 columns" };
|
||||
}
|
||||
|
||||
if (numCols === 1) {
|
||||
if (!isNumericColumn(cells, 0)) {
|
||||
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
|
||||
}
|
||||
|
||||
const hasHeader = tryParseNumber(cells[0][0]) === null;
|
||||
const values = (hasHeader ? cells.slice(1) : cells).map((line) =>
|
||||
tryParseNumber(line[0]),
|
||||
);
|
||||
|
||||
if (values.length < 2) {
|
||||
return { type: NOT_SPREADSHEET, reason: "Less than two rows" };
|
||||
}
|
||||
|
||||
return {
|
||||
type: VALID_SPREADSHEET,
|
||||
spreadsheet: {
|
||||
title: hasHeader ? cells[0][0] : null,
|
||||
labels: null,
|
||||
values: values as number[],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const labelColumnNumeric = isNumericColumn(cells, 0);
|
||||
const valueColumnNumeric = isNumericColumn(cells, 1);
|
||||
|
||||
if (!labelColumnNumeric && !valueColumnNumeric) {
|
||||
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
|
||||
}
|
||||
|
||||
const [labelColumnIndex, valueColumnIndex] = valueColumnNumeric
|
||||
? [0, 1]
|
||||
: [1, 0];
|
||||
const hasHeader = tryParseNumber(cells[0][valueColumnIndex]) === null;
|
||||
const rows = hasHeader ? cells.slice(1) : cells;
|
||||
|
||||
if (rows.length < 2) {
|
||||
return { type: NOT_SPREADSHEET, reason: "Less than 2 rows" };
|
||||
}
|
||||
|
||||
return {
|
||||
type: VALID_SPREADSHEET,
|
||||
spreadsheet: {
|
||||
title: hasHeader ? cells[0][valueColumnIndex] : null,
|
||||
labels: rows.map((row) => row[labelColumnIndex]),
|
||||
values: rows.map((row) => tryParseNumber(row[valueColumnIndex])!),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const transposeCells = (cells: string[][]) => {
|
||||
const nextCells: string[][] = [];
|
||||
for (let col = 0; col < cells[0].length; col++) {
|
||||
const nextCellRow: string[] = [];
|
||||
for (let row = 0; row < cells.length; row++) {
|
||||
nextCellRow.push(cells[row][col]);
|
||||
}
|
||||
nextCells.push(nextCellRow);
|
||||
}
|
||||
return nextCells;
|
||||
};
|
||||
|
||||
export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
|
||||
// Copy/paste from excel, spreadsheets, tsv, csv.
|
||||
// For now we only accept 2 columns with an optional header
|
||||
|
||||
// Check for tab separated values
|
||||
let lines = text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => line.trim().split("\t"));
|
||||
|
||||
// Check for comma separated files
|
||||
if (lines.length && lines[0].length !== 2) {
|
||||
lines = text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => line.trim().split(","));
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
return { type: NOT_SPREADSHEET, reason: "No values" };
|
||||
}
|
||||
|
||||
const numColsFirstLine = lines[0].length;
|
||||
const isSpreadsheet = lines.every((line) => line.length === numColsFirstLine);
|
||||
|
||||
if (!isSpreadsheet) {
|
||||
return {
|
||||
type: NOT_SPREADSHEET,
|
||||
reason: "All rows don't have same number of columns",
|
||||
};
|
||||
}
|
||||
|
||||
const result = tryParseCells(lines);
|
||||
if (result.type !== VALID_SPREADSHEET) {
|
||||
const transposedResults = tryParseCells(transposeCells(lines));
|
||||
if (transposedResults.type === VALID_SPREADSHEET) {
|
||||
return transposedResults;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const bgColors = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX);
|
||||
|
||||
// Put all the common properties here so when the whole chart is selected
|
||||
// the properties dialog shows the correct selected values
|
||||
const commonProps = {
|
||||
fillStyle: "hachure",
|
||||
fontFamily: DEFAULT_FONT_FAMILY,
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
opacity: 100,
|
||||
roughness: 1,
|
||||
strokeColor: COLOR_PALETTE.black,
|
||||
roundness: null,
|
||||
strokeStyle: "solid",
|
||||
strokeWidth: 1,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
locked: false,
|
||||
} as const;
|
||||
|
||||
const getChartDimensions = (spreadsheet: Spreadsheet) => {
|
||||
const chartWidth =
|
||||
(BAR_WIDTH + BAR_GAP) * spreadsheet.values.length + BAR_GAP;
|
||||
const chartHeight = BAR_HEIGHT + BAR_GAP * 2;
|
||||
return { chartWidth, chartHeight };
|
||||
};
|
||||
|
||||
const chartXLabels = (
|
||||
spreadsheet: Spreadsheet,
|
||||
x: number,
|
||||
y: number,
|
||||
groupId: string,
|
||||
backgroundColor: string,
|
||||
): ChartElements => {
|
||||
return (
|
||||
spreadsheet.labels?.map((label, index) => {
|
||||
return newTextElement({
|
||||
groupIds: [groupId],
|
||||
backgroundColor,
|
||||
...commonProps,
|
||||
text: label.length > 8 ? `${label.slice(0, 5)}...` : label,
|
||||
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
|
||||
y: y + BAR_GAP / 2,
|
||||
width: BAR_WIDTH,
|
||||
angle: 5.87 as Radians,
|
||||
fontSize: FONT_SIZES.sm,
|
||||
textAlign: "center",
|
||||
verticalAlign: "top",
|
||||
});
|
||||
}) || []
|
||||
);
|
||||
};
|
||||
|
||||
const chartYLabels = (
|
||||
spreadsheet: Spreadsheet,
|
||||
x: number,
|
||||
y: number,
|
||||
groupId: string,
|
||||
backgroundColor: string,
|
||||
): ChartElements => {
|
||||
const minYLabel = newTextElement({
|
||||
groupIds: [groupId],
|
||||
backgroundColor,
|
||||
...commonProps,
|
||||
x: x - BAR_GAP,
|
||||
y: y - BAR_GAP,
|
||||
text: "0",
|
||||
textAlign: "right",
|
||||
});
|
||||
|
||||
const maxYLabel = newTextElement({
|
||||
groupIds: [groupId],
|
||||
backgroundColor,
|
||||
...commonProps,
|
||||
x: x - BAR_GAP,
|
||||
y: y - BAR_HEIGHT - minYLabel.height / 2,
|
||||
text: Math.max(...spreadsheet.values).toLocaleString(),
|
||||
textAlign: "right",
|
||||
});
|
||||
|
||||
return [minYLabel, maxYLabel];
|
||||
};
|
||||
|
||||
const chartLines = (
|
||||
spreadsheet: Spreadsheet,
|
||||
x: number,
|
||||
y: number,
|
||||
groupId: string,
|
||||
backgroundColor: string,
|
||||
): ChartElements => {
|
||||
const { chartWidth, chartHeight } = getChartDimensions(spreadsheet);
|
||||
const xLine = newLinearElement({
|
||||
backgroundColor,
|
||||
groupIds: [groupId],
|
||||
...commonProps,
|
||||
type: "line",
|
||||
x,
|
||||
y,
|
||||
width: chartWidth,
|
||||
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
|
||||
});
|
||||
|
||||
const yLine = newLinearElement({
|
||||
backgroundColor,
|
||||
groupIds: [groupId],
|
||||
...commonProps,
|
||||
type: "line",
|
||||
x,
|
||||
y,
|
||||
height: chartHeight,
|
||||
points: [pointFrom(0, 0), pointFrom(0, -chartHeight)],
|
||||
});
|
||||
|
||||
const maxLine = newLinearElement({
|
||||
backgroundColor,
|
||||
groupIds: [groupId],
|
||||
...commonProps,
|
||||
type: "line",
|
||||
x,
|
||||
y: y - BAR_HEIGHT - BAR_GAP,
|
||||
strokeStyle: "dotted",
|
||||
width: chartWidth,
|
||||
opacity: GRID_OPACITY,
|
||||
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
|
||||
});
|
||||
|
||||
return [xLine, yLine, maxLine];
|
||||
};
|
||||
|
||||
// For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g
|
||||
const chartBaseElements = (
|
||||
spreadsheet: Spreadsheet,
|
||||
x: number,
|
||||
y: number,
|
||||
groupId: string,
|
||||
backgroundColor: string,
|
||||
debug?: boolean,
|
||||
): ChartElements => {
|
||||
const { chartWidth, chartHeight } = getChartDimensions(spreadsheet);
|
||||
|
||||
const title = spreadsheet.title
|
||||
? newTextElement({
|
||||
backgroundColor,
|
||||
groupIds: [groupId],
|
||||
...commonProps,
|
||||
text: spreadsheet.title,
|
||||
x: x + chartWidth / 2,
|
||||
y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE,
|
||||
roundness: null,
|
||||
textAlign: "center",
|
||||
})
|
||||
: null;
|
||||
|
||||
const debugRect = debug
|
||||
? newElement({
|
||||
backgroundColor,
|
||||
groupIds: [groupId],
|
||||
...commonProps,
|
||||
type: "rectangle",
|
||||
x,
|
||||
y: y - chartHeight,
|
||||
width: chartWidth,
|
||||
height: chartHeight,
|
||||
strokeColor: COLOR_PALETTE.black,
|
||||
fillStyle: "solid",
|
||||
opacity: 6,
|
||||
})
|
||||
: null;
|
||||
|
||||
return [
|
||||
...(debugRect ? [debugRect] : []),
|
||||
...(title ? [title] : []),
|
||||
...chartXLabels(spreadsheet, x, y, groupId, backgroundColor),
|
||||
...chartYLabels(spreadsheet, x, y, groupId, backgroundColor),
|
||||
...chartLines(spreadsheet, x, y, groupId, backgroundColor),
|
||||
];
|
||||
};
|
||||
|
||||
const chartTypeBar = (
|
||||
spreadsheet: Spreadsheet,
|
||||
x: number,
|
||||
y: number,
|
||||
): ChartElements => {
|
||||
const max = Math.max(...spreadsheet.values);
|
||||
const groupId = randomId();
|
||||
const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)];
|
||||
|
||||
const bars = spreadsheet.values.map((value, index) => {
|
||||
const barHeight = (value / max) * BAR_HEIGHT;
|
||||
return newElement({
|
||||
backgroundColor,
|
||||
groupIds: [groupId],
|
||||
...commonProps,
|
||||
type: "rectangle",
|
||||
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP,
|
||||
y: y - barHeight - BAR_GAP,
|
||||
width: BAR_WIDTH,
|
||||
height: barHeight,
|
||||
});
|
||||
});
|
||||
|
||||
return [
|
||||
...bars,
|
||||
...chartBaseElements(
|
||||
spreadsheet,
|
||||
x,
|
||||
y,
|
||||
groupId,
|
||||
backgroundColor,
|
||||
isDevEnv(),
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
const chartTypeLine = (
|
||||
spreadsheet: Spreadsheet,
|
||||
x: number,
|
||||
y: number,
|
||||
): ChartElements => {
|
||||
const max = Math.max(...spreadsheet.values);
|
||||
const groupId = randomId();
|
||||
const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)];
|
||||
|
||||
let index = 0;
|
||||
const points = [];
|
||||
for (const value of spreadsheet.values) {
|
||||
const cx = index * (BAR_WIDTH + BAR_GAP);
|
||||
const cy = -(value / max) * BAR_HEIGHT;
|
||||
points.push([cx, cy]);
|
||||
index++;
|
||||
}
|
||||
|
||||
const maxX = Math.max(...points.map((element) => element[0]));
|
||||
const maxY = Math.max(...points.map((element) => element[1]));
|
||||
const minX = Math.min(...points.map((element) => element[0]));
|
||||
const minY = Math.min(...points.map((element) => element[1]));
|
||||
|
||||
const line = newLinearElement({
|
||||
backgroundColor,
|
||||
groupIds: [groupId],
|
||||
...commonProps,
|
||||
type: "line",
|
||||
x: x + BAR_GAP + BAR_WIDTH / 2,
|
||||
y: y - BAR_GAP,
|
||||
height: maxY - minY,
|
||||
width: maxX - minX,
|
||||
strokeWidth: 2,
|
||||
points: points as any,
|
||||
});
|
||||
|
||||
const dots = spreadsheet.values.map((value, index) => {
|
||||
const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2;
|
||||
const cy = -(value / max) * BAR_HEIGHT + BAR_GAP / 2;
|
||||
return newElement({
|
||||
backgroundColor,
|
||||
groupIds: [groupId],
|
||||
...commonProps,
|
||||
fillStyle: "solid",
|
||||
strokeWidth: 2,
|
||||
type: "ellipse",
|
||||
x: x + cx + BAR_WIDTH / 2,
|
||||
y: y + cy - BAR_GAP * 2,
|
||||
width: BAR_GAP,
|
||||
height: BAR_GAP,
|
||||
});
|
||||
});
|
||||
|
||||
const lines = spreadsheet.values.map((value, index) => {
|
||||
const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2;
|
||||
const cy = (value / max) * BAR_HEIGHT + BAR_GAP / 2 + BAR_GAP;
|
||||
return newLinearElement({
|
||||
backgroundColor,
|
||||
groupIds: [groupId],
|
||||
...commonProps,
|
||||
type: "line",
|
||||
x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2,
|
||||
y: y - cy,
|
||||
height: cy,
|
||||
strokeStyle: "dotted",
|
||||
opacity: GRID_OPACITY,
|
||||
points: [pointFrom(0, 0), pointFrom(0, cy)],
|
||||
});
|
||||
});
|
||||
|
||||
return [
|
||||
...chartBaseElements(
|
||||
spreadsheet,
|
||||
x,
|
||||
y,
|
||||
groupId,
|
||||
backgroundColor,
|
||||
isDevEnv(),
|
||||
),
|
||||
line,
|
||||
...lines,
|
||||
...dots,
|
||||
];
|
||||
};
|
||||
|
||||
export const renderSpreadsheet = (
|
||||
chartType: string,
|
||||
spreadsheet: Spreadsheet,
|
||||
x: number,
|
||||
y: number,
|
||||
): ChartElements => {
|
||||
if (chartType === "line") {
|
||||
return chartTypeLine(spreadsheet, x, y);
|
||||
}
|
||||
return chartTypeBar(spreadsheet, x, y);
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import { isDevEnv } from "@excalidraw/common";
|
||||
|
||||
import { newElement } from "@excalidraw/element";
|
||||
|
||||
import { commonProps } from "./charts.constants";
|
||||
import {
|
||||
chartBaseElements,
|
||||
chartXLabels,
|
||||
createSeriesLegend,
|
||||
getBackgroundColor,
|
||||
getCartesianChartLayout,
|
||||
getChartDimensions,
|
||||
getColorOffset,
|
||||
getRotatedTextElementBottom,
|
||||
getSeriesColors,
|
||||
} from "./charts.helpers";
|
||||
|
||||
import type { ChartElements, Spreadsheet } from "./charts.types";
|
||||
|
||||
export const renderBarChart = (
|
||||
spreadsheet: Spreadsheet,
|
||||
x: number,
|
||||
y: number,
|
||||
colorSeed?: number,
|
||||
): ChartElements => {
|
||||
const series = spreadsheet.series;
|
||||
const layout = getCartesianChartLayout("bar", series.length);
|
||||
const max = Math.max(
|
||||
1,
|
||||
...series.flatMap((seriesData) =>
|
||||
seriesData.values.map((value) => Math.max(0, value)),
|
||||
),
|
||||
);
|
||||
const colorOffset = getColorOffset(colorSeed);
|
||||
const backgroundColor = getBackgroundColor(colorOffset);
|
||||
const seriesColors = getSeriesColors(series.length, colorOffset);
|
||||
const interBarGap =
|
||||
series.length > 1
|
||||
? Math.max(1, Math.floor(layout.gap / (series.length + 1)))
|
||||
: 0;
|
||||
const barWidth =
|
||||
series.length > 1
|
||||
? Math.max(
|
||||
2,
|
||||
(layout.slotWidth - interBarGap * (series.length - 1)) /
|
||||
series.length,
|
||||
)
|
||||
: layout.slotWidth;
|
||||
const clusterWidth =
|
||||
series.length * barWidth + interBarGap * (series.length - 1);
|
||||
const clusterOffset = (layout.slotWidth - clusterWidth) / 2;
|
||||
|
||||
const bars = series[0].values.flatMap((_, categoryIndex) =>
|
||||
series.map((seriesData, seriesIndex) => {
|
||||
const value = Math.max(0, seriesData.values[categoryIndex] ?? 0);
|
||||
const barHeight = (value / max) * layout.chartHeight;
|
||||
const barColor =
|
||||
series.length > 1 ? seriesColors[seriesIndex] : backgroundColor;
|
||||
return newElement({
|
||||
backgroundColor: barColor,
|
||||
...commonProps,
|
||||
type: "rectangle",
|
||||
fillStyle: series.length > 1 ? "solid" : commonProps.fillStyle,
|
||||
strokeColor: series.length > 1 ? barColor : commonProps.strokeColor,
|
||||
x:
|
||||
x +
|
||||
categoryIndex * (layout.slotWidth + layout.gap) +
|
||||
layout.gap +
|
||||
clusterOffset +
|
||||
seriesIndex * (barWidth + interBarGap),
|
||||
y: y - barHeight - layout.gap,
|
||||
width: barWidth,
|
||||
height: barHeight,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const baseElements = chartBaseElements(
|
||||
spreadsheet,
|
||||
x,
|
||||
y,
|
||||
backgroundColor,
|
||||
layout,
|
||||
max,
|
||||
isDevEnv(),
|
||||
);
|
||||
const xLabels = chartXLabels(spreadsheet, x, y, backgroundColor, layout);
|
||||
const xLabelsBottomY = Math.max(
|
||||
y + layout.gap / 2,
|
||||
...xLabels.map((label) => getRotatedTextElementBottom(label)),
|
||||
);
|
||||
const { chartWidth } = getChartDimensions(spreadsheet, layout);
|
||||
const seriesLegend = createSeriesLegend(
|
||||
series,
|
||||
seriesColors,
|
||||
x + chartWidth / 2,
|
||||
xLabelsBottomY,
|
||||
y + layout.gap * 5,
|
||||
backgroundColor,
|
||||
);
|
||||
|
||||
return [...baseElements, ...bars, ...seriesLegend];
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
COLOR_PALETTE,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
VERTICAL_ALIGN,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
export const CARTESIAN_BASE_SLOT_WIDTH = 44;
|
||||
export const CARTESIAN_BAR_SLOT_EXTRA_PER_SERIES = 22;
|
||||
export const CARTESIAN_BAR_SLOT_EXTRA_MAX = 66;
|
||||
export const CARTESIAN_LINE_SLOT_WIDTH = 48;
|
||||
export const CARTESIAN_GAP = 14;
|
||||
export const CARTESIAN_BAR_HEIGHT = 304;
|
||||
export const CARTESIAN_LINE_HEIGHT = 320;
|
||||
export const CARTESIAN_LABEL_ROTATION = 5.87 as Radians;
|
||||
export const CARTESIAN_LABEL_MIN_WIDTH = 28;
|
||||
export const CARTESIAN_LABEL_SLOT_PADDING = 4;
|
||||
export const CARTESIAN_LABEL_AXIS_CLEARANCE = 2;
|
||||
export const CARTESIAN_LABEL_MAX_WIDTH_BUFFER = 10;
|
||||
export const CARTESIAN_LABEL_ROTATED_WIDTH_BUFFER = 10;
|
||||
export const CARTESIAN_LABEL_OVERFLOW_PREFERENCE_BUFFER = 8;
|
||||
|
||||
export const BAR_GAP = 12;
|
||||
export const BAR_HEIGHT = 256;
|
||||
export const GRID_OPACITY = 10;
|
||||
|
||||
export const RADAR_GRID_LEVELS = 4;
|
||||
export const RADAR_LABEL_OFFSET = BAR_GAP * 2;
|
||||
export const RADAR_PADDING = BAR_GAP * 2;
|
||||
export const RADAR_SINGLE_SERIES_LOG_SCALE_THRESHOLD = 100;
|
||||
export const RADAR_AXIS_LABEL_MAX_WIDTH = 140;
|
||||
export const RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD = 0.35;
|
||||
export const RADAR_AXIS_LABEL_CLEARANCE = BAR_GAP / 2;
|
||||
export const RADAR_LEGEND_SWATCH_SIZE = 20;
|
||||
export const RADAR_LEGEND_ITEM_GAP = BAR_GAP * 2;
|
||||
export const RADAR_LEGEND_TEXT_GAP = BAR_GAP;
|
||||
|
||||
// Put all common chart element properties here so properties dialog
|
||||
// shows stable values when selecting chart groups.
|
||||
export const commonProps = {
|
||||
fillStyle: "hachure",
|
||||
fontFamily: DEFAULT_FONT_FAMILY,
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
opacity: 100,
|
||||
roughness: 1,
|
||||
strokeColor: COLOR_PALETTE.black,
|
||||
roundness: null,
|
||||
strokeStyle: "solid",
|
||||
strokeWidth: 1,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
locked: false,
|
||||
} as const;
|
||||
|
||||
export type CartesianChartType = "bar" | "line";
|
||||
|
||||
export type CartesianChartLayout = {
|
||||
slotWidth: number;
|
||||
gap: number;
|
||||
chartHeight: number;
|
||||
xLabelMaxWidth: number;
|
||||
};
|
||||
@@ -0,0 +1,865 @@
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
COLOR_PALETTE,
|
||||
DEFAULT_CHART_COLOR_INDEX,
|
||||
FONT_FAMILY,
|
||||
FONT_SIZES,
|
||||
ROUNDNESS,
|
||||
DEFAULT_FONT_SIZE,
|
||||
getAllColorsSpecificShade,
|
||||
getFontString,
|
||||
getLineHeight,
|
||||
ROUGHNESS,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
getApproxMinLineWidth,
|
||||
measureText,
|
||||
newElement,
|
||||
newLinearElement,
|
||||
newTextElement,
|
||||
wrapText,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
ChartType,
|
||||
ExcalidrawTextElement,
|
||||
} from "@excalidraw/element/types";
|
||||
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import {
|
||||
BAR_GAP,
|
||||
CARTESIAN_BAR_HEIGHT,
|
||||
CARTESIAN_BASE_SLOT_WIDTH,
|
||||
CARTESIAN_BAR_SLOT_EXTRA_MAX,
|
||||
CARTESIAN_BAR_SLOT_EXTRA_PER_SERIES,
|
||||
CARTESIAN_GAP,
|
||||
CARTESIAN_LABEL_AXIS_CLEARANCE,
|
||||
CARTESIAN_LABEL_MAX_WIDTH_BUFFER,
|
||||
CARTESIAN_LABEL_MIN_WIDTH,
|
||||
CARTESIAN_LABEL_OVERFLOW_PREFERENCE_BUFFER,
|
||||
CARTESIAN_LABEL_ROTATED_WIDTH_BUFFER,
|
||||
CARTESIAN_LABEL_ROTATION,
|
||||
CARTESIAN_LABEL_SLOT_PADDING,
|
||||
CARTESIAN_LINE_HEIGHT,
|
||||
CARTESIAN_LINE_SLOT_WIDTH,
|
||||
GRID_OPACITY,
|
||||
RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD,
|
||||
RADAR_AXIS_LABEL_CLEARANCE,
|
||||
RADAR_AXIS_LABEL_MAX_WIDTH,
|
||||
RADAR_LABEL_OFFSET,
|
||||
RADAR_LEGEND_ITEM_GAP,
|
||||
RADAR_LEGEND_SWATCH_SIZE,
|
||||
RADAR_LEGEND_TEXT_GAP,
|
||||
RADAR_PADDING,
|
||||
RADAR_SINGLE_SERIES_LOG_SCALE_THRESHOLD,
|
||||
BAR_HEIGHT,
|
||||
commonProps,
|
||||
type CartesianChartLayout,
|
||||
type CartesianChartType,
|
||||
} from "./charts.constants";
|
||||
|
||||
import type {
|
||||
ChartElements,
|
||||
Spreadsheet,
|
||||
SpreadsheetSeries,
|
||||
} from "./charts.types";
|
||||
|
||||
const bgColors = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX);
|
||||
|
||||
const getSpreadsheetDimensionCount = (spreadsheet: Spreadsheet) =>
|
||||
spreadsheet.labels?.length ?? spreadsheet.series[0]?.values.length ?? 0;
|
||||
|
||||
export const isSpreadsheetValidForChartType = (
|
||||
spreadsheet: Spreadsheet | null,
|
||||
chartType: ChartType,
|
||||
) => {
|
||||
if (!spreadsheet) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const dimensionCount = getSpreadsheetDimensionCount(spreadsheet);
|
||||
if (dimensionCount < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (chartType === "radar") {
|
||||
return dimensionCount >= 3;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const getSeriesAwareSlotWidth = (
|
||||
baseSlotWidth: number,
|
||||
seriesCount: number,
|
||||
) => {
|
||||
const extraSlotWidth =
|
||||
seriesCount <= 1
|
||||
? 0
|
||||
: Math.min(
|
||||
CARTESIAN_BAR_SLOT_EXTRA_MAX,
|
||||
(seriesCount - 1) * CARTESIAN_BAR_SLOT_EXTRA_PER_SERIES,
|
||||
);
|
||||
return baseSlotWidth + extraSlotWidth;
|
||||
};
|
||||
|
||||
export const getCartesianChartLayout = (
|
||||
chartType: CartesianChartType,
|
||||
seriesCount: number,
|
||||
): CartesianChartLayout => {
|
||||
if (chartType === "line") {
|
||||
const slotWidth = getSeriesAwareSlotWidth(
|
||||
CARTESIAN_LINE_SLOT_WIDTH,
|
||||
seriesCount,
|
||||
);
|
||||
return {
|
||||
slotWidth,
|
||||
gap: CARTESIAN_GAP,
|
||||
chartHeight: CARTESIAN_LINE_HEIGHT,
|
||||
xLabelMaxWidth:
|
||||
slotWidth + CARTESIAN_GAP * 3 + CARTESIAN_LABEL_MAX_WIDTH_BUFFER,
|
||||
};
|
||||
}
|
||||
|
||||
const slotWidth = getSeriesAwareSlotWidth(
|
||||
CARTESIAN_BASE_SLOT_WIDTH,
|
||||
seriesCount,
|
||||
);
|
||||
return {
|
||||
slotWidth,
|
||||
gap: CARTESIAN_GAP,
|
||||
chartHeight: CARTESIAN_BAR_HEIGHT,
|
||||
xLabelMaxWidth:
|
||||
slotWidth + CARTESIAN_GAP * 3 + CARTESIAN_LABEL_MAX_WIDTH_BUFFER,
|
||||
};
|
||||
};
|
||||
|
||||
export const getChartDimensions = (
|
||||
spreadsheet: Spreadsheet,
|
||||
layout: CartesianChartLayout,
|
||||
) => {
|
||||
const chartWidth =
|
||||
(layout.slotWidth + layout.gap) * spreadsheet.series[0].values.length +
|
||||
layout.gap;
|
||||
const chartHeight = layout.chartHeight + layout.gap * 2;
|
||||
return { chartWidth, chartHeight };
|
||||
};
|
||||
|
||||
export const getRadarDimensions = () => {
|
||||
const chartWidth = BAR_HEIGHT + RADAR_PADDING * 2;
|
||||
const chartHeight = BAR_HEIGHT + RADAR_PADDING * 2;
|
||||
return { chartWidth, chartHeight };
|
||||
};
|
||||
|
||||
const getCircularDistance = (
|
||||
firstIndex: number,
|
||||
secondIndex: number,
|
||||
paletteSize: number,
|
||||
) => {
|
||||
const absoluteDistance = Math.abs(firstIndex - secondIndex);
|
||||
return Math.min(absoluteDistance, paletteSize - absoluteDistance);
|
||||
};
|
||||
|
||||
export const getSeriesColors = (
|
||||
seriesCount: number,
|
||||
colorOffset: number,
|
||||
): readonly string[] => {
|
||||
if (seriesCount <= 0 || bgColors.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const paletteSize = bgColors.length;
|
||||
const startIndex = ((colorOffset % paletteSize) + paletteSize) % paletteSize;
|
||||
const selectedIndices = [startIndex];
|
||||
const maxUniqueColors = Math.min(seriesCount, paletteSize);
|
||||
const availableIndices = new Set(
|
||||
Array.from({ length: paletteSize }, (_, index) => index).filter(
|
||||
(index) => index !== startIndex,
|
||||
),
|
||||
);
|
||||
|
||||
while (selectedIndices.length < maxUniqueColors) {
|
||||
let bestIndex = -1;
|
||||
let bestMinDistance = -1;
|
||||
let bestAverageDistance = -1;
|
||||
|
||||
for (const candidateIndex of availableIndices) {
|
||||
const distances = selectedIndices.map((selectedIndex) =>
|
||||
getCircularDistance(candidateIndex, selectedIndex, paletteSize),
|
||||
);
|
||||
const minDistance = Math.min(...distances);
|
||||
const averageDistance =
|
||||
distances.reduce((total, distance) => total + distance, 0) /
|
||||
distances.length;
|
||||
|
||||
if (
|
||||
minDistance > bestMinDistance ||
|
||||
(minDistance === bestMinDistance &&
|
||||
averageDistance > bestAverageDistance)
|
||||
) {
|
||||
bestIndex = candidateIndex;
|
||||
bestMinDistance = minDistance;
|
||||
bestAverageDistance = averageDistance;
|
||||
}
|
||||
}
|
||||
|
||||
selectedIndices.push(bestIndex);
|
||||
availableIndices.delete(bestIndex);
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
{ length: seriesCount },
|
||||
(_, index) => bgColors[selectedIndices[index % selectedIndices.length]],
|
||||
);
|
||||
};
|
||||
|
||||
export const getColorOffset = (colorSeed?: number) => {
|
||||
if (bgColors.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (typeof colorSeed !== "number" || !Number.isFinite(colorSeed)) {
|
||||
return Math.floor(Math.random() * bgColors.length);
|
||||
}
|
||||
|
||||
const seedText = colorSeed.toString();
|
||||
let hash = 0;
|
||||
for (let index = 0; index < seedText.length; index++) {
|
||||
hash = (hash * 31 + seedText.charCodeAt(index)) | 0;
|
||||
}
|
||||
return Math.abs(hash) % bgColors.length;
|
||||
};
|
||||
|
||||
export const getBackgroundColor = (colorOffset: number) =>
|
||||
bgColors[colorOffset];
|
||||
|
||||
export const getRadarValueScale = (
|
||||
series: SpreadsheetSeries[],
|
||||
_labelsLength: number,
|
||||
) => {
|
||||
const allValues = series.flatMap((s) =>
|
||||
s.values.map((value) => Math.max(0, value)),
|
||||
);
|
||||
const positiveValues = allValues.filter((value) => value > 0);
|
||||
const max = Math.max(1, ...allValues);
|
||||
const minPositive =
|
||||
positiveValues.length > 0 ? Math.min(...positiveValues) : 1;
|
||||
const useLogScale =
|
||||
series.length === 1 &&
|
||||
minPositive > 0 &&
|
||||
max / minPositive >= RADAR_SINGLE_SERIES_LOG_SCALE_THRESHOLD;
|
||||
|
||||
return {
|
||||
renderSteps: false,
|
||||
normalize: (value: number, _axisIndex: number) => {
|
||||
const safeValue = Math.max(0, value);
|
||||
return useLogScale
|
||||
? Math.log10(safeValue + 1) / Math.log10(max + 1)
|
||||
: safeValue / max;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const shouldWrapRadarText = (text: string) => /\s/.test(text.trim());
|
||||
|
||||
export const getRadarDisplayText = (
|
||||
text: string,
|
||||
fontString: ReturnType<typeof getFontString>,
|
||||
maxWidth: number,
|
||||
) => {
|
||||
return shouldWrapRadarText(text)
|
||||
? wrapText(text, fontString, maxWidth)
|
||||
: text;
|
||||
};
|
||||
|
||||
export const createRadarAxisLabels = (
|
||||
labels: readonly string[],
|
||||
angles: readonly number[],
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
radius: number,
|
||||
backgroundColor: string,
|
||||
): {
|
||||
axisLabels: ChartElements;
|
||||
axisLabelTopY: number;
|
||||
axisLabelBottomY: number;
|
||||
} => {
|
||||
const fontFamily = FONT_FAMILY.Excalifont;
|
||||
const fontSize = FONT_SIZES.sm;
|
||||
const lineHeight = getLineHeight(fontFamily);
|
||||
const fontString = getFontString({ fontFamily, fontSize });
|
||||
const baseLabelWidth = Math.min(
|
||||
RADAR_AXIS_LABEL_MAX_WIDTH,
|
||||
radius * (labels.length > 8 ? 0.56 : 0.72),
|
||||
);
|
||||
const minLabelWidth = getApproxMinLineWidth(fontString, lineHeight);
|
||||
|
||||
const axisLabels = labels.map((label, index) => {
|
||||
const angle = angles[index];
|
||||
const longestWordWidth = Math.max(
|
||||
0,
|
||||
...label
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((word) => measureText(word, fontString, lineHeight).width),
|
||||
);
|
||||
const maxLabelWidth = Math.max(
|
||||
minLabelWidth,
|
||||
baseLabelWidth,
|
||||
longestWordWidth,
|
||||
);
|
||||
const displayLabel = getRadarDisplayText(label, fontString, maxLabelWidth);
|
||||
const metrics = measureText(displayLabel, fontString, lineHeight);
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
|
||||
const textAlign: "left" | "center" | "right" =
|
||||
cos > RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD
|
||||
? "left"
|
||||
: cos < -RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD
|
||||
? "right"
|
||||
: "center";
|
||||
|
||||
// Keep labels outside the radar ring by projecting text extents
|
||||
// onto the axis direction.
|
||||
const centerAlignedXExtent = textAlign === "center" ? metrics.width / 2 : 0;
|
||||
const projectedExtent =
|
||||
Math.abs(cos) * centerAlignedXExtent +
|
||||
Math.abs(sin) * (metrics.height / 2);
|
||||
const radialOffset =
|
||||
RADAR_LABEL_OFFSET + projectedExtent + RADAR_AXIS_LABEL_CLEARANCE;
|
||||
const anchorX = centerX + cos * (radius + radialOffset);
|
||||
const anchorY = centerY + sin * (radius + radialOffset);
|
||||
|
||||
const yNudge =
|
||||
sin > RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD
|
||||
? BAR_GAP / 3
|
||||
: sin < -RADAR_AXIS_LABEL_ALIGNMENT_THRESHOLD
|
||||
? -BAR_GAP / 3
|
||||
: 0;
|
||||
|
||||
return newTextElement({
|
||||
backgroundColor,
|
||||
...commonProps,
|
||||
text: displayLabel,
|
||||
originalText: label,
|
||||
x: anchorX,
|
||||
y: anchorY + yNudge,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
lineHeight,
|
||||
textAlign,
|
||||
verticalAlign: "middle",
|
||||
});
|
||||
});
|
||||
|
||||
const axisLabelTopY = Math.min(...axisLabels.map((axisLabel) => axisLabel.y));
|
||||
const axisLabelBottomY = Math.max(
|
||||
...axisLabels.map((axisLabel) => axisLabel.y + axisLabel.height),
|
||||
);
|
||||
return { axisLabels, axisLabelTopY, axisLabelBottomY };
|
||||
};
|
||||
|
||||
export const createSeriesLegend = (
|
||||
series: SpreadsheetSeries[],
|
||||
seriesColors: readonly string[],
|
||||
centerX: number,
|
||||
minLegendTopY: number,
|
||||
fallbackLegendY: number,
|
||||
backgroundColor: string,
|
||||
): ChartElements => {
|
||||
if (series.length <= 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const fontFamily = FONT_FAMILY["Lilita One"];
|
||||
const fontSize = FONT_SIZES.lg;
|
||||
const lineHeight = getLineHeight(fontFamily);
|
||||
const fontString = getFontString({ fontFamily, fontSize });
|
||||
const legendItems = series.map((seriesItem, index) => {
|
||||
const label = seriesItem.title?.trim() || `Series ${index + 1}`;
|
||||
const displayLabel = getRadarDisplayText(label, fontString, BAR_HEIGHT);
|
||||
const metrics = measureText(displayLabel, fontString, lineHeight);
|
||||
const itemWidth =
|
||||
RADAR_LEGEND_SWATCH_SIZE + RADAR_LEGEND_TEXT_GAP + metrics.width;
|
||||
return {
|
||||
label,
|
||||
displayLabel,
|
||||
color: seriesColors[index],
|
||||
width: itemWidth,
|
||||
height: metrics.height,
|
||||
};
|
||||
});
|
||||
const maxLegendHalfHeight = Math.max(
|
||||
RADAR_LEGEND_SWATCH_SIZE / 2,
|
||||
...legendItems.map((item) => item.height / 2),
|
||||
);
|
||||
const legendY = Math.max(
|
||||
fallbackLegendY,
|
||||
minLegendTopY + maxLegendHalfHeight + RADAR_LABEL_OFFSET,
|
||||
);
|
||||
|
||||
const pillPaddingX = RADAR_LEGEND_ITEM_GAP;
|
||||
const pillPaddingY = RADAR_LEGEND_SWATCH_SIZE * 0.6;
|
||||
const totalLegendWidth =
|
||||
legendItems.reduce((total, item) => total + item.width, 0) +
|
||||
RADAR_LEGEND_ITEM_GAP * Math.max(0, legendItems.length - 1);
|
||||
const pillWidth = totalLegendWidth + pillPaddingX * 2;
|
||||
const pillHeight = maxLegendHalfHeight * 2 + pillPaddingY * 2;
|
||||
|
||||
const legendElements: NonDeletedExcalidrawElement[] = [];
|
||||
|
||||
// rounded pill background
|
||||
legendElements.push(
|
||||
newElement({
|
||||
...commonProps,
|
||||
backgroundColor: "transparent",
|
||||
type: "rectangle",
|
||||
fillStyle: "solid",
|
||||
strokeColor: COLOR_PALETTE.black,
|
||||
x: centerX - pillWidth / 2,
|
||||
y: legendY - pillHeight / 2,
|
||||
width: pillWidth,
|
||||
height: pillHeight,
|
||||
roughness: ROUGHNESS.architect,
|
||||
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
|
||||
}),
|
||||
);
|
||||
|
||||
let cursorX = centerX - totalLegendWidth / 2;
|
||||
|
||||
legendItems.forEach((item) => {
|
||||
// solid filled swatch
|
||||
legendElements.push(
|
||||
newElement({
|
||||
...commonProps,
|
||||
backgroundColor: item.color,
|
||||
type: "rectangle",
|
||||
x: cursorX,
|
||||
y: legendY - RADAR_LEGEND_SWATCH_SIZE / 2,
|
||||
width: RADAR_LEGEND_SWATCH_SIZE,
|
||||
height: RADAR_LEGEND_SWATCH_SIZE,
|
||||
fillStyle: "solid",
|
||||
strokeColor: item.color,
|
||||
roughness: ROUGHNESS.architect,
|
||||
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
|
||||
}),
|
||||
);
|
||||
|
||||
// label in default (black) color
|
||||
legendElements.push(
|
||||
newTextElement({
|
||||
...commonProps,
|
||||
text: item.displayLabel,
|
||||
originalText: item.label,
|
||||
autoResize: false,
|
||||
x: cursorX + RADAR_LEGEND_SWATCH_SIZE + RADAR_LEGEND_TEXT_GAP,
|
||||
y: legendY,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
lineHeight,
|
||||
textAlign: "left",
|
||||
verticalAlign: "middle",
|
||||
}),
|
||||
);
|
||||
|
||||
cursorX += item.width + RADAR_LEGEND_ITEM_GAP;
|
||||
});
|
||||
|
||||
return legendElements;
|
||||
};
|
||||
|
||||
const ellipsifyTextToWidth = (
|
||||
text: string,
|
||||
maxWidth: number,
|
||||
fontString: ReturnType<typeof getFontString>,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
if (measureText(text, fontString, lineHeight).width <= maxWidth) {
|
||||
return text;
|
||||
}
|
||||
|
||||
let end = text.length;
|
||||
while (end > 1) {
|
||||
const candidate = `${text.slice(0, end)}...`;
|
||||
if (measureText(candidate, fontString, lineHeight).width <= maxWidth) {
|
||||
return candidate;
|
||||
}
|
||||
end--;
|
||||
}
|
||||
|
||||
return text[0] ? `${text[0]}...` : text;
|
||||
};
|
||||
|
||||
const wrapOrEllipsifyTextToWidth = (
|
||||
text: string,
|
||||
maxWidth: number,
|
||||
fontString: ReturnType<typeof getFontString>,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
if (measureText(text, fontString, lineHeight).width <= maxWidth) {
|
||||
return { wrapped: false, text };
|
||||
}
|
||||
|
||||
const words = text.trim().split(/\s+/).filter(Boolean);
|
||||
if (words.length > 1) {
|
||||
const hasLongWord = words.some((word) => {
|
||||
return measureText(word, fontString, lineHeight).width > maxWidth;
|
||||
});
|
||||
if (
|
||||
!hasLongWord &&
|
||||
maxWidth >= getApproxMinLineWidth(fontString, lineHeight)
|
||||
) {
|
||||
return { wrapped: true, text: wrapText(text, fontString, maxWidth) };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
wrapped: false,
|
||||
text: ellipsifyTextToWidth(text, maxWidth, fontString, lineHeight),
|
||||
};
|
||||
};
|
||||
|
||||
const getRotatedBoundingBox = (
|
||||
width: number,
|
||||
height: number,
|
||||
angle: number,
|
||||
) => {
|
||||
const cos = Math.abs(Math.cos(angle));
|
||||
const sin = Math.abs(Math.sin(angle));
|
||||
return {
|
||||
width: width * cos + height * sin,
|
||||
height: width * sin + height * cos,
|
||||
};
|
||||
};
|
||||
|
||||
type CartesianAxisLabelSpec = {
|
||||
originalText: string;
|
||||
text: string;
|
||||
wrapped: boolean;
|
||||
metrics: ReturnType<typeof measureText>;
|
||||
rotatedWidth: number;
|
||||
rotatedHeight: number;
|
||||
};
|
||||
|
||||
const isEllipsifiedLabel = (text: string) => text.includes("...");
|
||||
|
||||
const getCartesianAxisLabelSpec = (
|
||||
label: string,
|
||||
maxLabelWidth: number,
|
||||
maxRotatedWidth: number,
|
||||
fontString: ReturnType<typeof getFontString>,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
): CartesianAxisLabelSpec => {
|
||||
const minWidth = Math.max(
|
||||
CARTESIAN_LABEL_MIN_WIDTH,
|
||||
Math.ceil(getApproxMinLineWidth(fontString, lineHeight)),
|
||||
);
|
||||
const maxWidth = Math.max(minWidth, Math.floor(maxLabelWidth));
|
||||
const candidateWidths: number[] = [];
|
||||
for (let width = maxWidth; width >= minWidth; width -= 4) {
|
||||
candidateWidths.push(width);
|
||||
}
|
||||
if (candidateWidths[candidateWidths.length - 1] !== minWidth) {
|
||||
candidateWidths.push(minWidth);
|
||||
}
|
||||
|
||||
const getRank = (spec: CartesianAxisLabelSpec) => {
|
||||
const ellipsified = isEllipsifiedLabel(spec.text);
|
||||
const visibleChars = spec.text
|
||||
.replace(/\.\.\./g, "")
|
||||
.replace(/\n/g, "").length;
|
||||
const lineCount = spec.text.split("\n").length;
|
||||
return {
|
||||
ellipsified,
|
||||
visibleChars,
|
||||
lineCount,
|
||||
};
|
||||
};
|
||||
|
||||
const shouldPrefer = (
|
||||
candidate: CartesianAxisLabelSpec,
|
||||
current: CartesianAxisLabelSpec,
|
||||
) => {
|
||||
const candidateRank = getRank(candidate);
|
||||
const currentRank = getRank(current);
|
||||
if (candidateRank.ellipsified !== currentRank.ellipsified) {
|
||||
return !candidateRank.ellipsified;
|
||||
}
|
||||
if (candidateRank.visibleChars !== currentRank.visibleChars) {
|
||||
return candidateRank.visibleChars > currentRank.visibleChars;
|
||||
}
|
||||
if (candidateRank.lineCount !== currentRank.lineCount) {
|
||||
return candidateRank.lineCount < currentRank.lineCount;
|
||||
}
|
||||
return candidate.rotatedHeight < current.rotatedHeight;
|
||||
};
|
||||
|
||||
let bestFit: CartesianAxisLabelSpec | null = null;
|
||||
let bestOverflowAny: {
|
||||
overflow: number;
|
||||
spec: CartesianAxisLabelSpec;
|
||||
} | null = null;
|
||||
let bestOverflowNonEllipsified: {
|
||||
overflow: number;
|
||||
spec: CartesianAxisLabelSpec;
|
||||
} | null = null;
|
||||
|
||||
for (const width of candidateWidths) {
|
||||
const { wrapped, text } = wrapOrEllipsifyTextToWidth(
|
||||
label,
|
||||
width,
|
||||
fontString,
|
||||
lineHeight,
|
||||
);
|
||||
const metrics = measureText(text, fontString, lineHeight);
|
||||
const rotated = getRotatedBoundingBox(
|
||||
metrics.width,
|
||||
metrics.height,
|
||||
CARTESIAN_LABEL_ROTATION,
|
||||
);
|
||||
const spec = {
|
||||
originalText: label,
|
||||
text,
|
||||
metrics,
|
||||
rotatedWidth: rotated.width,
|
||||
rotatedHeight: rotated.height,
|
||||
wrapped,
|
||||
};
|
||||
const overflow = rotated.width - maxRotatedWidth;
|
||||
if (overflow <= 0) {
|
||||
if (!bestFit || shouldPrefer(spec, bestFit)) {
|
||||
bestFit = spec;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!bestOverflowAny ||
|
||||
overflow < bestOverflowAny.overflow ||
|
||||
(overflow === bestOverflowAny.overflow &&
|
||||
shouldPrefer(spec, bestOverflowAny.spec))
|
||||
) {
|
||||
bestOverflowAny = { overflow, spec };
|
||||
}
|
||||
if (
|
||||
!isEllipsifiedLabel(spec.text) &&
|
||||
(!bestOverflowNonEllipsified ||
|
||||
overflow < bestOverflowNonEllipsified.overflow ||
|
||||
(overflow === bestOverflowNonEllipsified.overflow &&
|
||||
shouldPrefer(spec, bestOverflowNonEllipsified.spec)))
|
||||
) {
|
||||
bestOverflowNonEllipsified = { overflow, spec };
|
||||
}
|
||||
}
|
||||
|
||||
if (bestFit) {
|
||||
return bestFit;
|
||||
}
|
||||
|
||||
if (
|
||||
bestOverflowNonEllipsified &&
|
||||
bestOverflowAny &&
|
||||
bestOverflowNonEllipsified.overflow <=
|
||||
bestOverflowAny.overflow + CARTESIAN_LABEL_OVERFLOW_PREFERENCE_BUFFER
|
||||
) {
|
||||
return bestOverflowNonEllipsified.spec;
|
||||
}
|
||||
|
||||
return bestOverflowAny!.spec;
|
||||
};
|
||||
|
||||
export const getRotatedTextElementBottom = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
) => {
|
||||
if (element.type !== "text") {
|
||||
return element.y + element.height;
|
||||
}
|
||||
const rotated = getRotatedBoundingBox(
|
||||
element.width,
|
||||
element.height,
|
||||
element.angle,
|
||||
);
|
||||
return element.y + element.height / 2 + rotated.height / 2;
|
||||
};
|
||||
|
||||
export const chartXLabels = (
|
||||
spreadsheet: Spreadsheet,
|
||||
x: number,
|
||||
y: number,
|
||||
backgroundColor: string,
|
||||
layout: CartesianChartLayout,
|
||||
): ChartElements => {
|
||||
const fontFamily = commonProps.fontFamily;
|
||||
const fontSize = FONT_SIZES.sm;
|
||||
const lineHeight = getLineHeight(fontFamily);
|
||||
const fontString = getFontString({ fontFamily, fontSize });
|
||||
const maxRotatedWidth = Math.max(
|
||||
1,
|
||||
layout.slotWidth +
|
||||
layout.gap -
|
||||
CARTESIAN_LABEL_SLOT_PADDING * 2 +
|
||||
CARTESIAN_LABEL_ROTATED_WIDTH_BUFFER,
|
||||
);
|
||||
const axisY = y;
|
||||
|
||||
return (
|
||||
spreadsheet.labels?.map((label, index) => {
|
||||
const labelSpec = getCartesianAxisLabelSpec(
|
||||
label,
|
||||
layout.xLabelMaxWidth,
|
||||
maxRotatedWidth,
|
||||
fontString,
|
||||
lineHeight,
|
||||
);
|
||||
const centerX =
|
||||
x +
|
||||
index * (layout.slotWidth + layout.gap) +
|
||||
layout.gap +
|
||||
layout.slotWidth / 2;
|
||||
const labelY =
|
||||
axisY +
|
||||
CARTESIAN_LABEL_AXIS_CLEARANCE +
|
||||
(labelSpec.rotatedHeight - labelSpec.metrics.height) / 2;
|
||||
|
||||
return newTextElement({
|
||||
backgroundColor,
|
||||
...commonProps,
|
||||
text: labelSpec.text,
|
||||
originalText: labelSpec.wrapped ? label : labelSpec.text,
|
||||
autoResize: !labelSpec.wrapped,
|
||||
x: centerX,
|
||||
y: labelY,
|
||||
angle: CARTESIAN_LABEL_ROTATION,
|
||||
fontSize,
|
||||
lineHeight,
|
||||
textAlign: "center",
|
||||
verticalAlign: "top",
|
||||
});
|
||||
}) || []
|
||||
);
|
||||
};
|
||||
|
||||
const chartYLabels = (
|
||||
spreadsheet: Spreadsheet,
|
||||
x: number,
|
||||
y: number,
|
||||
backgroundColor: string,
|
||||
layout: CartesianChartLayout,
|
||||
maxValue = Math.max(...spreadsheet.series[0].values),
|
||||
): ChartElements => {
|
||||
const minYLabel = newTextElement({
|
||||
backgroundColor,
|
||||
...commonProps,
|
||||
x: x - layout.gap,
|
||||
y: y - layout.gap,
|
||||
text: "0",
|
||||
textAlign: "right",
|
||||
});
|
||||
|
||||
const maxYLabel = newTextElement({
|
||||
backgroundColor,
|
||||
...commonProps,
|
||||
x: x - layout.gap,
|
||||
y: y - layout.chartHeight - minYLabel.height / 2,
|
||||
text: maxValue.toLocaleString(),
|
||||
textAlign: "right",
|
||||
});
|
||||
|
||||
return [minYLabel, maxYLabel];
|
||||
};
|
||||
|
||||
const chartLines = (
|
||||
spreadsheet: Spreadsheet,
|
||||
x: number,
|
||||
y: number,
|
||||
backgroundColor: string,
|
||||
layout: CartesianChartLayout,
|
||||
): ChartElements => {
|
||||
const { chartWidth, chartHeight } = getChartDimensions(spreadsheet, layout);
|
||||
const xLine = newLinearElement({
|
||||
backgroundColor,
|
||||
...commonProps,
|
||||
type: "line",
|
||||
x,
|
||||
y,
|
||||
width: chartWidth,
|
||||
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
|
||||
});
|
||||
|
||||
const yLine = newLinearElement({
|
||||
backgroundColor,
|
||||
...commonProps,
|
||||
type: "line",
|
||||
x,
|
||||
y,
|
||||
height: chartHeight,
|
||||
points: [pointFrom(0, 0), pointFrom(0, -chartHeight)],
|
||||
});
|
||||
|
||||
const maxLine = newLinearElement({
|
||||
backgroundColor,
|
||||
...commonProps,
|
||||
type: "line",
|
||||
x,
|
||||
y: y - layout.chartHeight - layout.gap,
|
||||
strokeStyle: "dotted",
|
||||
width: chartWidth,
|
||||
opacity: GRID_OPACITY,
|
||||
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
|
||||
});
|
||||
|
||||
return [xLine, yLine, maxLine];
|
||||
};
|
||||
|
||||
// For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g
|
||||
export const chartBaseElements = (
|
||||
spreadsheet: Spreadsheet,
|
||||
x: number,
|
||||
y: number,
|
||||
backgroundColor: string,
|
||||
layout: CartesianChartLayout,
|
||||
maxValue = Math.max(...spreadsheet.series[0].values),
|
||||
debug?: boolean,
|
||||
): ChartElements => {
|
||||
const { chartWidth, chartHeight } = getChartDimensions(spreadsheet, layout);
|
||||
|
||||
const title = spreadsheet.title
|
||||
? newTextElement({
|
||||
backgroundColor,
|
||||
...commonProps,
|
||||
text: spreadsheet.title,
|
||||
x: x + chartWidth / 2,
|
||||
y: y - layout.chartHeight - layout.gap * 2 - DEFAULT_FONT_SIZE,
|
||||
roundness: null,
|
||||
textAlign: "center",
|
||||
fontSize: FONT_SIZES.xl,
|
||||
fontFamily: FONT_FAMILY["Lilita One"],
|
||||
})
|
||||
: null;
|
||||
|
||||
const debugRect = debug
|
||||
? newElement({
|
||||
backgroundColor,
|
||||
...commonProps,
|
||||
type: "rectangle",
|
||||
x,
|
||||
y: y - chartHeight,
|
||||
width: chartWidth,
|
||||
height: chartHeight,
|
||||
strokeColor: COLOR_PALETTE.black,
|
||||
fillStyle: "solid",
|
||||
opacity: 6,
|
||||
})
|
||||
: null;
|
||||
|
||||
return [
|
||||
...(debugRect ? [debugRect] : []),
|
||||
...(title ? [title] : []),
|
||||
...chartXLabels(spreadsheet, x, y, backgroundColor, layout),
|
||||
...chartYLabels(spreadsheet, x, y, backgroundColor, layout, maxValue),
|
||||
...chartLines(spreadsheet, x, y, backgroundColor, layout),
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
|
||||
import { isDevEnv } from "@excalidraw/common";
|
||||
|
||||
import { newElement, newLinearElement } from "@excalidraw/element";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { GRID_OPACITY, commonProps } from "./charts.constants";
|
||||
import {
|
||||
chartBaseElements,
|
||||
chartXLabels,
|
||||
createSeriesLegend,
|
||||
getBackgroundColor,
|
||||
getCartesianChartLayout,
|
||||
getChartDimensions,
|
||||
getColorOffset,
|
||||
getRotatedTextElementBottom,
|
||||
getSeriesColors,
|
||||
} from "./charts.helpers";
|
||||
|
||||
import type { ChartElements, Spreadsheet } from "./charts.types";
|
||||
|
||||
export const renderLineChart = (
|
||||
spreadsheet: Spreadsheet,
|
||||
x: number,
|
||||
y: number,
|
||||
colorSeed?: number,
|
||||
): ChartElements => {
|
||||
const series = spreadsheet.series;
|
||||
const layout = getCartesianChartLayout("line", series.length);
|
||||
const max = Math.max(1, ...series.flatMap((seriesData) => seriesData.values));
|
||||
const colorOffset = getColorOffset(colorSeed);
|
||||
const backgroundColor = getBackgroundColor(colorOffset);
|
||||
const seriesColors = getSeriesColors(series.length, colorOffset);
|
||||
|
||||
const lines = series.map((seriesData, seriesIndex) => {
|
||||
const points = seriesData.values.map((value, valueIndex) =>
|
||||
pointFrom<LocalPoint>(
|
||||
valueIndex * (layout.slotWidth + layout.gap),
|
||||
-(value / max) * layout.chartHeight,
|
||||
),
|
||||
);
|
||||
|
||||
const maxX = Math.max(...points.map((point) => point[0]));
|
||||
const maxY = Math.max(...points.map((point) => point[1]));
|
||||
const minX = Math.min(...points.map((point) => point[0]));
|
||||
const minY = Math.min(...points.map((point) => point[1]));
|
||||
|
||||
return newLinearElement({
|
||||
backgroundColor: "transparent",
|
||||
...commonProps,
|
||||
type: "line",
|
||||
x: x + layout.gap + layout.slotWidth / 2,
|
||||
y: y - layout.gap,
|
||||
height: maxY - minY,
|
||||
width: maxX - minX,
|
||||
strokeColor: seriesColors[seriesIndex],
|
||||
strokeWidth: 2,
|
||||
points,
|
||||
});
|
||||
});
|
||||
|
||||
const dots = series.flatMap((seriesData, seriesIndex) =>
|
||||
seriesData.values.map((value, valueIndex) => {
|
||||
const cx = valueIndex * (layout.slotWidth + layout.gap) + layout.gap / 2;
|
||||
const cy = -(value / max) * layout.chartHeight + layout.gap / 2;
|
||||
return newElement({
|
||||
backgroundColor: seriesColors[seriesIndex],
|
||||
...commonProps,
|
||||
fillStyle: "solid",
|
||||
strokeColor: seriesColors[seriesIndex],
|
||||
strokeWidth: 2,
|
||||
type: "ellipse",
|
||||
x: x + cx + layout.slotWidth / 2,
|
||||
y: y + cy - layout.gap * 2,
|
||||
width: layout.gap,
|
||||
height: layout.gap,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const guideValues = series[0].values.map((_, valueIndex) =>
|
||||
Math.max(
|
||||
0,
|
||||
...series.map((seriesData) => seriesData.values[valueIndex] ?? 0),
|
||||
),
|
||||
);
|
||||
const guides = guideValues.map((value, valueIndex) => {
|
||||
const cx = valueIndex * (layout.slotWidth + layout.gap) + layout.gap / 2;
|
||||
const cy = (value / max) * layout.chartHeight + layout.gap / 2 + layout.gap;
|
||||
return newLinearElement({
|
||||
backgroundColor,
|
||||
...commonProps,
|
||||
type: "line",
|
||||
x: x + cx + layout.slotWidth / 2 + layout.gap / 2,
|
||||
y: y - cy,
|
||||
height: cy,
|
||||
strokeStyle: "dotted",
|
||||
opacity: GRID_OPACITY,
|
||||
points: [pointFrom(0, 0), pointFrom(0, cy)],
|
||||
});
|
||||
});
|
||||
|
||||
const baseElements = chartBaseElements(
|
||||
spreadsheet,
|
||||
x,
|
||||
y,
|
||||
backgroundColor,
|
||||
layout,
|
||||
max,
|
||||
isDevEnv(),
|
||||
);
|
||||
const xLabels = chartXLabels(spreadsheet, x, y, backgroundColor, layout);
|
||||
const xLabelsBottomY = Math.max(
|
||||
y + layout.gap / 2,
|
||||
...xLabels.map((label) => getRotatedTextElementBottom(label)),
|
||||
);
|
||||
const { chartWidth } = getChartDimensions(spreadsheet, layout);
|
||||
const seriesLegend = createSeriesLegend(
|
||||
series,
|
||||
seriesColors,
|
||||
x + chartWidth / 2,
|
||||
xLabelsBottomY,
|
||||
y + layout.gap * 5,
|
||||
backgroundColor,
|
||||
);
|
||||
|
||||
return [...baseElements, ...lines, ...guides, ...dots, ...seriesLegend];
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
import { type ParseSpreadsheetResult } from "./charts.types";
|
||||
|
||||
/**
|
||||
* @private exported for testing
|
||||
*/
|
||||
export const tryParseNumber = (s: string): number | null => {
|
||||
const match =
|
||||
/^([-+]?)[$\u20AC\u00A3\u00A5\u20A9]?([-+]?)([\d.,]+)[%]?$/.exec(s);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return parseFloat(`${(match[1] || match[2]) + match[3]}`.replace(/,/g, ""));
|
||||
};
|
||||
|
||||
const isNumericColumn = (lines: string[][], columnIndex: number) =>
|
||||
lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null);
|
||||
|
||||
/**
|
||||
* @private exported for testing
|
||||
*/
|
||||
export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
||||
const numCols = cells[0].length;
|
||||
|
||||
if (numCols > 2) {
|
||||
const hasHeader = cells[0].every((cell) => tryParseNumber(cell) === null);
|
||||
const rows = hasHeader ? cells.slice(1) : cells;
|
||||
|
||||
if (rows.length < 1) {
|
||||
return { ok: false, reason: "No data rows" };
|
||||
}
|
||||
|
||||
const invalidNumericColumn = rows.some((row) =>
|
||||
row.slice(1).some((value) => tryParseNumber(value) === null),
|
||||
);
|
||||
if (invalidNumericColumn) {
|
||||
return { ok: false, reason: "Value is not numeric" };
|
||||
}
|
||||
|
||||
// When there are more value columns than data rows, the data is in
|
||||
// "wide" format — transpose so columns become labels (dimensions)
|
||||
// and rows become series. This enables e.g. radar charts for wide data.
|
||||
const numValueCols = numCols - 1;
|
||||
if (numValueCols > rows.length) {
|
||||
const labels = hasHeader ? cells[0].slice(1).map((h) => h.trim()) : null;
|
||||
const series = rows.map((row) => ({
|
||||
title: row[0]?.trim() || null,
|
||||
values: row.slice(1).map((v) => tryParseNumber(v)!),
|
||||
}));
|
||||
const title =
|
||||
series.length === 1
|
||||
? series[0].title
|
||||
: hasHeader
|
||||
? cells[0][0].trim() || null
|
||||
: null;
|
||||
return {
|
||||
ok: true,
|
||||
data: { title, labels, series },
|
||||
};
|
||||
}
|
||||
|
||||
const series = cells[0].slice(1).map((seriesTitle, index) => {
|
||||
const valueColumnIndex = index + 1;
|
||||
const fallbackTitle = `Series ${valueColumnIndex}`;
|
||||
return {
|
||||
title: hasHeader ? seriesTitle.trim() || fallbackTitle : fallbackTitle,
|
||||
values: rows.map((row) => tryParseNumber(row[valueColumnIndex])!),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
title: hasHeader ? cells[0][0].trim() || null : null,
|
||||
labels: rows.map((row) => row[0]),
|
||||
series,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (numCols === 1) {
|
||||
if (!isNumericColumn(cells, 0)) {
|
||||
return { ok: false, reason: "Value is not numeric" };
|
||||
}
|
||||
|
||||
const hasHeader = tryParseNumber(cells[0][0]) === null;
|
||||
const title = hasHeader ? cells[0][0] : null;
|
||||
const values = (hasHeader ? cells.slice(1) : cells).map((line) =>
|
||||
tryParseNumber(line[0]),
|
||||
);
|
||||
|
||||
if (values.length < 2) {
|
||||
return { ok: false, reason: "Less than two rows" };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
title,
|
||||
labels: null,
|
||||
series: [{ title, values: values as number[] }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const hasHeader = tryParseNumber(cells[0][1]) === null;
|
||||
const rows = hasHeader ? cells.slice(1) : cells;
|
||||
|
||||
if (rows.length < 2) {
|
||||
return { ok: false, reason: "Less than 2 rows" };
|
||||
}
|
||||
|
||||
const invalidNumericColumn = rows.some(
|
||||
(row) => tryParseNumber(row[1]) === null,
|
||||
);
|
||||
if (invalidNumericColumn) {
|
||||
return { ok: false, reason: "Value is not numeric" };
|
||||
}
|
||||
|
||||
const title = hasHeader ? cells[0][1] : null;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
title,
|
||||
labels: rows.map((row) => row[0]),
|
||||
series: [{ title, values: rows.map((row) => tryParseNumber(row[1])!) }],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
|
||||
// Copy/paste from excel, spreadsheets, TSV, CSV, semicolon-separated.
|
||||
const parseDelimitedLines = (delimiter: "\t" | "," | ";") =>
|
||||
text
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.split("\n")
|
||||
.filter((line) => line.trim().length > 0)
|
||||
.map((line) => line.split(delimiter).map((cell) => cell.trim()));
|
||||
|
||||
// Score each delimiter: prefer consistent column counts with the most columns.
|
||||
// A delimiter that produces all single-column rows likely isn't the right one.
|
||||
const candidates = (["\t", ",", ";"] as const).map((delimiter) => {
|
||||
const parsed = parseDelimitedLines(delimiter);
|
||||
const numCols = parsed[0]?.length ?? 0;
|
||||
const isConsistent =
|
||||
parsed.length > 0 && parsed.every((line) => line.length === numCols);
|
||||
return { delimiter, parsed, numCols, isConsistent };
|
||||
});
|
||||
|
||||
// Prefer: consistent + most columns. Among ties, tab > comma > semicolon
|
||||
// (the array order already encodes this priority).
|
||||
const best =
|
||||
candidates.find((c) => c.isConsistent && c.numCols > 1) ??
|
||||
candidates.find((c) => c.isConsistent) ??
|
||||
candidates[0];
|
||||
|
||||
const lines = best.parsed;
|
||||
|
||||
if (lines.length === 0) {
|
||||
return { ok: false, reason: "No values" };
|
||||
}
|
||||
|
||||
const numColsFirstLine = lines[0].length;
|
||||
const isSpreadsheet = lines.every((line) => line.length === numColsFirstLine);
|
||||
|
||||
if (!isSpreadsheet) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "All rows don't have same number of columns",
|
||||
};
|
||||
}
|
||||
|
||||
return tryParseCells(lines);
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
FONT_FAMILY,
|
||||
FONT_SIZES,
|
||||
getFontString,
|
||||
getLineHeight,
|
||||
ROUGHNESS,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
measureText,
|
||||
newLinearElement,
|
||||
newTextElement,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
BAR_GAP,
|
||||
BAR_HEIGHT,
|
||||
GRID_OPACITY,
|
||||
RADAR_GRID_LEVELS,
|
||||
RADAR_LABEL_OFFSET,
|
||||
commonProps,
|
||||
} from "./charts.constants";
|
||||
import {
|
||||
createRadarAxisLabels,
|
||||
createSeriesLegend,
|
||||
getBackgroundColor,
|
||||
getColorOffset,
|
||||
getRadarDimensions,
|
||||
getRadarDisplayText,
|
||||
getRadarValueScale,
|
||||
getSeriesColors,
|
||||
isSpreadsheetValidForChartType,
|
||||
} from "./charts.helpers";
|
||||
|
||||
import type { ChartElements, Spreadsheet } from "./charts.types";
|
||||
|
||||
export const renderRadarChart = (
|
||||
spreadsheet: Spreadsheet,
|
||||
x: number,
|
||||
y: number,
|
||||
colorSeed?: number,
|
||||
): ChartElements | null => {
|
||||
if (!isSpreadsheetValidForChartType(spreadsheet, "radar")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const labels =
|
||||
spreadsheet.labels ??
|
||||
spreadsheet.series[0].values.map((_, index) => `Value ${index + 1}`);
|
||||
|
||||
const series = spreadsheet.series;
|
||||
const { normalize, renderSteps } = getRadarValueScale(series, labels.length);
|
||||
const colorOffset = getColorOffset(colorSeed);
|
||||
const backgroundColor = getBackgroundColor(colorOffset);
|
||||
const seriesColors = getSeriesColors(series.length, colorOffset);
|
||||
const { chartWidth, chartHeight } = getRadarDimensions();
|
||||
const centerX = x + chartWidth / 2;
|
||||
const centerY = y - chartHeight / 2;
|
||||
const radius = BAR_HEIGHT / 2;
|
||||
const angles = labels.map(
|
||||
(_, index) => -Math.PI / 2 + (Math.PI * 2 * index) / labels.length,
|
||||
);
|
||||
|
||||
const { axisLabels, axisLabelTopY, axisLabelBottomY } = createRadarAxisLabels(
|
||||
labels,
|
||||
angles,
|
||||
centerX,
|
||||
centerY,
|
||||
radius,
|
||||
backgroundColor,
|
||||
);
|
||||
|
||||
const titleFontFamily = FONT_FAMILY["Lilita One"];
|
||||
const titleFontSize = FONT_SIZES.xl;
|
||||
const titleLineHeight = getLineHeight(titleFontFamily);
|
||||
const titleFontString = getFontString({
|
||||
fontFamily: titleFontFamily,
|
||||
fontSize: titleFontSize,
|
||||
});
|
||||
const titleText = spreadsheet.title
|
||||
? getRadarDisplayText(
|
||||
spreadsheet.title,
|
||||
titleFontString,
|
||||
chartWidth + RADAR_LABEL_OFFSET * 2,
|
||||
)
|
||||
: null;
|
||||
const titleTextMetrics = titleText
|
||||
? measureText(titleText, titleFontString, titleLineHeight)
|
||||
: null;
|
||||
const title = titleText
|
||||
? newTextElement({
|
||||
backgroundColor,
|
||||
...commonProps,
|
||||
text: titleText,
|
||||
originalText: spreadsheet.title ?? titleText,
|
||||
x: x + chartWidth / 2,
|
||||
y: axisLabelTopY - RADAR_LABEL_OFFSET - titleTextMetrics!.height / 2,
|
||||
fontFamily: titleFontFamily,
|
||||
fontSize: titleFontSize,
|
||||
lineHeight: titleLineHeight,
|
||||
textAlign: "center",
|
||||
})
|
||||
: null;
|
||||
|
||||
const radarGridLines = renderSteps
|
||||
? Array.from({ length: RADAR_GRID_LEVELS }, (_, levelIndex) => {
|
||||
const levelRatio = (levelIndex + 1) / RADAR_GRID_LEVELS;
|
||||
const levelRadius = radius * levelRatio;
|
||||
const points = angles.map((angle) =>
|
||||
pointFrom<LocalPoint>(
|
||||
Math.cos(angle) * levelRadius,
|
||||
Math.sin(angle) * levelRadius,
|
||||
),
|
||||
);
|
||||
points.push(pointFrom(points[0][0], points[0][1]));
|
||||
|
||||
return newLinearElement({
|
||||
backgroundColor: "transparent",
|
||||
...commonProps,
|
||||
type: "line",
|
||||
x: centerX,
|
||||
y: centerY,
|
||||
width: levelRadius * 2,
|
||||
height: levelRadius * 2,
|
||||
strokeStyle: "solid",
|
||||
roughness: ROUGHNESS.architect,
|
||||
opacity: GRID_OPACITY,
|
||||
polygon: true,
|
||||
points,
|
||||
});
|
||||
})
|
||||
: [];
|
||||
|
||||
const spokes = angles.map((angle) => {
|
||||
const px = Math.cos(angle) * radius;
|
||||
const py = Math.sin(angle) * radius;
|
||||
return newLinearElement({
|
||||
backgroundColor: "transparent",
|
||||
...commonProps,
|
||||
type: "line",
|
||||
x: centerX,
|
||||
y: centerY,
|
||||
width: Math.abs(px),
|
||||
height: Math.abs(py),
|
||||
strokeStyle: "solid",
|
||||
roughness: ROUGHNESS.architect,
|
||||
opacity: GRID_OPACITY,
|
||||
points: [pointFrom(0, 0), pointFrom(px, py)],
|
||||
});
|
||||
});
|
||||
|
||||
const seriesPolygons = series.map((seriesData, index) => {
|
||||
const points = angles.map((angle, axisIndex) => {
|
||||
const value = seriesData.values[axisIndex] ?? 0;
|
||||
const pointRadius = normalize(value, axisIndex) * radius;
|
||||
return pointFrom<LocalPoint>(
|
||||
Math.cos(angle) * pointRadius,
|
||||
Math.sin(angle) * pointRadius,
|
||||
);
|
||||
});
|
||||
points.push(pointFrom(points[0][0], points[0][1]));
|
||||
|
||||
return newLinearElement({
|
||||
backgroundColor: "transparent",
|
||||
...commonProps,
|
||||
type: "line",
|
||||
x: centerX,
|
||||
y: centerY,
|
||||
width: radius * 2,
|
||||
height: radius * 2,
|
||||
strokeColor: seriesColors[index],
|
||||
strokeWidth: 2,
|
||||
polygon: true,
|
||||
points,
|
||||
});
|
||||
});
|
||||
|
||||
const seriesLegend = createSeriesLegend(
|
||||
series,
|
||||
seriesColors,
|
||||
centerX,
|
||||
axisLabelBottomY,
|
||||
y + BAR_GAP * 5,
|
||||
backgroundColor,
|
||||
);
|
||||
|
||||
return [
|
||||
...(title ? [title] : []),
|
||||
...axisLabels,
|
||||
...radarGridLines,
|
||||
...spokes,
|
||||
...seriesPolygons,
|
||||
...seriesLegend,
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
export type ChartElements = readonly NonDeletedExcalidrawElement[];
|
||||
|
||||
export interface Spreadsheet {
|
||||
title: string | null;
|
||||
labels: string[] | null;
|
||||
series: SpreadsheetSeries[];
|
||||
}
|
||||
|
||||
export interface SpreadsheetSeries {
|
||||
title: string | null;
|
||||
values: number[];
|
||||
}
|
||||
|
||||
export type ParseSpreadsheetResult =
|
||||
| { ok: false; reason: string }
|
||||
| { ok: true; data: Spreadsheet };
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ChartType } from "@excalidraw/element/types";
|
||||
|
||||
import { renderBarChart } from "./charts.bar";
|
||||
import { renderLineChart } from "./charts.line";
|
||||
import {
|
||||
tryParseCells,
|
||||
tryParseNumber,
|
||||
tryParseSpreadsheet,
|
||||
} from "./charts.parse";
|
||||
import { renderRadarChart } from "./charts.radar";
|
||||
|
||||
import type { ChartElements, Spreadsheet } from "./charts.types";
|
||||
|
||||
export {
|
||||
type ParseSpreadsheetResult,
|
||||
type Spreadsheet,
|
||||
type SpreadsheetSeries,
|
||||
type ChartElements,
|
||||
} from "./charts.types";
|
||||
|
||||
export { isSpreadsheetValidForChartType } from "./charts.helpers";
|
||||
export { tryParseCells, tryParseNumber, tryParseSpreadsheet };
|
||||
|
||||
export const renderSpreadsheet = (
|
||||
chartType: ChartType,
|
||||
spreadsheet: Spreadsheet,
|
||||
x: number,
|
||||
y: number,
|
||||
colorSeed?: number,
|
||||
): ChartElements | null => {
|
||||
if (chartType === "line") {
|
||||
return renderLineChart(spreadsheet, x, y, colorSeed);
|
||||
}
|
||||
if (chartType === "radar") {
|
||||
return renderRadarChart(spreadsheet, x, y, colorSeed);
|
||||
}
|
||||
return renderBarChart(spreadsheet, x, y, colorSeed);
|
||||
};
|
||||
@@ -155,67 +155,4 @@ describe("parseClipboard()", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should parse spreadsheet from either text/plain and text/html", async () => {
|
||||
let clipboardData;
|
||||
// -------------------------------------------------------------------------
|
||||
clipboardData = await parseClipboard(
|
||||
await parseDataTransferEvent(
|
||||
createPasteEvent({
|
||||
types: {
|
||||
"text/plain": `a b
|
||||
1 2
|
||||
4 5
|
||||
7 10`,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(clipboardData.spreadsheet).toEqual({
|
||||
title: "b",
|
||||
labels: ["1", "4", "7"],
|
||||
values: [2, 5, 10],
|
||||
});
|
||||
// -------------------------------------------------------------------------
|
||||
clipboardData = await parseClipboard(
|
||||
await parseDataTransferEvent(
|
||||
createPasteEvent({
|
||||
types: {
|
||||
"text/html": `a b
|
||||
1 2
|
||||
4 5
|
||||
7 10`,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(clipboardData.spreadsheet).toEqual({
|
||||
title: "b",
|
||||
labels: ["1", "4", "7"],
|
||||
values: [2, 5, 10],
|
||||
});
|
||||
// -------------------------------------------------------------------------
|
||||
clipboardData = await parseClipboard(
|
||||
await parseDataTransferEvent(
|
||||
createPasteEvent({
|
||||
types: {
|
||||
"text/html": `<html>
|
||||
<body>
|
||||
<!--StartFragment--><google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none"><colgroup><col width="100"/><col width="100"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"a"}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"b"}">b</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":2}">2</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":5}">5</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":10}">10</td></tr></tbody></table><!--EndFragment-->
|
||||
</body>
|
||||
</html>`,
|
||||
"text/plain": `a b
|
||||
1 2
|
||||
4 5
|
||||
7 10`,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(clipboardData.spreadsheet).toEqual({
|
||||
title: "b",
|
||||
labels: ["1", "4", "7"],
|
||||
values: [2, 5, 10],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,12 +33,6 @@ import {
|
||||
normalizeFile,
|
||||
} from "./data/blob";
|
||||
|
||||
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||
|
||||
import type { FileSystemHandle } from "./data/filesystem";
|
||||
|
||||
import type { Spreadsheet } from "./charts";
|
||||
|
||||
import type { BinaryFiles } from "./types";
|
||||
|
||||
type ElementsClipboard = {
|
||||
@@ -50,7 +44,6 @@ type ElementsClipboard = {
|
||||
export type PastedMixedContent = { type: "text" | "imageUrl"; value: string }[];
|
||||
|
||||
export interface ClipboardData {
|
||||
spreadsheet?: Spreadsheet;
|
||||
elements?: readonly ExcalidrawElement[];
|
||||
files?: BinaryFiles;
|
||||
text?: string;
|
||||
@@ -204,22 +197,17 @@ export const copyToClipboard = async (
|
||||
/** supply if available to make the operation more certain to succeed */
|
||||
clipboardEvent?: ClipboardEvent | null,
|
||||
) => {
|
||||
const json = serializeAsClipboardJSON({ elements, files });
|
||||
|
||||
await copyTextToSystemClipboard(
|
||||
serializeAsClipboardJSON({ elements, files }),
|
||||
{
|
||||
[MIME_TYPES.excalidrawClipboard]: json,
|
||||
[MIME_TYPES.text]: json,
|
||||
},
|
||||
clipboardEvent,
|
||||
);
|
||||
};
|
||||
|
||||
const parsePotentialSpreadsheet = (
|
||||
text: string,
|
||||
): { spreadsheet: Spreadsheet } | { errorMessage: string } | null => {
|
||||
const result = tryParseSpreadsheet(text);
|
||||
if (result.type === VALID_SPREADSHEET) {
|
||||
return { spreadsheet: result.spreadsheet };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** internal, specific to parsing paste events. Do not reuse. */
|
||||
function parseHTMLTree(el: ChildNode) {
|
||||
let result: PastedMixedContent = [];
|
||||
@@ -379,7 +367,7 @@ type AllowedParsedDataTransferItem =
|
||||
type: ValueOf<typeof IMAGE_MIME_TYPES>;
|
||||
kind: "file";
|
||||
file: File;
|
||||
fileHandle: FileSystemHandle | null;
|
||||
fileHandle: FileSystemFileHandle | null;
|
||||
}
|
||||
| { type: ValueOf<typeof STRING_MIME_TYPES>; kind: "string"; value: string };
|
||||
|
||||
@@ -388,7 +376,7 @@ type ParsedDataTransferItem =
|
||||
type: string;
|
||||
kind: "file";
|
||||
file: File;
|
||||
fileHandle: FileSystemHandle | null;
|
||||
fileHandle: FileSystemFileHandle | null;
|
||||
}
|
||||
| { type: string; kind: "string"; value: string };
|
||||
|
||||
@@ -401,7 +389,7 @@ export type ParsedDataTransferFile = Extract<
|
||||
{ kind: "file" }
|
||||
>;
|
||||
|
||||
type ParsedDataTranferList = ParsedDataTransferItem[] & {
|
||||
export type ParsedDataTranferList = ParsedDataTransferItem[] & {
|
||||
/**
|
||||
* Only allows filtering by known `string` data types, since `file`
|
||||
* types can have multiple items of the same type (e.g. multiple image files)
|
||||
@@ -452,6 +440,29 @@ const getDataTransferFiles = function (
|
||||
);
|
||||
};
|
||||
|
||||
/** @returns list of MIME types, synchronously */
|
||||
export const parseDataTransferEventMimeTypes = (
|
||||
event: ClipboardEvent | DragEvent | React.DragEvent<HTMLDivElement>,
|
||||
): Set<string> => {
|
||||
let items: DataTransferItemList | undefined = undefined;
|
||||
|
||||
if (isClipboardEvent(event)) {
|
||||
items = event.clipboardData?.items;
|
||||
} else {
|
||||
items = event.dataTransfer?.items;
|
||||
}
|
||||
|
||||
const types: Set<string> = new Set();
|
||||
|
||||
for (const item of Array.from(items || [])) {
|
||||
if (!types.has(item.type)) {
|
||||
types.add(item.type);
|
||||
}
|
||||
}
|
||||
|
||||
return types;
|
||||
};
|
||||
|
||||
export const parseDataTransferEvent = async (
|
||||
event: ClipboardEvent | DragEvent | React.DragEvent<HTMLDivElement>,
|
||||
): Promise<ParsedDataTranferList> => {
|
||||
@@ -460,8 +471,7 @@ export const parseDataTransferEvent = async (
|
||||
if (isClipboardEvent(event)) {
|
||||
items = event.clipboardData?.items;
|
||||
} else {
|
||||
const dragEvent = event;
|
||||
items = dragEvent.dataTransfer?.items;
|
||||
items = event.dataTransfer?.items;
|
||||
}
|
||||
|
||||
const dataItems = (
|
||||
@@ -524,19 +534,6 @@ export const parseClipboard = async (
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// if system clipboard contains spreadsheet, use it even though it's
|
||||
// technically possible it's staler than in-app clipboard
|
||||
const spreadsheetResult =
|
||||
!isPlainPaste && parsePotentialSpreadsheet(parsedEventData.value);
|
||||
|
||||
if (spreadsheetResult) {
|
||||
return spreadsheetResult;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
try {
|
||||
const systemClipboardData = JSON.parse(parsedEventData.value);
|
||||
const programmaticAPI =
|
||||
@@ -567,7 +564,7 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
|
||||
// ClipboardItem constructor, but throws on an unrelated MIME type error.
|
||||
// So we need to await this and fallback to awaiting the blob if applicable.
|
||||
await navigator.clipboard.write([
|
||||
new window.ClipboardItem({
|
||||
new ClipboardItem({
|
||||
[MIME_TYPES.png]: blob,
|
||||
}),
|
||||
]);
|
||||
@@ -576,7 +573,7 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
|
||||
// with resolution value instead
|
||||
if (isPromiseLike(blob)) {
|
||||
await navigator.clipboard.write([
|
||||
new window.ClipboardItem({
|
||||
new ClipboardItem({
|
||||
[MIME_TYPES.png]: await blob,
|
||||
}),
|
||||
]);
|
||||
@@ -586,28 +583,27 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const copyTextToSystemClipboard = async (
|
||||
text: string | null,
|
||||
export const copyTextToSystemClipboard = async <
|
||||
MimeType extends ValueOf<typeof STRING_MIME_TYPES>,
|
||||
>(
|
||||
text: string | { [K in MimeType]: string } | null,
|
||||
clipboardEvent?: ClipboardEvent | null,
|
||||
) => {
|
||||
// (1) first try using Async Clipboard API
|
||||
if (probablySupportsClipboardWriteText) {
|
||||
try {
|
||||
// NOTE: doesn't work on FF on non-HTTPS domains, or when document
|
||||
// not focused
|
||||
await navigator.clipboard.writeText(text || "");
|
||||
return;
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
text = text || "";
|
||||
|
||||
// (2) if fails and we have access to ClipboardEvent, use plain old setData()
|
||||
const entries = Object.entries(
|
||||
typeof text === "string" ? { [MIME_TYPES.text]: text } : text,
|
||||
);
|
||||
|
||||
// (1) if we have clipboardEvent, try using it first as it's the most
|
||||
// versatile
|
||||
try {
|
||||
if (clipboardEvent) {
|
||||
clipboardEvent.clipboardData?.setData(MIME_TYPES.text, text || "");
|
||||
if (clipboardEvent.clipboardData?.getData(MIME_TYPES.text) !== text) {
|
||||
throw new Error("Failed to setData on clipboardEvent");
|
||||
for (const [mimeType, value] of entries) {
|
||||
clipboardEvent.clipboardData?.setData(mimeType, value);
|
||||
if (clipboardEvent.clipboardData?.getData(mimeType) !== value) {
|
||||
throw new Error("Failed to setData on clipboardEvent");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -615,8 +611,26 @@ export const copyTextToSystemClipboard = async (
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
// (3) if that fails, use document.execCommand
|
||||
if (!copyTextViaExecCommand(text)) {
|
||||
const plainTextEntry = entries.find(
|
||||
([mimeType]) => mimeType === MIME_TYPES.text,
|
||||
);
|
||||
|
||||
// (2) if we don't have access to clipboardEvent, or that fails,
|
||||
// at least try setting text/plain via navigator.clipboard.writeText
|
||||
// (navigator.clipboard.write doesn't work with non-standard mime types)
|
||||
if (probablySupportsClipboardWriteText && plainTextEntry) {
|
||||
try {
|
||||
// NOTE: doesn't work on FF on non-HTTPS domains, or when document
|
||||
// not focused
|
||||
await navigator.clipboard.writeText(plainTextEntry[1]);
|
||||
return;
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
// (3) if previous fails, use document.execCommand
|
||||
if (plainTextEntry && !copyTextViaExecCommand(plainTextEntry[1])) {
|
||||
throw new Error("Error copying to clipboard.");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import clsx from "clsx";
|
||||
import { useRef, useState } from "react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { Popover } from "radix-ui";
|
||||
|
||||
import {
|
||||
CLASSES,
|
||||
@@ -226,7 +226,7 @@ export const SelectedShapeActions = ({
|
||||
{(appState.activeTool.type === "text" ||
|
||||
targetElements.some(isTextElement)) && (
|
||||
<>
|
||||
{renderAction("changeFontFamily")}
|
||||
<fieldset>{renderAction("changeFontFamily")}</fieldset>
|
||||
{renderAction("changeFontSize")}
|
||||
{(appState.activeTool.type === "text" ||
|
||||
suppportsHorizontalAlign(targetElements, elementsMap)) &&
|
||||
@@ -1081,8 +1081,9 @@ export const ShapesSwitcher = ({
|
||||
return (
|
||||
<>
|
||||
{getToolbarTools(app).map(
|
||||
({ value, icon, key, numericKey, fillable }, index) => {
|
||||
({ value, icon, key, numericKey, fillable, toolbar }) => {
|
||||
if (
|
||||
toolbar === false ||
|
||||
UIOptions.tools?.[
|
||||
value as Extract<
|
||||
typeof value,
|
||||
@@ -1099,6 +1100,9 @@ export const ShapesSwitcher = ({
|
||||
const shortcut = letter
|
||||
? `${letter} ${t("helpDialog.or")} ${numericKey}`
|
||||
: `${numericKey}`;
|
||||
const keybindingLabel =
|
||||
value === "hand" ? undefined : numericKey || letter;
|
||||
|
||||
// when in compact styles panel mode (tablet)
|
||||
// use a ToolPopover for selection/lasso toggle as well
|
||||
if (
|
||||
@@ -1143,7 +1147,7 @@ export const ShapesSwitcher = ({
|
||||
checked={activeTool.type === value}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||
keyBindingLabel={numericKey || letter}
|
||||
keyBindingLabel={keybindingLabel}
|
||||
aria-label={capitalizeString(label)}
|
||||
aria-keyshortcuts={shortcut}
|
||||
data-testid={`toolbar-${value}`}
|
||||
@@ -1263,9 +1267,9 @@ export const ShapesSwitcher = ({
|
||||
onSelect={() => app.onMagicframeToolSelect()}
|
||||
icon={MagicIcon}
|
||||
data-testid="toolbar-magicframe"
|
||||
badge={<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>}
|
||||
>
|
||||
{t("toolBar.magicframe")}
|
||||
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
</DropdownMenu.Content>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user