Merge branch 'master' into mtolmacs/chore/rename-inside-binding

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
Mark Tolmacs
2026-06-04 15:39:23 +00:00
221 changed files with 16954 additions and 4212 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:18-bullseye
FROM node:24-bullseye
# Vite wants to open the browser using `open`, so we
# need to install those utils.
+22 -1
View File
@@ -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"]
}
]
}
}
]
}
+2 -2
View File
@@ -9,11 +9,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 2
- name: Setup Node.js
uses: actions/setup-node@v2
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20.x
- name: Set up publish access
+1 -1
View File
@@ -9,5 +9,5 @@ jobs:
build-docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- run: docker build -t excalidraw .
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- uses: styfle/cancel-workflow-action@0.6.0
- uses: styfle/cancel-workflow-action@ce177499ccf9fd2aded3b0426c97e5434c2e8a73 # 0.6.0
with:
workflow_id: 400555, 400556, 905313, 1451724, 1710116, 3185001, 3438604
access_token: ${{ secrets.GITHUB_TOKEN }}
+2 -2
View File
@@ -7,10 +7,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js
uses: actions/setup-node@v2
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20.x
+3 -3
View File
@@ -10,12 +10,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
- name: Setup Node.js
uses: actions/setup-node@v2
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20.x
@@ -40,7 +40,7 @@ jobs:
echo ::set-output name=body::$body
- name: Update description with coverage
uses: kt3k/update-pr-description@v1.0.1
uses: kt3k/update-pr-description@1b35a6dcd84d81aa0bc1889610efdcde7f37b0c0 # v1.0.1
with:
pr_body: ${{ steps.getCommentBody.outputs.body }}
pr_title: "chore: Update translations from Crowdin"
+5 -5
View File
@@ -11,18 +11,18 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Login to DockerHub
uses: docker/login-action@v2
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5
with:
context: .
push: true
+87 -1
View File
@@ -6,11 +6,97 @@ on:
- opened
- edited
- synchronize
- labeled
- unlabeled
jobs:
semantic:
runs-on: ubuntu-latest
permissions:
pull-requests: read
steps:
- uses: amannn/action-semantic-pull-request@v5
- uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5
with:
requireScope: true
scopes: |
app
editor
packages/excalidraw
packages/utils
docker
repo
ignoreLabels: |
skip-semantic-title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
label-scope:
needs: semantic
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- name: Label scoped PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
scope_labels=(s-app s-editor s-package)
readarray -t desired_labels < <(
node <<'NODE'
const title = process.env.PR_TITLE;
const match = title.match(/^[a-z]+(?:\(([^)]+)\))?!?:/i);
const scopes = match?.[1]?.split(",").map((scope) => scope.trim()) ?? [];
const labels = new Set();
for (const scope of scopes) {
if (scope === "app") {
labels.add("s-app");
} else if (scope === "editor") {
labels.add("s-editor");
} else if (scope.startsWith("packages/")) {
labels.add("s-package");
}
}
process.stdout.write([...labels].join("\n"));
NODE
)
should_apply_label() {
local label="$1"
for desired_label in "${desired_labels[@]}"; do
if [[ "$desired_label" == "$label" ]]; then
return 0
fi
done
return 1
}
for label in "${scope_labels[@]}"; do
if ! should_apply_label "$label"; then
gh api \
--method DELETE \
"repos/${REPOSITORY}/issues/${PR_NUMBER}/labels/${label}" \
--silent 2>/dev/null || true
fi
done
for label in "${desired_labels[@]}"; do
if ! gh api \
--method POST \
"repos/${REPOSITORY}/issues/${PR_NUMBER}/labels" \
--field "labels[]=${label}" \
--silent; then
echo "::warning::Could not apply ${label}. The workflow token likely does not have issues:write permission for this PR."
fi
done
+2 -2
View File
@@ -9,9 +9,9 @@ jobs:
sentry:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js
uses: actions/setup-node@v2
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20.x
- name: Install and build
+3 -3
View File
@@ -10,9 +10,9 @@ jobs:
CI_JOB_NUMBER: 1
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js
uses: actions/setup-node@v3
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20.x
- name: Install in packages/excalidraw
@@ -20,7 +20,7 @@ jobs:
working-directory: packages/excalidraw
env:
CI: true
- uses: andresz1/size-limit-action@v1
- uses: andresz1/size-limit-action@e7493a72a44b113341c0cf6186ab49c17c4b65c1 # v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
build_script: build:esm
+3 -3
View File
@@ -10,9 +10,9 @@ jobs:
pull-requests: write
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: "Install Node"
uses: actions/setup-node@v2
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "20.x"
- name: "Install Deps"
@@ -21,6 +21,6 @@ jobs:
run: yarn test:coverage
- name: "Report Coverage"
if: always() # Also generate the report if tests are failing
uses: davelosert/vitest-coverage-report-action@v2
uses: davelosert/vitest-coverage-report-action@2500dafcee7dd64f85ab689c0b83798a8359770e # v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
+2 -2
View File
@@ -8,9 +8,9 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20.x
- name: Install and test
+3 -3
View File
@@ -1,4 +1,4 @@
FROM --platform=${BUILDPLATFORM} node:18 AS build
FROM --platform=${BUILDPLATFORM} node:24@sha256:8530f76a96d88820d288761f022e318970dda93d01536919fbc16076b7983e63 AS build
WORKDIR /opt/node_app
@@ -7,13 +7,13 @@ COPY . .
# do not ignore optional dependencies:
# Error: Cannot find module @rollup/rollup-linux-x64-gnu
RUN --mount=type=cache,target=/root/.cache/yarn \
npm_config_target_arch=${TARGETARCH} yarn --network-timeout 600000
npm_config_target_arch=${TARGETARCH} yarn --frozen-lockfile --network-timeout 600000
ARG NODE_ENV=production
RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker
FROM --platform=${TARGETPLATFORM} nginx:1.27-alpine
FROM nginx:stable-alpine-slim@sha256:2c605dbeab79a6b2a63340474fe58119d0ef95bdc4b1f41df0aa689659b3d13b
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
+1 -1
View File
@@ -29,7 +29,7 @@
<a href="https://docs.excalidraw.com/docs/introduction/contributing">
<img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" /></a>
<a href="https://discord.gg/UexuTaE">
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/></a>
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widget=false"/></a>
<a href="https://deepwiki.com/excalidraw/excalidraw">
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" /></a>
<a href="https://twitter.com/excalidraw">
@@ -172,7 +172,7 @@ convertToExcalidrawElements([
type: "arrow",
x: 450,
y: 20,
startArrowhead: "dot",
startArrowhead: "circle",
endArrowhead: "triangle",
strokeColor: "#1971c2",
strokeWidth: 2,
+2 -2
View File
@@ -97,8 +97,8 @@ const config = {
href: "https://discord.gg/UexuTaE",
},
{
label: "Twitter",
href: "https://twitter.com/excalidraw",
label: "𝕏",
href: "https://x.com/excalidraw",
},
{
label: "Linkedin",
@@ -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[] = [
+5 -5
View File
@@ -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",
-36
View File
@@ -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>;
};
+92 -20
View File
@@ -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 {
@@ -74,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";
@@ -114,6 +116,7 @@ import {
} from "./data";
import { updateStaleImageStatuses } from "./data/FileManager";
import { FileStatusStore } from "./data/fileStatusStore";
import {
importFromLocalStorage,
importUsernameFromLocalStorage,
@@ -369,6 +372,8 @@ const initializeScene = async (opts: {
};
const ExcalidrawWrapper = () => {
const excalidrawAPI = useExcalidrawAPI();
const [errorMessage, setErrorMessage] = useState("");
const isCollabDisabled = isRunningInIframe();
@@ -399,9 +404,6 @@ const ExcalidrawWrapper = () => {
}, VERSION_TIMEOUT);
}, []);
const [excalidrawAPI, excalidrawRefCallback] =
useCallbackRefState<ExcalidrawImperativeAPI>();
const [, setShareDialogState] = useAtom(shareDialogStateAtom);
const [collabAPI] = useAtom(collabAPIAtom);
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
@@ -433,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
@@ -471,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,
@@ -482,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);
}
@@ -500,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);
@@ -628,7 +648,7 @@ const ExcalidrawWrapper = () => {
false,
);
};
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode, loadImages]);
useEffect(() => {
const unloadHandler = (event: BeforeUnloadEvent) => {
@@ -773,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
@@ -839,8 +909,8 @@ const ExcalidrawWrapper = () => {
})}
>
<Excalidraw
excalidrawAPI={excalidrawRefCallback}
onChange={onChange}
onExport={onExport}
initialData={initialStatePromiseRef.current.promise}
isCollaborating={isCollaborating}
onPointerUpdate={collabAPI?.onPointerUpdate}
@@ -1206,7 +1276,9 @@ const ExcalidrawApp = () => {
return (
<TopErrorBoundary>
<Provider store={appJotaiStore}>
<ExcalidrawWrapper />
<ExcalidrawAPIProvider>
<ExcalidrawWrapper />
</ExcalidrawAPIProvider>
</Provider>
</TopErrorBoundary>
);
+2
View File
@@ -72,6 +72,7 @@ import {
FileManager,
updateStaleImageStatuses,
} from "../data/FileManager";
import { FileStatusStore } from "../data/fileStatusStore";
import { LocalData } from "../data/LocalData";
import {
isSavedToFirebase,
@@ -149,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) {
@@ -414,7 +414,6 @@ export const debugRenderer = throttleRAF(
) => {
_debugRenderer(canvas, appState, elements, scale);
},
{ trailing: true },
);
export const loadSavedDebugState = () => {
+22
View File
@@ -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();
+3 -1
View File
@@ -26,7 +26,6 @@ import {
get,
} from "idb-keyval";
import { appJotaiStore, atom } from "excalidraw-app/app-jotai";
import { getNonDeletedElements } from "@excalidraw/element";
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
@@ -39,9 +38,11 @@ import type {
} from "@excalidraw/excalidraw/types";
import type { MaybePromise } from "@excalidraw/common/utility-types";
import { appJotaiStore, atom } from "../app-jotai";
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)[]) => {
+48
View File
@@ -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 };
}
}
+17 -1
View File
@@ -75,6 +75,13 @@ export default defineConfig(({ mode }) => {
find: /^@excalidraw\/utils\/(.*?)/,
replacement: path.resolve(__dirname, "../packages/utils/src/$1"),
},
{
find: /^@excalidraw\/fractional-indexing$/,
replacement: path.resolve(
__dirname,
"../packages/fractional-indexing/src/index.ts",
),
},
],
},
build: {
@@ -106,6 +113,10 @@ export default defineConfig(({ mode }) => {
if (id.includes("@excalidraw/mermaid-to-excalidraw")) {
return "mermaid-to-excalidraw";
}
if (id.includes("@codemirror/") || id.includes("@lezer/")) {
return "codemirror.chunk";
}
},
},
},
@@ -150,6 +161,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: [
{
@@ -189,7 +205,7 @@ export default defineConfig(({ mode }) => {
},
},
{
urlPattern: new RegExp(".chunk-.+.js"),
urlPattern: new RegExp("(.chunk-.+|CodeMirrorEditor-.+)\\.js"),
handler: "CacheFirst",
options: {
cacheName: "chunk",
+2 -1
View File
@@ -56,7 +56,8 @@
"build:element": "yarn --cwd ./packages/element build:esm",
"build:excalidraw": "yarn --cwd ./packages/excalidraw build:esm",
"build:math": "yarn --cwd ./packages/math build:esm",
"build:packages": "yarn build:common && yarn build:math && yarn build:element && yarn build:excalidraw",
"build:fractional-indexing": "yarn --cwd ./packages/fractional-indexing build:esm",
"build:packages": "yarn build:common && yarn build:fractional-indexing && yarn build:math && yarn build:element && yarn build:excalidraw",
"build:version": "yarn --cwd ./excalidraw-app build:version",
"build": "yarn --cwd ./excalidraw-app build",
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
@@ -1,9 +1,3 @@
declare global {
interface Window {
debug: typeof Debug;
}
}
const lessPrecise = (num: number, precision = 5) =>
parseFloat(num.toPrecision(precision));
@@ -157,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;
+74
View File
@@ -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');
});
});
+136
View File
@@ -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();
}
}
+14 -15
View File
@@ -240,22 +240,21 @@ export const DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE = {
// -----------------------------------------------------------------------------
// !!!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;
// 3rd row
COLOR_PALETTE.green[index],
COLOR_PALETTE.teal[index],
COLOR_PALETTE.yellow[index],
COLOR_PALETTE.orange[index],
COLOR_PALETTE.red[index],
];
// -----------------------------------------------------------------------------
// other helpers
+4 -3
View File
@@ -337,9 +337,10 @@ export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
export const EXPORT_SCALES = [1, 2, 3];
export const DEFAULT_EXPORT_PADDING = 10; // px
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024;
export const DEFAULT_IMAGE_OPTIONS: AppProps["imageOptions"] = {
maxWidthOrHeight: 1440,
maxFileSizeBytes: 4 * 1024 * 1024,
};
export const SVG_NS = "http://www.w3.org/2000/svg";
export const SVG_DOCUMENT_PREAMBLE = `<?xml version="1.0" standalone="no"?>
+3
View File
@@ -11,4 +11,7 @@ export * from "./random";
export * from "./url";
export * from "./utils";
export * from "./emitter";
export * from "./appEventBus";
export * from "./editorInterface";
export * from "./versionedSnapshotStore";
export { Debug } from "../debug";
+89
View File
@@ -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();
});
});
});
+23 -24
View File
@@ -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 {
@@ -86,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,
@@ -148,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 = () => {
@@ -188,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;
@@ -441,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 = (
@@ -1330,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);
});
}
}
+2 -1
View File
@@ -64,6 +64,7 @@
},
"dependencies": {
"@excalidraw/common": "0.18.0",
"@excalidraw/math": "0.18.0"
"@excalidraw/math": "0.18.0",
"@excalidraw/fractional-indexing": "3.3.0"
}
}
+14 -36
View File
@@ -338,29 +338,20 @@ export class Scene {
this.callbacks.clear();
}
insertElementAtIndex(element: ExcalidrawElement, index: number) {
if (!Number.isFinite(index) || index < 0) {
throw new Error(
"insertElementAtIndex can only be called with index >= 0",
);
}
const nextElements = [
...this.elements.slice(0, index),
element,
...this.elements.slice(index),
];
syncMovedIndices(nextElements, arrayToMap([element]));
this.replaceAllElements(nextElements);
}
insertElementsAtIndex(elements: ExcalidrawElement[], index: number) {
/** low-level - generally use app.insertNewElements() */
insertElementsAtIndex(
elements: ExcalidrawElement[],
/** null indicates end of the array */
index: number | null,
) {
if (!elements.length) {
return;
}
if (index === null) {
index = this.elements.length;
}
if (!Number.isFinite(index) || index < 0) {
throw new Error(
"insertElementAtIndex can only be called with index >= 0",
@@ -378,24 +369,9 @@ export class Scene {
this.replaceAllElements(nextElements);
}
/** low-level - generally use app.insertNewElement() */
insertElement = (element: ExcalidrawElement) => {
const index = element.frameId
? this.getElementIndex(element.frameId)
: this.elements.length;
this.insertElementAtIndex(element, index);
};
insertElements = (elements: ExcalidrawElement[]) => {
if (!elements.length) {
return;
}
const index = elements[0]?.frameId
? this.getElementIndex(elements[0].frameId)
: this.elements.length;
this.insertElementsAtIndex(elements, index);
this.insertElementsAtIndex([element], null);
};
getElementIndex(elementId: string) {
@@ -438,6 +414,8 @@ export class Scene {
options: {
informMutation: boolean;
isDragging: boolean;
isBindingEnabled?: boolean;
isMidpointSnappingEnabled?: boolean;
} = {
informMutation: true,
isDragging: false,
-1
View File
@@ -43,7 +43,6 @@ export const alignElements = (
// update bound elements
updateBoundElements(element, scene, {
simultaneouslyUpdated: group,
indirectArrowUpdate: true,
});
return updatedEle;
});
+32
View File
@@ -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;
};
+24 -7
View File
@@ -42,6 +42,7 @@ export const isFocusPointVisible = (
isBindingEnabled: AppState["isBindingEnabled"];
zoom: AppState["zoom"];
},
startOrEnd: "start" | "end",
ignoreOverlap = false,
): boolean => {
// No focus point management for elbow arrows, because elbow arrows
@@ -76,14 +77,25 @@ export const isFocusPointVisible = (
}
}
// Check if the focus point is within the element's shape bounds
return hitElementItself({
element: bindableElement,
const arrowPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
startOrEnd === "end" ? arrow.points.length - 1 : 0,
elementsMap,
point: focusPoint,
threshold: getBindingGap(bindableElement, arrow),
overrideShouldTestInside: true,
});
);
// 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
@@ -129,6 +141,7 @@ const focusPointUpdate = (
currentBinding,
bindableElement,
elementsMap,
true,
);
if (newPoint) {
@@ -353,6 +366,7 @@ export const handleFocusPointPointerDown = (
bindableElement,
elementsMap,
appState,
"start",
) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold
) {
@@ -387,6 +401,7 @@ export const handleFocusPointPointerDown = (
bindableElement,
elementsMap,
appState,
"end",
) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold
) {
@@ -501,6 +516,7 @@ export const handleFocusPointHover = (
bindableElement,
elementsMap,
appState,
"start",
) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold
) {
@@ -529,6 +545,7 @@ export const handleFocusPointHover = (
bindableElement,
elementsMap,
appState,
"end",
) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold
) {
+251 -190
View File
@@ -1,5 +1,4 @@
import {
KEYS,
arrayToMap,
getFeatureFlag,
invariant,
@@ -27,11 +26,7 @@ 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,
@@ -116,6 +111,7 @@ export type BindingStrategy =
*/
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 = (
@@ -140,12 +136,6 @@ 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: {
isBindingEnabled: AppState["isBindingEnabled"];
}): boolean => {
@@ -180,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.
@@ -224,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,
);
}
};
@@ -720,12 +731,11 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
});
// Handle outside-outside binding to the same element
if (otherBinding && otherBinding.elementId === hit?.id) {
invariant(
!opts?.newArrow || appState.selectedLinearElement?.initialState.origin,
"appState.selectedLinearElement.initialState.origin must be defined for new arrows",
);
if (
otherBinding &&
otherBinding.elementId === hit?.id &&
(!opts?.newArrow || appState.selectedLinearElement?.initialState.origin)
) {
return {
start: {
mode: "fixed",
@@ -798,6 +808,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
startDragged ? "start" : "end",
elementsMap,
appState.zoom,
appState.isMidpointSnappingEnabled,
) || globalPoint,
}
: { mode: null };
@@ -807,13 +818,23 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
startDragged ? -1 : 0,
elementsMap,
);
const pointIsCloseToOtherElement =
otherFocusPoint &&
otherBindableElement &&
hitElementItself({
point: globalPoint,
element: otherBindableElement,
elementsMap,
threshold: maxBindingDistance_simple(appState.zoom),
overrideShouldTestInside: true,
});
const otherNeverOverride = opts?.newArrow
? appState.selectedLinearElement?.initialState.arrowStartIsInside
: otherBinding?.mode === "fixed";
const other: BindingStrategy = !otherNeverOverride
? otherBindableElement &&
!otherFocusPointIsInElement &&
!pointIsCloseToOtherElement &&
appState.selectedLinearElement?.initialState.altFocusPoint
? {
mode: "orbit",
@@ -832,6 +853,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
startDragged ? "end" : "start",
elementsMap,
appState.zoom,
appState.isMidpointSnappingEnabled,
) || otherEndpoint,
}
: { mode: undefined }
@@ -995,6 +1017,7 @@ export const bindBindingElement = (
startOrEnd: "start" | "end",
scene: Scene,
focusPoint?: GlobalPoint,
shouldSnapToOutline = true,
): void => {
const elementsMap = scene.getNonDeletedElementsMap();
@@ -1009,6 +1032,7 @@ export const bindBindingElement = (
hoveredElement,
startOrEnd,
elementsMap,
shouldSnapToOutline,
),
};
} else {
@@ -1080,7 +1104,6 @@ export const updateBoundElements = (
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
changedElements?: Map<string, ExcalidrawElement>;
indirectArrowUpdate?: boolean;
},
) => {
if (!isBindableElement(changedElement)) {
@@ -1175,11 +1198,6 @@ export const updateBoundElements = (
};
boundElementsVisitor(elementsMap, changedElement, visitor);
if (options?.indirectArrowUpdate) {
boundElementsVisitor(elementsMap, changedElement, visitor);
boundElementsVisitor(elementsMap, changedElement, visitor);
}
};
const updateArrowBindings = (
@@ -1348,6 +1366,7 @@ export const bindPointToSnapToElementOutline = (
startOrEnd: "start" | "end",
elementsMap: ElementsMap,
customIntersector?: LineSegment<GlobalPoint>,
isMidpointSnappingEnabled = true,
): GlobalPoint => {
const elbowed = isElbowArrow(arrowElement);
const point = LinearElementEditor.getPointAtIndexGlobalCoordinates(
@@ -1387,13 +1406,9 @@ export const bindPointToSnapToElementOutline = (
const isHorizontal = headingIsHorizontal(
headingForPointFromElement(bindableElement, aabb, point),
);
const snapPoint = snapToMid(
bindableElement,
elementsMap,
edgePoint,
0.05,
arrowElement,
);
const snapPoint = isMidpointSnappingEnabled
? snapToMid(bindableElement, elementsMap, edgePoint, 0.05, arrowElement)
: undefined;
const resolved = snapPoint || point;
const otherPoint = pointFrom<GlobalPoint>(
isHorizontal ? bindableCenter[0] : resolved[0],
@@ -1689,10 +1704,41 @@ export const snapToMid = (
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>,
@@ -1700,9 +1746,7 @@ export const updateBoundPoint = (
binding: FixedPointBinding | null | undefined,
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
opts?: {
customIntersector?: LineSegment<GlobalPoint>;
},
dragging?: boolean,
): LocalPoint | null => {
if (
binding == null ||
@@ -1717,150 +1761,139 @@ export const updateBoundPoint = (
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 = opts?.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 === "fixed") {
return LinearElementEditor.createPointAt(
arrow,
elementsMap,
);
const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
const edgePoint = 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,
);
};
@@ -1870,6 +1903,8 @@ export const calculateFixedPointForElbowArrowBinding = (
hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
elementsMap: ElementsMap,
shouldSnapToOutline = true,
isMidpointSnappingEnabled = true,
): { fixedPoint: FixedPoint } => {
const bounds = [
hoveredElement.x,
@@ -1877,12 +1912,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,
@@ -1896,9 +1939,9 @@ export const calculateFixedPointForElbowArrowBinding = (
return {
fixedPoint: normalizeFixedPoint([
(nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) /
hoveredElement.width,
Math.max(hoveredElement.width, PRECISION),
(nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) /
hoveredElement.height,
Math.max(hoveredElement.height, PRECISION),
]),
};
};
@@ -1929,9 +1972,11 @@ export const calculateFixedPointForNonElbowArrowBinding = (
// Calculate the ratio relative to the element's bounds
const fixedPointX =
(nonRotatedPoint[0] - hoveredElement.x) / hoveredElement.width;
(nonRotatedPoint[0] - hoveredElement.x) /
Math.max(hoveredElement.width, PRECISION);
const fixedPointY =
(nonRotatedPoint[1] - hoveredElement.y) / hoveredElement.height;
(nonRotatedPoint[1] - hoveredElement.y) /
Math.max(hoveredElement.height, PRECISION);
return {
fixedPoint: normalizeFixedPoint([fixedPointX, fixedPointY]),
@@ -2428,21 +2473,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 =
+63 -30
View File
@@ -680,8 +680,9 @@ export const getMinMaxXYFromCurvePathOps = (
return [minX, minY, maxX, maxY];
};
export const getBoundsFromPoints = (
points: ExcalidrawFreeDrawElement["points"],
export const getBoundsFromPoints = <P extends GlobalPoint | LocalPoint>(
points: readonly P[],
padding: number = 0,
): Bounds => {
let minX = Infinity;
let minY = Infinity;
@@ -695,7 +696,7 @@ export const getBoundsFromPoints = (
maxY = Math.max(maxY, y);
}
return [minX, minY, maxX, maxY];
return [minX - padding, minY - padding, maxX + padding, maxY + padding];
};
const getFreeDrawElementAbsoluteCoords = (
@@ -709,6 +710,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 +721,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 +751,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 +837,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 +870,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 +888,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,16 +899,16 @@ 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
@@ -1248,6 +1262,17 @@ export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
): boolean =>
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
// TODO make pointInsideBounds inclusive and remove this function once we
// test nothing is breaking
export const pointInsideBoundsInclusive = <P extends GlobalPoint | LocalPoint>(
p: P,
bounds: Bounds,
): boolean =>
p[0] >= bounds[0] &&
p[0] <= bounds[2] &&
p[1] >= bounds[1] &&
p[1] <= bounds[3];
export const doBoundsIntersect = (
bounds1: Bounds | null,
bounds2: Bounds | null,
@@ -1262,13 +1287,21 @@ export const doBoundsIntersect = (
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
};
export const boundsContainBounds = (outerBounds: Bounds, innerBounds: Bounds) =>
[
pointFrom<GlobalPoint>(innerBounds[0], innerBounds[1]),
pointFrom<GlobalPoint>(innerBounds[0], innerBounds[3]),
pointFrom<GlobalPoint>(innerBounds[2], innerBounds[1]),
pointFrom<GlobalPoint>(innerBounds[2], innerBounds[3]),
].every((point) => pointInsideBoundsInclusive(point, outerBounds));
export const elementCenterPoint = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
xOffset: number = 0,
yOffset: number = 0,
) => {
if (isLinearElement(element)) {
if (isLinearElement(element) || isFreeDrawElement(element)) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const [x, y] = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
+46 -19
View File
@@ -61,6 +61,8 @@ import { distanceToElement } from "./distance";
import { getBindingGap } from "./binding";
import { hasBackground } from "./comparisons";
import type {
ElementsMap,
ExcalidrawArrowElement,
@@ -83,7 +85,7 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
}
const isDraggableFromInside =
!isTransparent(element.backgroundColor) ||
(hasBackground(element.type) && !isTransparent(element.backgroundColor)) ||
hasBoundTextElement(element) ||
isIframeLikeElement(element) ||
isTextElement(element);
@@ -154,14 +156,11 @@ export const hitElementItself = ({
// Hit test against the extended, rotated bounding box of the element first
const bounds = getElementBounds(element, elementsMap, true);
const hitBounds = isPointWithinBounds(
pointFrom(bounds[0] - threshold, bounds[1] - threshold),
pointRotateRads(
point,
getCenterForBounds(bounds),
-element.angle as Radians,
),
pointFrom(bounds[2] + threshold, bounds[3] + threshold),
const hitBounds = isPointInRotatedBounds(
point,
bounds,
element.angle,
threshold,
);
// PERF: Bail out early if the point is not even in the
@@ -192,18 +191,32 @@ export const hitElementItself = ({
return result;
};
const isPointInRotatedBounds = (
point: GlobalPoint,
bounds: Bounds,
angle: Radians,
tolerance = 0,
) => {
const adjustedPoint =
angle === 0
? point
: pointRotateRads(point, getCenterForBounds(bounds), -angle as Radians);
return isPointWithinBounds(
pointFrom(bounds[0] - tolerance, bounds[1] - tolerance),
adjustedPoint,
pointFrom(bounds[2] + tolerance, bounds[3] + tolerance),
);
};
export const hitElementBoundingBox = (
point: GlobalPoint,
element: ExcalidrawElement,
elementsMap: ElementsMap,
tolerance = 0,
) => {
let [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
x1 -= tolerance;
y1 -= tolerance;
x2 += tolerance;
y2 += tolerance;
return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
const bounds = getElementBounds(element, elementsMap, true);
return isPointInRotatedBounds(point, bounds, element.angle, tolerance);
};
export const hitElementBoundingBoxOnly = (
@@ -313,7 +326,10 @@ export const getAllHoveredElementAtPoint = (
) {
candidateElements.push(element);
if (!isTransparent(element.backgroundColor)) {
if (
hasBackground(element.type) &&
!isTransparent(element.backgroundColor)
) {
break;
}
}
@@ -465,7 +481,12 @@ export const intersectElementWithLineSegment = (
case "line":
case "freedraw":
case "arrow":
return intersectLinearOrFreeDrawWithLineSegment(element, line, onlyFirst);
return intersectLinearOrFreeDrawWithLineSegment(
element,
line,
elementsMap,
onlyFirst,
);
}
};
@@ -532,11 +553,15 @@ const lineIntersections = (
const intersectLinearOrFreeDrawWithLineSegment = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
segment: LineSegment<GlobalPoint>,
elementsMap: ElementsMap,
onlyFirst = false,
): GlobalPoint[] => {
// NOTE: This is the only one which return the decomposed elements
// rotated! This is due to taking advantage of roughjs definitions.
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
const intersections: GlobalPoint[] = [];
for (const l of lines) {
@@ -564,7 +589,9 @@ const intersectLinearOrFreeDrawWithLineSegment = (
continue;
}
const hits = curveIntersectLineSegment(c, segment);
const hits = curveIntersectLineSegment(c, segment, {
iterLimit: 10,
});
if (hits.length > 0) {
intersections.push(...hits);
+6 -2
View File
@@ -48,7 +48,7 @@ export const distanceToElement = (
case "line":
case "arrow":
case "freedraw":
return distanceToLinearOrFreeDraElement(element, p);
return distanceToLinearOrFreeDraElement(element, elementsMap, p);
}
};
@@ -133,9 +133,13 @@ const distanceToEllipseElement = (
const distanceToLinearOrFreeDraElement = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
p: GlobalPoint,
) => {
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
return Math.min(
...lines.map((s) => distanceToLineSegment(p, s)),
...curves.map((a) => curvePointDistance(a, p)),
+22 -2
View File
@@ -111,6 +111,9 @@ export const duplicateElements = (
* user interaction.
*/
type: "everything";
// TODO remove/review this once we add frame children order migration
// and invariant checks
preserveFrameChildrenOrder?: boolean;
}
| {
/**
@@ -170,6 +173,8 @@ export const duplicateElements = (
opts.type === "in-place"
? opts.idsOfElementsToDuplicate
: new Map(elements.map((el) => [el.id, el]));
const preserveFrameChildrenOrder =
opts.type === "everything" && opts.preserveFrameChildrenOrder;
// For sanity
if (opts.type === "in-place") {
@@ -250,6 +255,9 @@ export const duplicateElements = (
elementsWithDuplicates.splice(index + 1, 0, ...castArray(elements));
};
// main
// ---------------------------------------------------------------------------
const frameIdsToDuplicate = new Set(
elements
.filter(
@@ -274,7 +282,7 @@ export const duplicateElements = (
if (groupId) {
const groupElements = getElementsInGroup(elements, groupId).flatMap(
(element) =>
isFrameLikeElement(element)
isFrameLikeElement(element) && !preserveFrameChildrenOrder
? [...getFrameChildren(elements, element.id), element]
: [element],
);
@@ -290,13 +298,25 @@ export const duplicateElements = (
// frame duplication
// -------------------------------------------------------------------------
if (element.frameId && frameIdsToDuplicate.has(element.frameId)) {
if (
!preserveFrameChildrenOrder &&
element.frameId &&
frameIdsToDuplicate.has(element.frameId)
) {
continue;
}
if (isFrameLikeElement(element)) {
const frameId = element.id;
if (preserveFrameChildrenOrder) {
insertBeforeOrAfterIndex(
findLastIndex(elementsWithDuplicates, (el) => el.id === frameId),
copyElements(element),
);
continue;
}
const frameChildren = getFrameChildren(elements, frameId);
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
+16 -4
View File
@@ -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,
@@ -2116,8 +2124,8 @@ const normalizeArrowElementUpdate = (
offsetY < -MAX_POS ||
offsetY > MAX_POS ||
offsetX + points[points.length - 1][0] < -MAX_POS ||
offsetY + points[points.length - 1][0] > MAX_POS ||
offsetX + points[points.length - 1][1] < -MAX_POS ||
offsetX + points[points.length - 1][0] > MAX_POS ||
offsetY + points[points.length - 1][1] < -MAX_POS ||
offsetY + points[points.length - 1][1] > MAX_POS
) {
console.error(
@@ -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,
);
}
+79 -2
View File
@@ -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";
+12 -2
View File
@@ -1,7 +1,10 @@
import { generateNKeysBetween } from "fractional-indexing";
import { arrayToMap } from "@excalidraw/common";
import {
validateOrderKey,
generateNKeysBetween,
} from "@excalidraw/fractional-indexing";
import { mutateElement, newElementWith } from "./mutateElement";
import { getBoundTextElement } from "./textElement";
import { hasBoundTextElement } from "./typeChecks";
@@ -382,6 +385,13 @@ const isValidFractionalIndex = (
return false;
}
try {
// Format validation
validateOrderKey(index);
} catch {
return false;
}
if (predecessor && successor) {
return predecessor < index && index < successor;
}
+108 -48
View File
@@ -1,7 +1,6 @@
import { arrayToMap } from "@excalidraw/common";
import { isPointWithinBounds, pointFrom } from "@excalidraw/math";
import { doLineSegmentsIntersect } from "@excalidraw/utils/bbox";
import { elementsOverlappingBBox } from "@excalidraw/utils/withinBounds";
import type {
AppClassProperties,
@@ -18,9 +17,13 @@ import {
getElementLineSegments,
getCommonBounds,
getElementAbsoluteCoords,
doBoundsIntersect,
getElementBounds,
boundsContainBounds,
} from "./bounds";
import { mutateElement } from "./mutateElement";
import { getBoundTextElement, getContainerElement } from "./textElement";
import { syncMovedIndices } from "./fractionalIndex";
import {
isFrameElement,
isFrameLikeElement,
@@ -100,8 +103,9 @@ export const isElementContainingFrame = (
frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap,
) => {
return getElementsWithinSelection([frame], element, elementsMap).some(
(e) => e.id === frame.id,
return boundsContainBounds(
getElementBounds(element, elementsMap),
getElementBounds(frame, elementsMap),
);
};
@@ -488,10 +492,44 @@ export const filterElementsEligibleAsFrameChildren = (
return eligibleElements;
};
export const getCommonFrameId = (elements: readonly ExcalidrawElement[]) => {
let commonFrameId: ExcalidrawElement["frameId"] | undefined;
for (const element of elements) {
if (isFrameLikeElement(element) || !element.frameId) {
return null;
}
if (commonFrameId === undefined) {
commonFrameId = element.frameId;
} else if (commonFrameId !== element.frameId) {
return null;
}
}
return commonFrameId ?? null;
};
export const getFrameChildrenInsertionIndex = (
elements: readonly ExcalidrawElement[],
frameId: ExcalidrawFrameLikeElement["id"],
): number | null => {
for (let index = elements.length - 1; index >= 0; index--) {
const element = elements[index];
if (element.id === frameId) {
return index;
} else if (element.frameId === frameId) {
return index + 1;
}
}
return null;
};
/**
* Retains (or repairs for target frame) the ordering invriant where children
* elements come right before the parent frame:
* [el, el, child, child, frame, el]
* Adds elements and their bound elements to frame. Reorders added elements to
* be just below frame, or just above its highest child (whichever is higher).
*
* @returns mutated allElements (same data structure)
*/
@@ -499,19 +537,11 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
allElements: T,
elementsToAdd: NonDeletedExcalidrawElement[],
frame: ExcalidrawFrameLikeElement,
appState: AppState,
): T => {
const elementsMap = arrayToMap(allElements);
const currTargetFrameChildrenMap = new Map<ExcalidrawElement["id"], true>();
for (const element of allElements.values()) {
if (element.frameId === frame.id) {
currTargetFrameChildrenMap.set(element.id, true);
}
}
const commonFrameId = getCommonFrameId(elementsToAdd);
const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id));
const finalElementsToAdd: ExcalidrawElement[] = [];
const finalElementsToAdd = new Set<ExcalidrawElement>();
const otherFrames = new Set<ExcalidrawFrameLikeElement["id"]>();
@@ -522,7 +552,8 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
}
// - add bound text elements if not already in the array
// - filter out elements that are already in the frame
// - keep elements already in the frame so mixed selections can be reordered
// together
for (const element of omitGroupsContainingFrameLikes(
allElements,
elementsToAdd,
@@ -535,38 +566,68 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
continue;
}
// if the element is already in another frame (which is also in elementsToAdd),
// it means that frame and children are selected at the same time
// => keep original frame membership, do not add to the target frame
if (
element.frameId &&
appState.selectedElementIds[element.id] &&
appState.selectedElementIds[element.frameId]
) {
if (element.frameId && element.frameId !== frame.id) {
continue;
}
if (!currTargetFrameChildrenMap.has(element.id)) {
finalElementsToAdd.push(element);
}
finalElementsToAdd.add(element);
const boundTextElement = getBoundTextElement(element, elementsMap);
if (
boundTextElement &&
!suppliedElementsToAddSet.has(boundTextElement.id) &&
!currTargetFrameChildrenMap.has(boundTextElement.id)
) {
finalElementsToAdd.push(boundTextElement);
if (boundTextElement && !finalElementsToAdd.has(boundTextElement)) {
finalElementsToAdd.add(boundTextElement);
}
}
for (const element of finalElementsToAdd) {
mutateElement(element, elementsMap, {
frameId: frame.id,
});
// we don't always need to update the element if it's already in the frame,
// but we still need to accumulate in finalElementsToAdd so we potentially
// reorder them if added together
if (element.frameId !== frame.id) {
mutateElement(element, elementsMap, {
frameId: frame.id,
});
}
}
return allElements;
// (re)order elements to be just below the frame,
// or just above the highest child if that is higher
// (latter case is denormalized order until we migrate)
// ---------------------------------------------------------------------------
if (
!finalElementsToAdd.size ||
// if all elements to add already belong to the frame, then we don't want to
// reorder (case: we're dragging element children within the frame)
commonFrameId === frame.id
) {
return allElements;
}
const otherElements = Array.from(allElements.values()).filter(
(element) => !finalElementsToAdd.has(element),
);
const insertionIndex = getFrameChildrenInsertionIndex(
otherElements,
frame.id,
);
if (insertionIndex === null) {
return allElements;
}
const reorderedElements = [
...otherElements.slice(0, insertionIndex),
...finalElementsToAdd,
...otherElements.slice(insertionIndex),
];
syncMovedIndices(reorderedElements, arrayToMap([...finalElementsToAdd]));
return (
Array.isArray(allElements)
? reorderedElements
: new Map(reorderedElements.map((element) => [element.id, element]))
) as T;
};
export const removeElementsFromFrame = (
@@ -620,13 +681,11 @@ export const replaceAllElementsInFrame = <T extends ExcalidrawElement>(
allElements: readonly T[],
nextElementsInFrame: ExcalidrawElement[],
frame: ExcalidrawFrameLikeElement,
app: AppClassProperties,
): T[] => {
return addElementsToFrame(
removeAllElementsFromFrame(allElements, frame),
nextElementsInFrame,
frame,
app.state,
).slice();
};
@@ -920,16 +979,17 @@ export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
export const getElementsOverlappingFrame = (
elements: readonly ExcalidrawElement[],
frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap,
) => {
return (
elementsOverlappingBBox({
elements,
bounds: frame,
type: "overlap",
})
// removes elements who are overlapping, but are in a different frame,
return elements.filter(
(el) =>
// exclude elements which are overlapping, but are in a different frame,
// and thus invisible in target frame
.filter((el) => !el.frameId || el.frameId === frame.id)
(!el.frameId || el.frameId === frame.id) &&
doBoundsIntersect(
getElementBounds(el, elementsMap),
getElementBounds(frame, elementsMap),
),
);
};
+1
View File
@@ -99,3 +99,4 @@ export * from "./typeChecks";
export * from "./utils";
export * from "./zindex";
export * from "./arrows/helpers";
export * from "./arrowheads";
+87 -54
View File
@@ -9,7 +9,6 @@ import {
vectorFromPoint,
curveLength,
curvePointAtLength,
lineSegment,
} from "@excalidraw/math";
import { getCurvePathOps } from "@excalidraw/utils/shape";
@@ -360,11 +359,20 @@ export class LinearElementEditor {
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)) {
@@ -419,6 +427,7 @@ export class LinearElementEditor {
"start",
elementsMap,
app.state.zoom,
app.state.isMidpointSnappingEnabled,
)
: linearElementEditor.initialState.altFocusPoint,
},
@@ -467,16 +476,22 @@ export class LinearElementEditor {
});
}
invariant(
lastClickedPoint > -1 &&
selectedPointsIndices.includes(lastClickedPoint) &&
element.points[lastClickedPoint],
`There must be a valid lastClickedPoint in order to drag it. selectedPointsIndices(${JSON.stringify(
selectedPointsIndices,
)}) points(0..${
element.points.length - 1
}) lastClickedPoint(${lastClickedPoint})`,
);
if (
lastClickedPoint < 0 ||
!selectedPointsIndices.includes(lastClickedPoint) ||
!element.points[lastClickedPoint]
) {
console.error(
`There must be a valid lastClickedPoint in order to drag it. selectedPointsIndices(${JSON.stringify(
selectedPointsIndices,
)}) points(0..${
element.points.length - 1
}) lastClickedPoint(${lastClickedPoint}) isElbowArrow: ${elbowed}`,
);
// Fall back to the actual last point as a last resort.
lastClickedPoint = element.points.length - 1;
}
// point that's being dragged (out of all selected points)
const draggingPoint = element.points[lastClickedPoint];
@@ -539,11 +554,20 @@ export class LinearElementEditor {
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)) {
@@ -637,6 +661,7 @@ export class LinearElementEditor {
"start",
elementsMap,
app.state.zoom,
app.state.isMidpointSnappingEnabled,
)
: linearElementEditor.initialState.altFocusPoint,
},
@@ -775,6 +800,7 @@ export class LinearElementEditor {
element.points[index + 1],
index,
appState.zoom,
elementsMap,
)
) {
midpoints.push(null);
@@ -784,6 +810,7 @@ export class LinearElementEditor {
const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
element,
index + 1,
elementsMap,
);
midpoints.push(segmentMidPoint);
index++;
@@ -871,6 +898,7 @@ export class LinearElementEditor {
endPoint: P,
index: number,
zoom: Zoom,
elementsMap: ElementsMap,
) {
if (isElbowArrow(element)) {
if (index >= 0 && index < element.points.length) {
@@ -885,7 +913,10 @@ export class LinearElementEditor {
let distance = pointDistance(startPoint, endPoint);
if (element.points.length > 2 && element.roundness) {
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
invariant(
lines.length === 0 && curves.length > 0,
@@ -905,6 +936,7 @@ export class LinearElementEditor {
static getSegmentMidPoint(
element: NonDeleted<ExcalidrawLinearElement>,
index: number,
elementsMap: ElementsMap,
): GlobalPoint {
if (isElbowArrow(element)) {
invariant(
@@ -917,7 +949,10 @@ export class LinearElementEditor {
return pointFrom<GlobalPoint>(element.x + p[0], element.y + p[1]);
}
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
invariant(
(lines.length === 0 && curves.length > 0) ||
@@ -1525,6 +1560,10 @@ export class LinearElementEditor {
endBinding?: FixedPointBinding | null;
moveMidPointsWithElement?: boolean | null;
},
options?: {
isBindingEnabled?: boolean;
isMidpointSnappingEnabled?: boolean;
},
) {
const { points } = element;
@@ -1593,6 +1632,8 @@ export class LinearElementEditor {
otherUpdates,
{
isDragging: Array.from(pointUpdates.values()).some((t) => t.isDragging),
isBindingEnabled: options?.isBindingEnabled,
isMidpointSnappingEnabled: options?.isMidpointSnappingEnabled,
},
);
}
@@ -1707,6 +1748,8 @@ export class LinearElementEditor {
isDragging?: boolean;
zoom?: AppState["zoom"];
sceneElementsMap?: NonDeletedSceneElementsMap;
isBindingEnabled?: boolean;
isMidpointSnappingEnabled?: boolean;
},
) {
if (isElbowArrow(element)) {
@@ -1727,6 +1770,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?
@@ -1822,6 +1867,7 @@ export class LinearElementEditor {
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
element,
index + 1,
elementsMap,
);
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
@@ -2093,13 +2139,13 @@ const pointDraggingUpdates = (
} => {
const naiveDraggingPoints = new Map(
selectedPointsIndices.map((pointIndex) => {
// NOTE: Avoid stale point index issue potentially caused by elbow
// arrows unpredictably changing the number of points during dragging
const point = element.points[pointIndex] ?? element.points.at(-1);
return [
pointIndex,
{
point: pointFrom<LocalPoint>(
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
),
point: pointFrom<LocalPoint>(point[0] + deltaX, point[1] + deltaY),
isDragging: true,
},
];
@@ -2146,14 +2192,16 @@ const pointDraggingUpdates = (
suggestedBinding: suggestedBindingElement
? {
element: suggestedBindingElement,
midPoint: snapToMid(
suggestedBindingElement,
elementsMap,
pointFrom<GlobalPoint>(
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
),
),
midPoint: app.state.isMidpointSnappingEnabled
? snapToMid(
suggestedBindingElement,
elementsMap,
pointFrom<GlobalPoint>(
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
),
)
: undefined,
}
: null,
},
@@ -2339,19 +2387,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 =
@@ -2382,14 +2417,12 @@ const pointDraggingUpdates = (
? nextArrow.points[0]
: endBindable
? updateBoundPoint(
element,
nextArrow,
"endBinding",
nextArrow.endBinding,
endBindable,
elementsMap,
{
customIntersector: endCustomIntersector,
},
endIsDragged,
) || nextArrow.points[nextArrow.points.length - 1]
: nextArrow.points[nextArrow.points.length - 1];
@@ -2415,12 +2448,12 @@ const pointDraggingUpdates = (
? endLocalPoint
: startBindable
? updateBoundPoint(
element,
nextArrow,
"startBinding",
nextArrow.startBinding,
startBindable,
elementsMap,
{ customIntersector: startCustomIntersector },
startIsDragged,
) || nextArrow.points[0]
: nextArrow.points[0];
+2
View File
@@ -40,6 +40,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
updates: ElementUpdate<TElement>,
options?: {
isDragging?: boolean;
isBindingEnabled?: boolean;
isMidpointSnappingEnabled?: boolean;
},
) => {
let didChange = false;
+317 -46
View File
@@ -1,16 +1,34 @@
import { arrayToMap, isShallowEqual } from "@excalidraw/common";
import { arrayToMap, isShallowEqual, type Bounds } from "@excalidraw/common";
import {
lineSegment,
pointFrom,
pointRotateRads,
type GlobalPoint,
} from "@excalidraw/math";
import type {
AppState,
BoxSelectionMode,
InteractiveCanvasAppState,
} from "@excalidraw/excalidraw/types";
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
import {
boundsContainBounds,
doBoundsIntersect,
elementCenterPoint,
getElementAbsoluteCoords,
getElementBounds,
pointInsideBounds,
} from "./bounds";
import { intersectElementWithLineSegment } from "./collision";
import { isElementInViewport } from "./sizeHelpers";
import {
isArrowElement,
isBoundToContainer,
isFrameLikeElement,
isFreeDrawElement,
isLinearElement,
isTextElement,
} from "./typeChecks";
import {
elementOverlapsWithFrame,
@@ -20,14 +38,33 @@ import {
import { LinearElementEditor } from "./linearElementEditor";
import { selectGroupsForSelectedElements } from "./groups";
import { getBoundTextElement } from "./textElement";
import type {
ElementsMap,
ElementsMapOrArray,
ExcalidrawElement,
ExcalidrawFrameLikeElement,
NonDeleted,
NonDeletedExcalidrawElement,
} from "./types";
const shouldIgnoreElementFromSelection = (
element: NonDeletedExcalidrawElement,
) => element.locked || isBoundToContainer(element);
const excludeElementsFromFrames = <T extends ExcalidrawElement>(
selectedElements: readonly T[],
framesInSelection: Set<ExcalidrawFrameLikeElement["id"]>,
) => {
return selectedElements.filter((element) => {
if (element.frameId && framesInSelection.has(element.frameId)) {
return false;
}
return true;
});
};
/**
* Frames and their containing elements are not to be selected at the same time.
* Given an array of selected elements, if there are frames and their containing elements
@@ -47,68 +84,286 @@ export const excludeElementsInFramesFromSelection = <
}
});
return selectedElements.filter((element) => {
if (element.frameId && framesInSelection.has(element.frameId)) {
return false;
}
return true;
});
return excludeElementsFromFrames(selectedElements, framesInSelection);
};
export const getElementsWithinSelection = (
elements: readonly NonDeletedExcalidrawElement[],
selection: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
// TODO remove (this flag is effectively unused AFAIK)
excludeElementsInFrames: boolean = true,
) => {
const [selectionX1, selectionY1, selectionX2, selectionY2] =
boxSelectionMode: BoxSelectionMode = "contain",
): NonDeletedExcalidrawElement[] => {
const [selectionStartX, selectionStartY, selectionEndX, selectionEndY] =
getElementAbsoluteCoords(selection, elementsMap);
const selectionX1 = Math.min(selectionStartX, selectionEndX);
const selectionY1 = Math.min(selectionStartY, selectionEndY);
const selectionX2 = Math.max(selectionStartX, selectionEndX);
const selectionY2 = Math.max(selectionStartY, selectionEndY);
const selectionBounds = [
selectionX1,
selectionY1,
selectionX2,
selectionY2,
] as Bounds;
const selectionEdges = [
lineSegment<GlobalPoint>(
pointFrom(selectionX1, selectionY1),
pointFrom(selectionX2, selectionY1),
),
lineSegment<GlobalPoint>(
pointFrom(selectionX2, selectionY1),
pointFrom(selectionX2, selectionY2),
),
lineSegment<GlobalPoint>(
pointFrom(selectionX2, selectionY2),
pointFrom(selectionX1, selectionY2),
),
lineSegment<GlobalPoint>(
pointFrom(selectionX1, selectionY2),
pointFrom(selectionX1, selectionY1),
),
];
let elementsInSelection = elements.filter((element) => {
let [elementX1, elementY1, elementX2, elementY2] = getElementBounds(
element,
elementsMap,
);
const framesInSelection = excludeElementsInFrames
? new Set<NonDeletedExcalidrawElement["id"]>()
: null;
const groups: Record<string, NonDeletedExcalidrawElement[]> = {};
const elementsInSelection: Set<NonDeletedExcalidrawElement> = new Set();
const containingFrame = getContainingFrame(element, elementsMap);
if (containingFrame) {
const [fx1, fy1, fx2, fy2] = getElementBounds(
containingFrame,
for (const element of elements) {
if (shouldIgnoreElementFromSelection(element)) {
continue;
}
// Track only selectable top-level group members, so ignored elements such
// as bound text and locked elements don't affect group selection.
const groupId = element.groupIds.at(-1);
if (groupId) {
if (!groups[groupId]) {
groups[groupId] = [];
}
groups[groupId].push(element);
}
const strokeWidth = element.strokeWidth;
let labelAABB: Bounds | null = null;
let elementAABB = getElementBounds(element, elementsMap);
elementAABB = [
elementAABB[0] - strokeWidth / 2,
elementAABB[1] - strokeWidth / 2,
elementAABB[2] + strokeWidth / 2,
elementAABB[3] + strokeWidth / 2,
] as Bounds;
// Whether the element bounds should include the bound text element bounds
const boundTextElement =
isArrowElement(element) && getBoundTextElement(element, elementsMap);
if (boundTextElement) {
const { x, y } = LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElement,
elementsMap,
);
elementX1 = Math.max(fx1, elementX1);
elementY1 = Math.max(fy1, elementY1);
elementX2 = Math.min(fx2, elementX2);
elementY2 = Math.min(fy2, elementY2);
labelAABB = [
x,
y,
x + boundTextElement.width,
y + boundTextElement.height,
] as Bounds;
}
return (
element.locked === false &&
element.type !== "selection" &&
!isBoundToContainer(element) &&
selectionX1 <= elementX1 &&
selectionY1 <= elementY1 &&
selectionX2 >= elementX2 &&
selectionY2 >= elementY2
);
});
// Clip element bounds by its containing frame (if any), since only the
// visible (frame-clipped) portion of the element is relevant for selection.
const associatedFrame = getContainingFrame(element, elementsMap);
if (
associatedFrame &&
elementOverlapsWithFrame(element, associatedFrame, elementsMap)
) {
const frameAABB = getElementBounds(associatedFrame, elementsMap);
elementAABB = [
Math.max(elementAABB[0], frameAABB[0]),
Math.max(elementAABB[1], frameAABB[1]),
Math.min(elementAABB[2], frameAABB[2]),
Math.min(elementAABB[3], frameAABB[3]),
] as Bounds;
elementsInSelection = excludeElementsInFrames
? excludeElementsInFramesFromSelection(elementsInSelection)
: elementsInSelection;
elementsInSelection = elementsInSelection.filter((element) => {
const containingFrame = getContainingFrame(element, elementsMap);
if (containingFrame) {
return elementOverlapsWithFrame(element, containingFrame, elementsMap);
labelAABB = labelAABB
? ([
Math.max(labelAABB[0], frameAABB[0]),
Math.max(labelAABB[1], frameAABB[1]),
Math.min(labelAABB[2], frameAABB[2]),
Math.min(labelAABB[3], frameAABB[3]),
] as Bounds)
: null;
}
return true;
});
const commonAABB = labelAABB
? ([
Math.min(labelAABB[0], elementAABB[0]),
Math.min(labelAABB[1], elementAABB[1]),
Math.max(labelAABB[2], elementAABB[2]),
Math.max(labelAABB[3], elementAABB[3]),
] as Bounds)
: elementAABB;
return elementsInSelection;
// ============== Evaluation ==============
// 1. If the selection box WRAPs the element's AABB, then add it to the
// selection and move on, regardless of the selection mode.
//
// PERF: This trick only works with axis-aligned box selection and the
// current convex element shapes!
if (boundsContainBounds(selectionBounds, commonAABB)) {
if (framesInSelection && isFrameLikeElement(element)) {
framesInSelection.add(element.id);
}
elementsInSelection.add(element);
continue;
}
// 2. Handle the case where the label is overlapped by the selection box
if (
boxSelectionMode === "overlap" &&
labelAABB &&
doBoundsIntersect(selectionBounds, labelAABB)
) {
elementsInSelection.add(element);
continue;
}
// 3. Handle the case where the selection is not wrapping the element, but
// it does intersect the element's outline (non-AABB).
if (
boxSelectionMode === "overlap" &&
doBoundsIntersect(selectionBounds, elementAABB)
) {
let hasIntersection = false;
// Preliminary check potential intersection imprecision
if (isLinearElement(element) || isFreeDrawElement(element)) {
const center = elementCenterPoint(element, elementsMap);
hasIntersection = element.points.some((point) => {
const rotatedPoint = pointRotateRads(
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
center,
element.angle,
);
return pointInsideBounds(rotatedPoint, selectionBounds);
});
} else {
const nonRotatedElementBounds = getElementBounds(
element,
elementsMap,
true,
);
const center = elementCenterPoint(element, elementsMap);
hasIntersection = [
pointRotateRads(
pointFrom<GlobalPoint>(
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
nonRotatedElementBounds[1],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
nonRotatedElementBounds[2],
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
nonRotatedElementBounds[3],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
nonRotatedElementBounds[0],
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
),
center,
element.angle,
),
].some((point) => {
return pointInsideBounds(
pointRotateRads(point, center, element.angle),
selectionBounds,
);
});
}
if (!hasIntersection) {
hasIntersection = selectionEdges.some(
(selectionEdge) =>
intersectElementWithLineSegment(
element,
elementsMap,
selectionEdge,
strokeWidth / 2,
true, // Stop at first hit for better performance
).length > 0,
);
}
if (hasIntersection) {
if (framesInSelection && isFrameLikeElement(element)) {
framesInSelection.add(element.id);
}
elementsInSelection.add(element);
continue;
}
}
// 4. We don't need to handle when the selection is inside the element
// as it is separately handled in App.
}
if (framesInSelection) {
elementsInSelection.forEach((element) => {
if (element.frameId && framesInSelection.has(element.frameId)) {
elementsInSelection.delete(element);
}
});
}
if (boxSelectionMode === "overlap") {
Array.from(elementsInSelection).forEach((element) => {
const groupId = element.groupIds.at(-1);
const group = groupId ? groups[groupId] : null;
group?.forEach((groupElement) => elementsInSelection.add(groupElement));
});
} else if (boxSelectionMode === "contain") {
elementsInSelection.forEach((element) => {
// note: currently we only support top-level group handling since
// we don't support box selecting while editing the group/subgroup
// see https://github.com/excalidraw/excalidraw/pull/11234#issuecomment-4387654451
const groupId = element.groupIds.at(-1);
const group = groupId ? groups[groupId] : null;
if (
group &&
!group.every((groupElement) => elementsInSelection.has(groupElement))
) {
elementsInSelection.delete(element);
}
});
}
// to maintain original order elements (namely for group selection)
return elements.filter((element) => elementsInSelection.has(element));
};
export const getVisibleAndNonSelectedElements = (
@@ -288,3 +543,19 @@ export const getSelectionStateForElements = (
),
};
};
/**
* Returns editing or single-selected text element, if any.
*/
export const getActiveTextElement = (
selectedElements: readonly NonDeleted<ExcalidrawElement>[],
appState: Pick<AppState, "editingTextElement">,
) => {
const activeTextElement =
appState.editingTextElement ||
(selectedElements.length === 1 &&
isTextElement(selectedElements[0]) &&
selectedElements[0]);
return activeTextElement || null;
};
+230 -102
View File
@@ -57,8 +57,8 @@ import { headingForPointIsHorizontal } from "./heading";
import { canChangeRoundness } from "./comparisons";
import {
elementCenterPoint,
getArrowheadPoints,
getCenterForBounds,
getDiamondPoints,
getElementAbsoluteCoords,
} from "./bounds";
@@ -69,10 +69,10 @@ import type {
NonDeletedExcalidrawElement,
ExcalidrawSelectionElement,
ExcalidrawLinearElement,
Arrowhead,
ExcalidrawFreeDrawElement,
ElementsMap,
ExcalidrawLineElement,
Arrowhead,
} from "./types";
import type { Drawable, Options } from "roughjs/bin/core";
@@ -296,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[],
@@ -306,63 +382,54 @@ const getArrowheadShapes = (
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
: strokeColor,
fillStyle: "solid",
stroke: 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(
@@ -372,24 +439,34 @@ const getArrowheadShapes = (
[x3, y3],
[x, y],
],
{
...options,
fill:
arrowhead === "triangle_outline"
? canvasBackgroundColor
: 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(
@@ -400,53 +477,117 @@ const getArrowheadShapes = (
[x4, y4],
[x, y],
],
{
...options,
fill:
arrowhead === "diamond_outline"
? canvasBackgroundColor
: 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),
);
}
}
};
export const generateLinearCollisionShape = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
) => {
elementsMap: ElementsMap,
): {
op: string;
data: number[];
}[] => {
const generator = new RoughGenerator();
const options: Options = {
seed: element.seed,
@@ -455,20 +596,7 @@ export const generateLinearCollisionShape = (
roughness: 0,
preserveVertices: true,
};
const center = getCenterForBounds(
// Need a non-rotated center point
element.points.reduce(
(acc, point) => {
return [
Math.min(element.x + point[0], acc[0]),
Math.min(element.y + point[1], acc[1]),
Math.max(element.x + point[0], acc[2]),
Math.max(element.y + point[1], acc[3]),
];
},
[Infinity, Infinity, -Infinity, -Infinity],
),
);
const center = elementCenterPoint(element, elementsMap);
switch (element.type) {
case "line":
+63 -65
View File
@@ -1,59 +1,56 @@
import { arrayToMapWithIndex } from "@excalidraw/common";
import { arrayToMap } from "@excalidraw/common";
import type { ExcalidrawElement } from "./types";
const normalizeGroupElementOrder = (elements: readonly ExcalidrawElement[]) => {
const origElements: ExcalidrawElement[] = elements.slice();
const sortedElements = new Set<ExcalidrawElement>();
const orderInnerGroups = (
elements: readonly ExcalidrawElement[],
): ExcalidrawElement[] => {
const firstGroupSig = elements[0]?.groupIds?.join("");
const aGroup: ExcalidrawElement[] = [elements[0]];
const bGroup: ExcalidrawElement[] = [];
for (const element of elements.slice(1)) {
if (element.groupIds?.join("") === firstGroupSig) {
aGroup.push(element);
} else {
bGroup.push(element);
}
}
return bGroup.length ? [...aGroup, ...orderInnerGroups(bGroup)] : aGroup;
const defragmentGroups = (elements: readonly ExcalidrawElement[]) => {
const groupIdAtLevel = (element: ExcalidrawElement, level: number) => {
return element.groupIds[element.groupIds.length - level - 1];
};
const groupHandledElements = new Map<string, true>();
const orderLevel = (
levelElements: readonly ExcalidrawElement[],
level: number,
): ExcalidrawElement[] => {
const buckets = new Map<string, ExcalidrawElement[]>();
// Slots preserve first-occurrence order: a groupId reserves its slot
// the first time one of its members is seen; loose elements occupy
// their own slot. Groups are then expanded (and recursed into) in place.
const slots: (ExcalidrawElement | string)[] = [];
origElements.forEach((element, idx) => {
if (groupHandledElements.has(element.id)) {
return;
}
if (element.groupIds?.length) {
const topGroup = element.groupIds[element.groupIds.length - 1];
const groupElements = origElements.slice(idx).filter((element) => {
const ret = element?.groupIds?.some((id) => id === topGroup);
if (ret) {
groupHandledElements.set(element!.id, true);
}
return ret;
});
for (const elem of orderInnerGroups(groupElements)) {
sortedElements.add(elem);
for (const element of levelElements) {
const groupId = groupIdAtLevel(element, level);
if (groupId === undefined) {
slots.push(element);
continue;
}
} else {
sortedElements.add(element);
let bucket = buckets.get(groupId);
if (!bucket) {
bucket = [];
buckets.set(groupId, bucket);
slots.push(groupId);
}
bucket.push(element);
}
});
return slots.flatMap((slot) =>
typeof slot === "string"
? orderLevel(buckets.get(slot)!, level + 1)
: [slot],
);
};
// `groupIds` is stored innermost-first, so the outermost group is the
// last entry. We recurse from level 0 (outermost) inward.
const sortedElements = orderLevel(elements, 0);
// if there's a bug which resulted in losing some of the elements, return
// original instead as that's better than losing data
if (sortedElements.size !== elements.length) {
console.error("normalizeGroupElementOrder: lost some elements... bailing!");
if (sortedElements.length !== elements.length) {
console.error("defragmentGroups: lost some elements... bailing!");
return elements;
}
return [...sortedElements];
return sortedElements;
};
/**
@@ -68,39 +65,40 @@ const normalizeGroupElementOrder = (elements: readonly ExcalidrawElement[]) => {
const normalizeBoundElementsOrder = (
elements: readonly ExcalidrawElement[],
) => {
const elementsMap = arrayToMapWithIndex(elements);
const elementsMap = arrayToMap(elements);
const origElements: (ExcalidrawElement | null)[] = elements.slice();
const sortedElements = new Set<ExcalidrawElement>();
origElements.forEach((element, idx) => {
if (!element) {
return;
for (const element of elements) {
if (sortedElements.has(element)) {
continue;
}
if (element.boundElements?.length) {
sortedElements.add(element);
origElements[idx] = null;
element.boundElements.forEach((boundElement) => {
for (const boundElement of element.boundElements) {
const child = elementsMap.get(boundElement.id);
if (child && boundElement.type === "text") {
sortedElements.add(child[0]);
origElements[child[1]] = null;
sortedElements.add(child);
}
});
} else if (element.type === "text" && element.containerId) {
const parent = elementsMap.get(element.containerId);
if (!parent?.[0].boundElements?.find((x) => x.id === element.id)) {
sortedElements.add(element);
origElements[idx] = null;
// if element has a container and container lists it, skip this element
// as it'll be taken care of by the container
}
} else {
sortedElements.add(element);
origElements[idx] = null;
continue;
}
});
// if element has a container and container lists it, skip this element
// as it'll be taken care of by the container
if (
element.type === "text" &&
element.containerId &&
elementsMap
.get(element.containerId)
?.boundElements?.some((el) => el.id === element.id)
) {
continue;
}
sortedElements.add(element);
}
// if there's a bug which resulted in losing some of the elements, return
// original instead as that's better than losing data
@@ -117,5 +115,5 @@ const normalizeBoundElementsOrder = (
export const normalizeElementOrder = (
elements: readonly ExcalidrawElement[],
) => {
return normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
return normalizeBoundElementsOrder(defragmentGroups(elements));
};
+3 -1
View File
@@ -347,6 +347,7 @@ export const getContainerCenter = (
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
container,
index + 1,
elementsMap,
);
}
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
@@ -441,7 +442,8 @@ const VALID_CONTAINER_TYPES = new Set([
export const isValidTextContainer = (element: {
type: ExcalidrawElementType;
}) => VALID_CONTAINER_TYPES.has(element.type);
}): element is ExcalidrawTextContainer =>
VALID_CONTAINER_TYPES.has(element.type);
export const computeContainerDimensionForBoundText = (
dimension: number,
+207 -37
View File
@@ -4,6 +4,22 @@ import { charWidth, getLineWidth } from "./textMeasurements";
import type { FontString } from "./types";
/**
* This module approximates browser-like soft wrapping for Excalidraw text.
*
* The flow is:
* 1. `parseTokens()` splits a hard line into breakable tokens using a unicode-aware regex.
* 2. `getWrappedTextLines()` reflows each hard line into one or more visual lines and
* records where each visual line came from in the source text.
* 3. `wrapLine()` assembles tokens into lines, and `wrapWord()` handles a single token
* that is wider than the available width.
* 4. `trimLine()` / `trimLineEndAtSoftBreak()` mirror browser behavior around trailing
* whitespace so the rendered text stays consistent with what users see on canvas.
*
* Mostly, you'll want to use wrapText(). getWrappedTextLines() is for callers
* that need metadata such as mapping visual lines back to `originalText`
* for caret placement or future editor features.
*/
let cachedCjkRegex: RegExp | undefined;
let cachedLineBreakRegex: RegExp | undefined;
let cachedEmojiRegex: RegExp | undefined;
@@ -358,6 +374,10 @@ const Break = {
/**
* Breaks the line into the tokens based on the found line break opporutnities.
*
* Note: tokenization normalizes to NFC first so decomposed graphemes are treated as
* their composed variants for wrapping. Any code that needs exact source offsets should
* keep in mind that this assumes the input text is already NFC-normalized.
*/
export const parseTokens = (line: string) => {
const breakLineRegex = getLineBreakRegex();
@@ -370,56 +390,120 @@ export const parseTokens = (line: string) => {
/**
* Wraps the original text into the lines based on the given width.
*
* This is a convenience adapter over `getWrappedTextLines()` for call sites
* that only need the rendered wrapped string and not the source offsets.
*/
export const wrapText = (
text: string,
font: FontString,
maxWidth: number,
): string => {
return getWrappedTextLines(text, font, maxWidth)
.map((line) => line.text)
.join("\n");
};
/**
* A single rendered visual line produced from the original text.
*
* `start` and `end` are end-exclusive code-unit offsets into the original text, and do
* not include synthetic soft line breaks inserted by this module. If trailing whitespace
* was trimmed away at a wrap boundary, `end` points to the last rendered character.
*/
export type WrappedTextLine = {
text: string;
start: number;
end: number;
};
/**
* Splits only on existing hard line breaks and preserves original offsets.
*/
const getHardLineBreaks = (text: string): WrappedTextLine[] => {
let offset = 0;
return text.split("\n").map((line) => {
const start = offset;
const end = start + line.length;
offset = end + 1;
return {
text: line,
start,
end,
};
});
};
/**
* Returns the rendered visual lines together with their source offsets.
*
* This is the source-of-truth wrapping pipeline for callers that need more than the
* final wrapped string, for example caret placement or future editor/rich-text mapping.
*/
export const getWrappedTextLines = (
text: string,
font: FontString,
maxWidth: number,
): WrappedTextLine[] => {
// if maxWidth is not finite or NaN which can happen in case of bugs in
// computation, we need to make sure we don't continue as we'll end up
// in an infinite loop
if (!Number.isFinite(maxWidth) || maxWidth < 0) {
return text;
return getHardLineBreaks(text);
}
const lines: Array<string> = [];
const originalLines = text.split("\n");
const lines: WrappedTextLine[] = [];
let offset = 0;
for (const originalLine of originalLines) {
const currentLineWidth = getLineWidth(originalLine, font);
for (const originalLine of text.split("\n")) {
const originalLineWidth = getLineWidth(originalLine, font);
if (currentLineWidth <= maxWidth) {
lines.push(originalLine);
continue;
if (originalLineWidth <= maxWidth) {
lines.push({
text: originalLine,
start: offset,
end: offset + originalLine.length,
});
} else {
lines.push(...wrapLine(originalLine, font, maxWidth, offset));
}
const wrappedLine = wrapLine(originalLine, font, maxWidth);
lines.push(...wrappedLine);
offset += originalLine.length + 1;
}
return lines.join("\n");
return lines;
};
/**
* Wraps the original line into the lines based on the given width.
* Wraps a single hard line into one or more visual lines.
*
* The line-local offsets are tracked in original-text code units so
* we can map the visual line back to the source.
*/
const wrapLine = (
line: string,
font: FontString,
maxWidth: number,
): string[] => {
const lines: Array<string> = [];
lineStart: number,
): WrappedTextLine[] => {
const lines: WrappedTextLine[] = [];
const tokens = parseTokens(line);
const tokenIterator = tokens[Symbol.iterator]();
let currentLine = "";
let currentLineStart = lineStart;
let currentLineEnd = lineStart;
let currentLineWidth = 0;
// Tracks the next token's code-unit position in the original source string.
let tokenOffset = lineStart;
let tokenIndex = 0;
let iterator = tokenIterator.next();
while (!iterator.done) {
const token = iterator.value;
while (tokenIndex < tokens.length) {
const token = tokens[tokenIndex];
const tokenStart = tokenOffset;
const tokenEnd = tokenStart + token.length;
const testLine = currentLine + token;
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
@@ -429,37 +513,59 @@ const wrapLine = (
// build up the current line, skipping length check for possibly trailing whitespaces
if (/\s/.test(token) || testLineWidth <= maxWidth) {
if (!currentLine) {
currentLineStart = tokenStart;
}
currentLine = testLine;
currentLineEnd = tokenEnd;
currentLineWidth = testLineWidth;
iterator = tokenIterator.next();
tokenOffset = tokenEnd;
tokenIndex++;
continue;
}
// current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped
if (!currentLine) {
const wrappedWord = wrapWord(token, font, maxWidth);
const trailingLine = wrappedWord[wrappedWord.length - 1] ?? "";
const wrappedWord = wrapWord(token, font, maxWidth, tokenStart);
const trailingLine = wrappedWord[wrappedWord.length - 1] ?? {
text: "",
start: tokenStart,
end: tokenStart,
};
const precedingLines = wrappedWord.slice(0, -1);
lines.push(...precedingLines);
// trailing line of the wrapped word might still be joined with next token/s
currentLine = trailingLine;
currentLineWidth = getLineWidth(trailingLine, font);
iterator = tokenIterator.next();
currentLine = trailingLine.text;
currentLineStart = trailingLine.start;
currentLineEnd = trailingLine.end;
currentLineWidth = getLineWidth(trailingLine.text, font);
tokenOffset = tokenEnd;
tokenIndex++;
} else {
// push & reset, but don't iterate on the next token, as we didn't use it yet!
lines.push(currentLine.trimEnd());
lines.push(
trimLineEndAtSoftBreak(currentLine, currentLineStart, currentLineEnd),
);
// purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above
currentLine = "";
currentLineStart = tokenStart;
currentLineEnd = tokenStart;
currentLineWidth = 0;
}
}
// iterator done, push the trailing line if exists
if (currentLine) {
const trailingLine = trimLine(currentLine, font, maxWidth);
const trailingLine = trimLine(
currentLine,
currentLineStart,
currentLineEnd,
font,
maxWidth,
);
lines.push(trailingLine);
}
@@ -467,59 +573,100 @@ const wrapLine = (
};
/**
* Wraps the word into the lines based on the given width.
* Wraps a single word that could not be placed on an empty line as-is.
*/
const wrapWord = (
word: string,
font: FontString,
maxWidth: number,
): Array<string> => {
wordStart: number,
): WrappedTextLine[] => {
// multi-codepoint emojis are already broken apart and shouldn't be broken further
if (getEmojiRegex().test(word)) {
return [word];
return [
{
text: word,
start: wordStart,
end: wordStart + word.length,
},
];
}
satisfiesWordInvariant(word);
const lines: Array<string> = [];
const lines: WrappedTextLine[] = [];
const chars = Array.from(word);
let currentLine = "";
let currentLineStart = wordStart;
let currentLineEnd = wordStart;
let currentLineWidth = 0;
let offset = wordStart;
for (const char of chars) {
const charStart = offset;
const charEnd = charStart + char.length;
const _charWidth = charWidth.calculate(char, font);
const testLineWidth = currentLineWidth + _charWidth;
if (testLineWidth <= maxWidth) {
if (!currentLine) {
currentLineStart = charStart;
}
currentLine = currentLine + char;
currentLineEnd = charEnd;
currentLineWidth = testLineWidth;
offset = charEnd;
continue;
}
if (currentLine) {
lines.push(currentLine);
lines.push({
text: currentLine,
start: currentLineStart,
end: currentLineEnd,
});
}
currentLine = char;
currentLineStart = charStart;
currentLineEnd = charEnd;
currentLineWidth = _charWidth;
offset = charEnd;
}
if (currentLine) {
lines.push(currentLine);
lines.push({
text: currentLine,
start: currentLineStart,
end: currentLineEnd,
});
}
return lines;
};
/**
* Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`.
* Trims trailing whitespace that is exceeding the `maxWidth`.
*
* Used for the trailing visual line of a hard line, where some trailing
* whitespace may still be visible if it fits into the available width.
*/
const trimLine = (line: string, font: FontString, maxWidth: number) => {
const trimLine = (
line: string,
start: number,
end: number,
font: FontString,
maxWidth: number,
): WrappedTextLine => {
const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth;
if (!shouldTrimWhitespaces) {
return line;
return {
text: line,
start,
end,
};
}
// defensively default to `trimeEnd` in case the regex does not match
@@ -543,7 +690,30 @@ const trimLine = (line: string, font: FontString, maxWidth: number) => {
trimmedLineWidth = testLineWidth;
}
return trimmedLine;
return {
text: trimmedLine,
start,
end: end - (line.length - trimmedLine.length),
};
};
/**
* Used for internal soft-wrap boundaries, where trailing whitespace should not
* survive into the rendered line even though it still exists in the original
* text.
*/
const trimLineEndAtSoftBreak = (
line: string,
start: number,
end: number,
): WrappedTextLine => {
const trimmedLine = line.trimEnd();
return {
text: trimmedLine,
start,
end: end - (line.length - trimmedLine.length),
};
};
/**
+20
View File
@@ -392,3 +392,23 @@ export const canBecomePolygon = (
(points.length === 3 && !pointsEqual(points[0], points[points.length - 1]))
);
};
export const isEligibleFrameChildType = (type: ElementOrToolType) => {
switch (type) {
case "rectangle":
case "diamond":
case "ellipse":
case "arrow":
case "line":
case "freedraw":
case "text":
case "image":
case "frame":
case "embeddable": {
return true;
}
default: {
return false;
}
}
};
+18 -5
View File
@@ -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<{
+43 -13
View File
@@ -43,6 +43,11 @@ import { LinearElementEditor } from "./linearElementEditor";
import { isRectangularElement } from "./typeChecks";
import { maxBindingDistance_simple } from "./binding";
import {
getGlobalFixedPointForBindableElement,
normalizeFixedPoint,
} from "./binding";
import type {
ElementsMap,
ExcalidrawArrowElement,
@@ -119,6 +124,7 @@ const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
*/
export function deconstructLinearOrFreeDrawElement(
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const cachedShape = getElementShapesCacheEntry(element, 0);
@@ -126,10 +132,7 @@ export function deconstructLinearOrFreeDrawElement(
return cachedShape;
}
const ops = generateLinearCollisionShape(element) as {
op: string;
data: number[];
}[];
const ops = generateLinearCollisionShape(element, elementsMap);
const lines = [];
const curves = [];
@@ -654,20 +657,23 @@ export const projectFixedPointOntoDiagonal = (
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;
}
const sideMidPoint = getSnapOutlineMidPoint(
point,
element,
elementsMap,
zoom,
);
if (sideMidPoint) {
return sideMidPoint;
if (isMidpointSnappingEnabled) {
const sideMidPoint = getSnapOutlineMidPoint(
point,
element,
elementsMap,
zoom,
);
if (sideMidPoint) {
return sideMidPoint;
}
}
// Do the projection onto the diagonals (or center lines
@@ -677,11 +683,35 @@ export const projectFixedPointOntoDiagonal = (
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),
+3 -1
View File
@@ -1,4 +1,4 @@
import { arrayToMap } from "@excalidraw/common";
import { arrayToMap, reseed } 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";
@@ -12,6 +12,7 @@ import { hitElementItself } from "../src/collision";
describe("check rotated elements can be hit:", () => {
beforeEach(async () => {
localStorage.clear();
reseed(7);
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
@@ -56,6 +57,7 @@ describe("hitElementItself cache", () => {
});
localStorage.clear();
reseed(7);
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
+81 -1
View File
@@ -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);
});
});
+63 -3
View File
@@ -1,9 +1,8 @@
/* eslint-disable no-lone-blocks */
import { generateKeyBetween } from "fractional-indexing";
import { arrayToMap } from "@excalidraw/common";
import {
InvalidFractionalIndexError,
syncInvalidIndices,
syncMovedIndices,
validateFractionalIndices,
@@ -13,13 +12,34 @@ import { deepCopyElement } from "@excalidraw/element";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import {
generateKeyBetween,
validateOrderKey,
} from "@excalidraw/fractional-indexing";
import type {
ElementsMap,
ExcalidrawElement,
FractionalIndex,
} from "@excalidraw/element/types";
import { InvalidFractionalIndexError } from "../src/fractionalIndex";
describe("fractional index format validation", () => {
it("should reject malformed base62 order keys", () => {
expect(() => validateOrderKey("a!")).toThrow();
expect(() => validateOrderKey("a_")).toThrow();
expect(() => validateOrderKey("a1!")).toThrow();
expect(() => validateOrderKey("a1_")).toThrow();
expect(() => validateOrderKey("zd0032")).toThrow();
});
it("should accept valid base62 order keys", () => {
expect(() => validateOrderKey("Zz")).not.toThrow();
expect(() => validateOrderKey("a0")).not.toThrow();
expect(() => validateOrderKey("a1")).not.toThrow();
expect(() => validateOrderKey("a1V")).not.toThrow();
expect(() => validateOrderKey("z".padEnd(28, "z"))).not.toThrow();
});
});
describe("sync invalid indices with array order", () => {
describe("should NOT sync empty array", () => {
@@ -104,6 +124,46 @@ describe("sync invalid indices with array order", () => {
});
});
describe("should sync when fractional index is malformed", () => {
// "zd0032" has head "z" which requires length 28 per getIntegerLength,
// but the string is far too short, so validateOrderKey throws for it
testInvalidIndicesSync({
elements: [{ id: "A", index: "zd0032" }],
expect: {
unchangedElements: [],
},
});
testInvalidIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "zd0032" },
{ id: "C", index: "a3" },
],
expect: {
unchangedElements: ["A", "C"],
},
});
testInvalidIndicesSync({
elements: [{ id: "A", index: "a!" }],
expect: {
unchangedElements: [],
},
});
testInvalidIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "a!" },
{ id: "C", index: "a2" },
],
expect: {
unchangedElements: ["A", "C"],
},
});
});
describe("should sync when fractional indices are duplicated", () => {
testInvalidIndicesSync({
elements: [
+594 -2
View File
@@ -2,15 +2,24 @@ 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";
import { Keyboard, Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import { getTextEditor } from "@excalidraw/excalidraw/tests/queries/dom";
import {
getCloneByOrigId,
render,
} from "@excalidraw/excalidraw/tests/test-utils";
import type { ExcalidrawElement } from "../src/types";
import { getSelectedElements } from "@excalidraw/excalidraw/scene";
import { elementOverlapsWithFrame } from "../src/frame";
import type {
ExcalidrawElement,
ExcalidrawFrameLikeElement,
} from "../src/types";
const { h } = window;
const mouse = new Pointer("mouse");
@@ -125,6 +134,250 @@ describe("adding elements to frames", () => {
});
});
it("should treat an element fully containing a frame as overlapping the frame", () => {
const containingRect = API.createElement({
type: "rectangle",
x: -50,
y: -50,
width: 250,
height: 250,
});
API.setElements([containingRect, frame]);
expect(
elementOverlapsWithFrame(
containingRect,
frame as ExcalidrawFrameLikeElement,
arrayToMap(h.elements),
),
).toBe(true);
});
it("should not add a newly created element to a frame behind a non-frame element", () => {
const cover = API.createElement({
id: "cover",
type: "rectangle",
x: 10,
y: 10,
width: 80,
height: 80,
backgroundColor: "#ffc9c9",
});
API.setElements([frame, cover]);
UI.clickTool("rectangle");
mouse.downAt(20, 20);
mouse.moveTo(40, 40);
mouse.upAt(40, 40);
const createdElement = h.elements.find(
(element) => element.id !== frame.id && element.id !== cover.id,
);
expect(createdElement?.frameId).toBe(null);
expect(h.elements.map((element) => element.id)).toEqual([
frame.id,
cover.id,
createdElement?.id,
]);
});
it("should add a newly created element to a frame over a non-frame element", () => {
const cover = API.createElement({
id: "cover",
type: "rectangle",
x: 10,
y: 10,
width: 80,
height: 80,
backgroundColor: "#ffc9c9",
});
API.setElements([cover, frame]);
UI.clickTool("rectangle");
mouse.downAt(20, 20);
mouse.moveTo(40, 40);
mouse.upAt(40, 40);
const createdElement = h.elements.find(
(element) => element.id !== frame.id && element.id !== cover.id,
);
expect(createdElement?.frameId).toBe(frame.id);
});
it("should highlight the target frame while creating a new element", () => {
API.setElements([frame]);
UI.clickTool("rectangle");
mouse.downAt(20, 20);
mouse.moveTo(40, 40);
expect(h.state.frameToHighlight?.id).toBe(frame.id);
mouse.upAt(40, 40);
expect(h.state.frameToHighlight).toBe(null);
});
it("should highlight the target frame while hovering with a creation tool", () => {
API.setElements([frame]);
UI.clickTool("rectangle");
mouse.moveTo(20, 20);
expect(h.state.frameToHighlight?.id).toBe(frame.id);
mouse.moveTo(200, 200);
expect(h.state.frameToHighlight).toBe(null);
});
it("should not add grid-snapped text outside the frame to the clicked frame", async () => {
const offsetFrame = API.createElement({
id: "offsetFrame",
type: "frame",
x: 10,
y: 0,
width: 150,
height: 150,
});
API.setElements([offsetFrame]);
API.setAppState({
gridModeEnabled: true,
});
UI.clickTool("text");
mouse.clickAt(12, 0);
await getTextEditor();
const createdText = h.elements.find(
(element) => element.id !== offsetFrame.id,
);
expect(createdText?.x).toBe(0);
expect(createdText?.y).toBe(0);
expect(createdText?.frameId).toBe(null);
});
it("should add a newly created element to a frame behind another frame", () => {
const lockedFrame = API.createElement({
id: "lockedFrame",
type: "frame",
x: 10,
y: 10,
width: 80,
height: 80,
locked: true,
});
API.setElements([frame, lockedFrame]);
UI.clickTool("rectangle");
mouse.downAt(20, 20);
mouse.moveTo(40, 40);
mouse.upAt(40, 40);
const createdElement = h.elements.find(
(element) => element.id !== frame.id && element.id !== lockedFrame.id,
);
expect(createdElement?.frameId).toBe(frame.id);
});
it("should insert a newly created frame child just below its frame", () => {
const frameChildUnderCursor = API.createElement({
id: "frameChildUnderCursor",
type: "rectangle",
x: 10,
y: 10,
width: 80,
height: 80,
backgroundColor: "#ffc9c9",
frameId: frame.id,
});
const otherFrameChild = API.createElement({
id: "otherFrameChild",
type: "rectangle",
x: 100,
y: 20,
width: 20,
height: 20,
frameId: frame.id,
});
API.setElements([frameChildUnderCursor, otherFrameChild, frame]);
UI.clickTool("rectangle");
mouse.downAt(20, 20);
mouse.moveTo(40, 40);
mouse.upAt(40, 40);
const createdElement = h.elements.find(
(element) =>
element.id !== frame.id &&
element.id !== frameChildUnderCursor.id &&
element.id !== otherFrameChild.id,
);
expect(createdElement?.frameId).toBe(frame.id);
expect(h.elements.map((element) => element.id)).toEqual([
frameChildUnderCursor.id,
otherFrameChild.id,
createdElement?.id,
frame.id,
]);
});
it("should insert a newly created frame child above the highest frame child", () => {
const frameChildUnderCursor = API.createElement({
id: "frameChildUnderCursor",
type: "rectangle",
x: 10,
y: 10,
width: 80,
height: 80,
backgroundColor: "#ffc9c9",
frameId: frame.id,
});
const otherFrameChild = API.createElement({
id: "otherFrameChild",
type: "rectangle",
x: 100,
y: 20,
width: 20,
height: 20,
frameId: frame.id,
});
API.setElements([frame, frameChildUnderCursor, otherFrameChild]);
UI.clickTool("rectangle");
mouse.downAt(20, 20);
mouse.moveTo(40, 40);
mouse.upAt(40, 40);
const createdElement = h.elements.find(
(element) =>
element.id !== frame.id &&
element.id !== frameChildUnderCursor.id &&
element.id !== otherFrameChild.id,
);
expect(createdElement?.frameId).toBe(frame.id);
expect(h.elements.map((element) => element.id)).toEqual([
frame.id,
frameChildUnderCursor.id,
otherFrameChild.id,
createdElement?.id,
]);
});
const commonTestCases = async (
func: typeof resizeFrameOverElement | typeof dragElementIntoFrame,
) => {
@@ -415,6 +668,345 @@ describe("adding elements to frames", () => {
describe("dragging elements into the frame", async () => {
await commonTestCases(dragElementIntoFrame);
it("should add a dragged element fully containing the frame", () => {
const containingRect = API.createElement({
type: "rectangle",
x: 220,
y: 20,
width: 300,
height: 300,
});
API.setElements([frame, containingRect]);
dragElementIntoFrame(frame, containingRect);
expect(API.getElement(containingRect).frameId).toBe(frame.id);
});
it("should drag an element into a frame", () => {
API.setElements([rect2, frame]);
dragElementIntoFrame(frame, rect2);
expect(rect2.frameId).toBe(frame.id);
});
it("should layer a dragged element above the highest frame child", () => {
const frameChild = API.createElement({
id: "frameChild",
type: "rectangle",
x: 10,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
API.setElements([frame, frameChild, rect2]);
dragElementIntoFrame(frame, rect2);
expect(rect2.frameId).toBe(frame.id);
expect(h.elements.map((element) => element.id)).toEqual([
frame.id,
frameChild.id,
rect2.id,
]);
expect(rect2.index! > frameChild.index!).toBe(true);
expect(rect2.index! > frame.index!).toBe(true);
});
it("should preview a dragged element above the highest frame child before pointerup", () => {
const frameChild = API.createElement({
id: "frameChild",
type: "rectangle",
x: 10,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
API.setElements([rect2, frame, frameChild]);
API.setSelectedElements([rect2]);
API.updateElement(rect2, {
x: 10,
y: 10,
});
const getRenderableElementIds = (
selectedElementsAreBeingDragged: boolean,
) => {
return h.app.renderer
.getRenderableElements({
zoom: h.state.zoom,
offsetLeft: 0,
offsetTop: 0,
scrollX: 0,
scrollY: 0,
height: 1000,
width: 1000,
editingTextElement: h.state.editingTextElement,
newElement: h.state.newElement,
selectedElements: getSelectedElements(h.elements, h.state),
selectedElementsAreBeingDragged,
frameToHighlight: frame as ExcalidrawFrameLikeElement,
})
.visibleElements.map((element) => element.id);
};
expect(h.elements.map((element) => element.id)).toEqual([
rect2.id,
frame.id,
frameChild.id,
]);
expect(getRenderableElementIds(false)).toEqual([
rect2.id,
frame.id,
frameChild.id,
]);
expect(getRenderableElementIds(true)).toEqual([
frame.id,
frameChild.id,
rect2.id,
]);
expect(h.elements.map((element) => element.id)).toEqual([
rect2.id,
frame.id,
frameChild.id,
]);
expect(rect2.frameId).toBe(null);
});
it("should not preview reorder dragged elements already in the highlighted frame", () => {
const frameChild = API.createElement({
id: "frameChild",
type: "rectangle",
x: 10,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
const otherFrameChild = API.createElement({
id: "otherFrameChild",
type: "rectangle",
x: 40,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
API.setElements([frameChild, frame, otherFrameChild]);
API.setSelectedElements([frameChild]);
const renderableElementIds = h.app.renderer
.getRenderableElements({
zoom: h.state.zoom,
offsetLeft: 0,
offsetTop: 0,
scrollX: 0,
scrollY: 0,
height: 1000,
width: 1000,
editingTextElement: h.state.editingTextElement,
newElement: h.state.newElement,
selectedElements: getSelectedElements(h.elements, h.state),
selectedElementsAreBeingDragged: true,
frameToHighlight: frame as ExcalidrawFrameLikeElement,
})
.visibleElements.map((element) => element.id);
expect(renderableElementIds).toEqual([
frameChild.id,
frame.id,
otherFrameChild.id,
]);
});
it("should put a dragged mixed selection above the highest frame child", () => {
const frameChild = API.createElement({
id: "frameChild",
type: "rectangle",
x: 50,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
boundElements: [{ id: "boundText", type: "text" }],
});
const boundText = API.createElement({
id: "boundText",
type: "text",
x: 50,
y: 10,
width: 20,
height: 20,
containerId: frameChild.id,
frameId: frame.id,
});
const otherFrameChild = API.createElement({
id: "otherFrameChild",
type: "rectangle",
x: 80,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
const nonFrameElement = API.createElement({
id: "nonFrameElement",
type: "rectangle",
x: 155,
y: 10,
width: 20,
height: 20,
});
API.setElements([
frame,
frameChild,
boundText,
otherFrameChild,
nonFrameElement,
]);
API.setSelectedElements([frameChild, nonFrameElement]);
mouse.downAt(
nonFrameElement.x + nonFrameElement.width / 2,
nonFrameElement.y + nonFrameElement.height / 2,
);
mouse.moveTo(frame.x + frame.width - 5, nonFrameElement.y + 10);
mouse.up();
expect(frameChild.frameId).toBe(frame.id);
expect(boundText.frameId).toBe(frame.id);
expect(nonFrameElement.frameId).toBe(frame.id);
expect(h.elements.map((element) => element.id)).toEqual([
frame.id,
otherFrameChild.id,
frameChild.id,
boundText.id,
nonFrameElement.id,
]);
});
it("should not reorder dragged elements already in the highlighted frame", () => {
const frameChild = API.createElement({
id: "frameChild",
type: "rectangle",
x: 50,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
const otherFrameChild = API.createElement({
id: "otherFrameChild",
type: "rectangle",
x: 80,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
API.setElements([frame, frameChild, otherFrameChild]);
API.setSelectedElements([frameChild]);
mouse.downAt(
frameChild.x + frameChild.width / 2,
frameChild.y + frameChild.height / 2,
);
mouse.moveTo(frameChild.x + frameChild.width / 2 + 5, frameChild.y + 10);
mouse.up();
expect(frameChild.frameId).toBe(frame.id);
expect(h.elements.map((element) => element.id)).toEqual([
frame.id,
frameChild.id,
otherFrameChild.id,
]);
});
it("should not drag an element into a frame behind a non-frame element", () => {
const cover = API.createElement({
id: "cover",
type: "rectangle",
x: 10,
y: 10,
width: 80,
height: 80,
backgroundColor: "#ffc9c9",
});
API.setElements([frame, cover, rect2]);
mouse.clickAt(rect2.x, rect2.y);
mouse.downAt(rect2.x + rect2.width / 2, rect2.y + rect2.height / 2);
mouse.moveTo(20, 20);
mouse.upAt(20, 20);
expect(rect2.frameId).toBe(null);
});
it("should drag an element into a frame over a non-frame element", () => {
const cover = API.createElement({
id: "cover",
type: "rectangle",
x: 10,
y: 10,
width: 80,
height: 80,
backgroundColor: "#ffc9c9",
});
API.setElements([cover, rect2, frame]);
mouse.clickAt(rect2.x, rect2.y);
mouse.downAt(rect2.x + rect2.width / 2, rect2.y + rect2.height / 2);
mouse.moveTo(20, 20);
mouse.upAt(20, 20);
expect(rect2.frameId).toBe(frame.id);
});
it("should keep dragging a frame child over a non-frame element above its frame", () => {
const cover = API.createElement({
id: "cover",
type: "rectangle",
x: 10,
y: 10,
width: 80,
height: 80,
backgroundColor: "#ffc9c9",
});
const frameChild = API.createElement({
id: "frameChild",
type: "rectangle",
x: 100,
y: 20,
width: 20,
height: 20,
frameId: frame.id,
});
API.setElements([frameChild, frame, cover]);
API.setSelectedElements([frameChild]);
mouse.downAt(
frameChild.x + frameChild.width / 2,
frameChild.y + frameChild.height / 2,
);
mouse.moveTo(20, 20);
expect(h.state.frameToHighlight?.id).toBe(frame.id);
mouse.upAt(20, 20);
expect(frameChild.frameId).toBe(frame.id);
});
it.skip("should drag element inside, duplicate it and keep it in frame", () => {
API.setElements([frame, rect2]);
+43 -3
View File
@@ -326,19 +326,59 @@ describe("normalizeElementsOrder", () => {
]),
[
"BA_rect1",
"CBA_rect3",
"CBA_rect7",
"BA_rect5",
"BA_rect6",
"A_rect2",
"A_rect5",
"CBA_rect3",
"CBA_rect7",
"rect4",
"X_rect8",
"X_rect11",
"YX_rect10",
"X_rect11",
"rect9",
],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "A_rect1",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "CBA_rect2",
type: "rectangle",
groupIds: ["C", "B", "A"],
}),
API.createElement({
id: "A_rect3",
type: "rectangle",
groupIds: ["A"],
}),
]),
["A_rect1", "CBA_rect2", "A_rect3"],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "abcT_rect1",
type: "rectangle",
groupIds: ["ab", "c", "T"],
}),
API.createElement({
id: "abcT_rect2",
type: "rectangle",
groupIds: ["a", "bc", "T"],
}),
API.createElement({
id: "abcT_rect3",
type: "rectangle",
groupIds: ["ab", "c", "T"],
}),
]),
["abcT_rect1", "abcT_rect3", "abcT_rect2"],
);
});
// TODO
+70 -1
View File
@@ -1,4 +1,8 @@
import { wrapText, parseTokens } from "../src/textWrapping";
import {
getWrappedTextLines,
parseTokens,
wrapText,
} from "../src/textWrapping";
import type { FontString } from "../src/types";
@@ -102,6 +106,71 @@ describe("Test wrapText", () => {
expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`);
});
it("should retain original text offsets for wrapped lines", () => {
expect(getWrappedTextLines("Hello World!", font, 60)).toEqual([
{
text: "Hello",
start: 0,
end: 5,
},
{
text: "World!",
start: 6,
end: 12,
},
]);
});
it("should exclude whitespace trimmed away at soft-wrap boundaries from line offsets", () => {
expect(getWrappedTextLines(" Hello World", font, 90)).toEqual([
{
text: " Hello",
start: 0,
end: 7,
},
{
text: "World",
start: 9,
end: 14,
},
]);
});
it("should retain offsets when wrapping a single long token", () => {
expect(getWrappedTextLines("Excalidraw", font, 50)).toEqual([
{
text: "Excal",
start: 0,
end: 5,
},
{
text: "idraw",
start: 5,
end: 10,
},
]);
});
it("should preserve empty hard lines in metadata", () => {
expect(getWrappedTextLines("A\n\nB", font, 100)).toEqual([
{
text: "A",
start: 0,
end: 1,
},
{
text: "",
start: 2,
end: 2,
},
{
text: "B",
start: 3,
end: 4,
},
]);
});
describe("When text is CJK", () => {
it("should break each CJK character when width is very small", () => {
// "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
+81
View File
@@ -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
View File
@@ -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";
@@ -0,0 +1,147 @@
import {
getElementsInGroup,
isSomeElementSelected,
makeNextSelectedElementIds,
selectGroupsForSelectedElements,
} from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import { KEYS, isWritableElement, updateActiveTool } from "@excalidraw/common";
import type { GroupId } from "@excalidraw/element/types";
import { register } from "./register";
import type { AppClassProperties, AppState } from "../types";
const getNextActiveTool = (
appState: Readonly<AppState>,
app: AppClassProperties,
) => {
if (appState.activeTool.type === "eraser") {
return updateActiveTool(appState, {
...(appState.activeTool.lastActiveTool || {
type: app.state.preferredSelectionTool.type,
}),
lastActiveToolBeforeEraser: null,
});
}
return updateActiveTool(appState, {
type: app.state.preferredSelectionTool.type,
});
};
const getParentEditingGroupId = (
appState: Readonly<AppState>,
app: AppClassProperties,
selectedElementIds: AppState["selectedElementIds"],
): GroupId | null => {
if (!appState.editingGroupId) {
return null;
}
const nonDeletedElements = app.scene.getNonDeletedElements();
const selectedElements = app.scene.getSelectedElements({
selectedElementIds,
elements: nonDeletedElements,
});
const candidateElements = selectedElements.length
? selectedElements
: getElementsInGroup(nonDeletedElements, appState.editingGroupId);
for (const element of candidateElements) {
const editingGroupIndex = element.groupIds.indexOf(appState.editingGroupId);
if (editingGroupIndex !== -1 && element.groupIds[editingGroupIndex + 1]) {
return element.groupIds[editingGroupIndex + 1] as GroupId;
}
}
return null;
};
export const actionDeselect = register({
name: "deselect",
label: "",
trackEvent: false,
perform: (_elements, appState, _, app) => {
const activeTool = getNextActiveTool(appState, app);
if (appState.editingGroupId) {
const nonDeletedElements = app.scene.getNonDeletedElements();
const selectedElementIds =
Object.keys(appState.selectedElementIds).length > 0
? appState.selectedElementIds
: getElementsInGroup(
nonDeletedElements,
appState.editingGroupId,
).reduce((acc, element) => {
acc[element.id] = true;
return acc;
}, {} as Record<string, true>);
return {
appState: {
...appState,
...selectGroupsForSelectedElements(
{
editingGroupId: getParentEditingGroupId(
appState,
app,
selectedElementIds,
),
selectedElementIds,
},
nonDeletedElements,
appState,
app,
),
activeEmbeddable: null,
activeTool,
selectedLinearElement: null,
selectionElement: null,
showHyperlinkPopup: false,
suggestedBinding: null,
frameToHighlight: null,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
}
return {
appState: {
...appState,
activeEmbeddable: null,
activeTool,
editingGroupId: null,
selectedElementIds: makeNextSelectedElementIds({}, appState),
selectedGroupIds: {},
selectedLinearElement: null,
selectionElement: null,
showHyperlinkPopup: false,
suggestedBinding: null,
frameToHighlight: null,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
keyTest: (event, appState, _, app) => {
if (event.key !== KEYS.ESCAPE) {
return false;
}
if (isWritableElement(event.target)) {
return false;
}
return (
!appState.newElement &&
appState.multiElement === null &&
!appState.selectedLinearElement?.isEditing &&
(appState.activeEmbeddable !== null ||
appState.activeTool.type !== app.state.preferredSelectionTool.type ||
!!appState.editingGroupId ||
!!appState.selectedLinearElement ||
isSomeElementSelected(app.scene.getNonDeletedElements(), appState))
);
},
});
@@ -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";
+220 -38
View File
@@ -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,
@@ -329,8 +329,8 @@ export const actionFinalize = register<FormData>({
selectionElement: null,
multiElement: null,
editingTextElement: null,
startBoundElement: null,
suggestedBinding: null,
frameToHighlight: null,
selectedElementIds:
element &&
!appState.activeTool.locked &&
@@ -348,9 +348,7 @@ export const actionFinalize = register<FormData>({
};
},
keyTest: (event, appState) =>
(event.key === KEYS.ESCAPE &&
(appState.selectedLinearElement?.isEditing ||
(!appState.newElement && appState.multiElement === null))) ||
(event.key === KEYS.ESCAPE && appState.selectedLinearElement?.isEditing) ||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
appState.multiElement !== null),
PanelComponent: ({ appState, updateData, data }) => (
@@ -205,7 +205,6 @@ export const actionWrapSelectionInFrame = register({
[...app.scene.getElementsIncludingDeleted(), frame],
selectedElements,
frame,
appState,
);
return {
@@ -277,7 +277,6 @@ export const actionUngroup = register({
elementsMap,
),
frame,
app,
);
}
});
@@ -1,5 +1,4 @@
import {
isWindows,
KEYS,
matchKey,
arrayToMap,
@@ -18,7 +17,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";
@@ -114,7 +113,7 @@ export const createRedoAction: ActionCreator = (history) => ({
),
keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
(isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)),
(event[KEYS.CTRL_OR_CMD] && !event.shiftKey && matchKey(event, KEYS.Y)),
PanelComponent: ({ appState, updateData, data, app }) => {
const { isRedoStackEmpty } = useEmitter(
history.onHistoryChangedEmitter,
+163 -92
View File
@@ -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";
@@ -187,7 +191,7 @@ export const getFormValue = function <T extends Primitive>(
elements: readonly ExcalidrawElement[],
app: AppClassProperties,
getAttribute: (element: ExcalidrawElement) => T,
isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
elementPredicate: true | ((element: ExcalidrawElement) => boolean),
defaultValue: T | ((isSomeElementSelected: boolean) => T),
): T {
const editingTextElement = app.state.editingTextElement;
@@ -205,9 +209,9 @@ export const getFormValue = function <T extends Primitive>(
if (hasSelection) {
const selectedElements = app.scene.getSelectedElements(app.state);
const targetElements =
isRelevantElement === true
elementPredicate === true
? selectedElements
: selectedElements.filter((el) => isRelevantElement(el));
: selectedElements.filter((el) => elementPredicate(el));
ret =
reduceToCommonValue(targetElements, getAttribute) ??
@@ -726,9 +730,28 @@ export const actionChangeOpacity = register<ExcalidrawElement["opacity"]>({
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ app, updateData }) => (
<Range updateData={updateData} app={app} testId="opacity" />
),
PanelComponent: ({ elements, appState, app, updateData }) => {
const opacity = getFormValue(
elements,
app,
(element) => element.opacity,
true,
(hasSelection) => (hasSelection ? null : appState.currentItemOpacity),
);
return (
<Range
label={t("labels.opacity")}
value={opacity ?? appState.currentItemOpacity}
hasCommonValue={opacity !== null}
onChange={updateData}
min={0}
max={100}
step={10}
testId="opacity"
/>
);
},
});
export const actionChangeFontSize = register<ExcalidrawTextElement["fontSize"]>(
@@ -1550,80 +1573,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 +1727,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 +1897,7 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
startElement,
"start",
elementsMap,
appState.isBindingEnabled,
),
}
: null;
@@ -1841,6 +1911,7 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
endElement,
"end",
elementsMap,
appState.isBindingEnabled,
),
}
: null;
@@ -1,24 +1,24 @@
import { getFontString } from "@excalidraw/common";
import { newElementWith } from "@excalidraw/element";
import { isExcalidrawElement, newElementWith } from "@excalidraw/element";
import { measureText } from "@excalidraw/element";
import { isTextElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { getSelectedElements } from "../scene";
import { register } from "./register";
import type { AppClassProperties } from "../types";
export const actionTextAutoResize = register({
name: "autoResize",
label: "labels.autoResize",
icon: null,
trackEvent: { category: "element" },
predicate: (elements, appState, _: unknown, app: AppClassProperties) => {
predicate: (elements, appState, _: unknown) => {
const selectedElements = getSelectedElements(elements, appState);
return (
selectedElements.length === 1 &&
@@ -26,13 +26,18 @@ export const actionTextAutoResize = register({
!selectedElements[0].autoResize
);
},
perform: (elements, appState, _, app) => {
perform: (elements, appState, targetElement) => {
const selectedElements = getSelectedElements(elements, appState);
const targetTextElement =
isExcalidrawElement(targetElement) && isTextElement(targetElement)
? targetElement
: (selectedElements[0] as ExcalidrawElement | undefined);
return {
appState,
elements: elements.map((element) => {
if (element.id === selectedElements[0].id && isTextElement(element)) {
if (element.id === targetTextElement?.id && isTextElement(element)) {
const metrics = measureText(
element.originalText,
getFontString(element),
@@ -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,
});
+3
View File
@@ -34,6 +34,7 @@ export {
export { actionSetEmbeddableAsActiveTool } from "./actionEmbeddable";
export { actionFinalize } from "./actionFinalize";
export { actionDeselect } from "./actionDeselect";
export {
actionChangeProjectName,
@@ -79,6 +80,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";
+3
View File
@@ -59,6 +59,8 @@ export type ActionName =
| "gridMode"
| "zenMode"
| "objectsSnapMode"
| "arrowBinding"
| "midpointSnapping"
| "stats"
| "changeStrokeColor"
| "changeBackgroundColor"
@@ -112,6 +114,7 @@ export type ActionName =
| "distributeVertically"
| "flipHorizontal"
| "flipVertical"
| "deselect"
| "viewMode"
| "exportWithDarkMode"
| "toggleTheme"
@@ -1,5 +1,4 @@
import { LaserPointer } from "@excalidraw/laser-pointer";
import {
SVG_NS,
getSvgPathFromStroke,
@@ -8,7 +7,8 @@ import {
import type { LaserPointerOptions } from "@excalidraw/laser-pointer";
import type { AnimationFrameHandler } from "./animation-frame-handler";
import { AnimationController } from "./renderer/animation";
import type App from "./components/App";
import type { AppState } from "./types";
@@ -34,15 +34,16 @@ export class AnimatedTrail implements Trail {
private container?: SVGSVGElement;
private trailElement: SVGPathElement;
private trailAnimation?: SVGAnimateElement;
private key: string;
private static counter = 0;
constructor(
private animationFrameHandler: AnimationFrameHandler,
protected app: App,
private options: Partial<LaserPointerOptions> &
Partial<AnimatedTrailOptions>,
) {
this.animationFrameHandler.register(this, this.onFrame.bind(this));
this.key = `animated-trail-${AnimatedTrail.counter++}`;
this.trailElement = document.createElementNS(SVG_NS, "path");
if (this.options.animateTrail) {
this.trailAnimation = document.createElementNS(SVG_NS, "animate");
@@ -73,6 +74,15 @@ export class AnimatedTrail implements Trail {
return false;
}
private cleanup() {
this.pastTrails = [];
this.currentTrail = undefined;
if (this.trailElement.parentNode === this.container) {
this.container?.removeChild(this.trailElement);
}
}
start(container?: SVGSVGElement) {
if (container) {
this.container = container;
@@ -82,15 +92,23 @@ export class AnimatedTrail implements Trail {
this.container.appendChild(this.trailElement);
}
this.animationFrameHandler.start(this);
if (!AnimationController.running(this.key)) {
AnimationController.start(this.key, () => {
const needsNext = this.onFrame();
if (needsNext) {
return { keep: true };
}
this.cleanup();
return null;
});
}
}
stop() {
this.animationFrameHandler.stop(this);
if (this.trailElement.parentNode === this.container) {
this.container?.removeChild(this.trailElement);
}
AnimationController.cancel(this.key);
this.cleanup();
}
startPath(x: number, y: number) {
@@ -145,21 +163,25 @@ export class AnimatedTrail implements Trail {
if (this.currentTrail) {
const currentPath = this.drawTrail(this.currentTrail, this.app.state);
paths.push(currentPath);
}
this.pastTrails = this.pastTrails.filter((trail) => {
return trail.getStrokeOutline().length !== 0;
});
this.pastTrails = this.pastTrails.filter(
(t) =>
t.getStrokeOutline(t.options.size / this.app.state.zoom.value)
.length !== 0,
);
if (paths.length === 0) {
this.stop();
// Clean up the SVG path if there are no trails to render
this.trailElement.setAttribute("d", "");
return false;
}
const svgPaths = paths.join(" ").trim();
this.trailElement.setAttribute("d", svgPaths);
if (this.trailAnimation) {
this.trailElement.setAttribute(
"fill",
@@ -175,6 +197,8 @@ export class AnimatedTrail implements Trail {
(this.options.fill ?? (() => "black"))(this),
);
}
return true;
}
private drawTrail(trail: LaserPointer, state: AppState): string {
@@ -1,79 +0,0 @@
export type AnimationCallback = (timestamp: number) => void | boolean;
export type AnimationTarget = {
callback: AnimationCallback;
stopped: boolean;
};
export class AnimationFrameHandler {
private targets = new WeakMap<object, AnimationTarget>();
private rafIds = new WeakMap<object, number>();
register(key: object, callback: AnimationCallback) {
this.targets.set(key, { callback, stopped: true });
}
start(key: object) {
const target = this.targets.get(key);
if (!target) {
return;
}
if (this.rafIds.has(key)) {
return;
}
this.targets.set(key, { ...target, stopped: false });
this.scheduleFrame(key);
}
stop(key: object) {
const target = this.targets.get(key);
if (target && !target.stopped) {
this.targets.set(key, { ...target, stopped: true });
}
this.cancelFrame(key);
}
private constructFrame(key: object): FrameRequestCallback {
return (timestamp: number) => {
const target = this.targets.get(key);
if (!target) {
return;
}
const shouldAbort = this.onFrame(target, timestamp);
if (!target.stopped && !shouldAbort) {
this.scheduleFrame(key);
} else {
this.cancelFrame(key);
}
};
}
private scheduleFrame(key: object) {
const rafId = requestAnimationFrame(this.constructFrame(key));
this.rafIds.set(key, rafId);
}
private cancelFrame(key: object) {
if (this.rafIds.has(key)) {
const rafId = this.rafIds.get(key)!;
cancelAnimationFrame(rafId);
}
this.rafIds.delete(key);
}
private onFrame(target: AnimationTarget, timestamp: number): boolean {
const shouldAbort = target.callback(timestamp);
return shouldAbort ?? false;
}
}
+7 -7
View File
@@ -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,
@@ -99,7 +99,6 @@ export const getDefaultAppState = (): Omit<
open: false,
panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties,
},
startBoundElement: null,
suggestedBinding: null,
frameRendering: { enabled: true, clip: true, name: true, outline: true },
frameToHighlight: null,
@@ -128,6 +127,7 @@ export const getDefaultAppState = (): Omit<
lockedMultiSelections: {},
activeLockedId: null,
bindMode: "orbit",
boxSelectionMode: "contain",
};
};
@@ -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,10 @@ 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 },
boxSelectionMode: { 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 +214,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 },
@@ -229,7 +230,6 @@ const APP_STATE_STORAGE_CONF = (<
selectionElement: { browser: false, export: false, server: false },
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
stats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false },
suggestedBinding: { browser: false, export: false, server: false },
frameRendering: { browser: false, export: false, server: false },
frameToHighlight: { browser: false, export: false, server: false },
File diff suppressed because it is too large Load Diff
-481
View File
@@ -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);
};
+103
View File
@@ -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),
];
};
+130
View File
@@ -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];
};
+174
View File
@@ -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);
};
+199
View File
@@ -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 };
+38
View File
@@ -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);
};
-63
View File
@@ -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="{&quot;1&quot;:2,&quot;2&quot;:&quot;a&quot;}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;b&quot;}">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="{&quot;1&quot;:3,&quot;3&quot;:1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;: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="{&quot;1&quot;:3,&quot;3&quot;:4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;: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="{&quot;1&quot;:3,&quot;3&quot;:7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;: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],
});
});
});
+2 -32
View File
@@ -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;
@@ -215,16 +208,6 @@ export const copyToClipboard = async (
);
};
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 = [];
@@ -384,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 };
@@ -393,7 +376,7 @@ type ParsedDataTransferItem =
type: string;
kind: "file";
file: File;
fileHandle: FileSystemHandle | null;
fileHandle: FileSystemFileHandle | null;
}
| { type: string; kind: "string"; value: string };
@@ -551,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 =

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