diff --git a/.codesandbox/Dockerfile b/.codesandbox/Dockerfile index fd5b38d1e8..ce8c857650 100644 --- a/.codesandbox/Dockerfile +++ b/.codesandbox/Dockerfile @@ -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. diff --git a/.eslintrc.json b/.eslintrc.json index 89f8227361..708210535c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -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"] + } + ] + } + } + ] } diff --git a/.github/workflows/autorelease-excalidraw.yml b/.github/workflows/autorelease-excalidraw.yml index c365647ee8..289189fd83 100644 --- a/.github/workflows/autorelease-excalidraw.yml +++ b/.github/workflows/autorelease-excalidraw.yml @@ -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 diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index f5f9b45bbe..3e2dc3d3c5 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -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 . diff --git a/.github/workflows/cancel.yml b/.github/workflows/cancel.yml index e1ef216651..a572df9bed 100644 --- a/.github/workflows/cancel.yml +++ b/.github/workflows/cancel.yml @@ -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 }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index cc73980d10..22ded0d079 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -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 diff --git a/.github/workflows/locales-coverage.yml b/.github/workflows/locales-coverage.yml index d69f1b1449..9a5a93adac 100644 --- a/.github/workflows/locales-coverage.yml +++ b/.github/workflows/locales-coverage.yml @@ -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" diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index 68eee27755..3019e9b097 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -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 diff --git a/.github/workflows/semantic-pr-title.yml b/.github/workflows/semantic-pr-title.yml index 34a6413fe2..0dc2c879f4 100644 --- a/.github/workflows/semantic-pr-title.yml +++ b/.github/workflows/semantic-pr-title.yml @@ -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 diff --git a/.github/workflows/sentry-production.yml b/.github/workflows/sentry-production.yml index 4434873fd3..c8270a0163 100644 --- a/.github/workflows/sentry-production.yml +++ b/.github/workflows/sentry-production.yml @@ -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 diff --git a/.github/workflows/size-limit.yml b/.github/workflows/size-limit.yml index ded07f91fb..4c80695f1f 100644 --- a/.github/workflows/size-limit.yml +++ b/.github/workflows/size-limit.yml @@ -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 diff --git a/.github/workflows/test-coverage-pr.yml b/.github/workflows/test-coverage-pr.yml index 227991a7bc..ffd75a7c84 100644 --- a/.github/workflows/test-coverage-pr.yml +++ b/.github/workflows/test-coverage-pr.yml @@ -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 }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8bebd6c1ee..78f5e9a7d2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index c08385d654..a941c99980 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 74fc2c084f..48b9d1de9f 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ PRs welcome! - Chat on Discord + Chat on Discord Ask DeepWiki diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/excalidraw-element-skeleton.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/excalidraw-element-skeleton.mdx index b633236aad..e1dd071eac 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/api/excalidraw-element-skeleton.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/api/excalidraw-element-skeleton.mdx @@ -172,7 +172,7 @@ convertToExcalidrawElements([ type: "arrow", x: 450, y: 20, - startArrowhead: "dot", + startArrowhead: "circle", endArrowhead: "triangle", strokeColor: "#1971c2", strokeWidth: 2, diff --git a/dev-docs/docusaurus.config.js b/dev-docs/docusaurus.config.js index 4e8d75800a..d2b34843ee 100644 --- a/dev-docs/docusaurus.config.js +++ b/dev-docs/docusaurus.config.js @@ -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", diff --git a/examples/with-script-in-browser/initialData.tsx b/examples/with-script-in-browser/initialData.tsx index 0db23d5f20..2b1a9d4820 100644 --- a/examples/with-script-in-browser/initialData.tsx +++ b/examples/with-script-in-browser/initialData.tsx @@ -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[] = [ diff --git a/examples/with-script-in-browser/package.json b/examples/with-script-in-browser/package.json index 653c2be40e..512786a659 100644 --- a/examples/with-script-in-browser/package.json +++ b/examples/with-script-in-browser/package.json @@ -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", diff --git a/examples/with-script-in-browser/utils.ts b/examples/with-script-in-browser/utils.ts index 285e9652d3..35f2c332c3 100644 --- a/examples/with-script-in-browser/utils.ts +++ b/examples/with-script-in-browser/utils.ts @@ -4,8 +4,6 @@ import { unstable_batchedUpdates } from "react-dom"; type FILE_EXTENSION = Exclude; -const INPUT_CHANGE_INTERVAL_MS = 500; - export type ResolvablePromise = Promise & { resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void; reject: (error: Error) => void; @@ -54,40 +52,6 @@ export const fileOpen = (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; }; diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 2e71cc6788..011379a4c1 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -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(); - 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, - isInitialLoad = false, - ) => { - if (!data.scene) { + // --------------------------------------------------------------------------- + // Hoisted loadImages + // --------------------------------------------------------------------------- + const loadImages = useCallback( + (data: ResolutionType, 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["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 = () => { })} > { return ( - + + + ); diff --git a/excalidraw-app/collab/Collab.tsx b/excalidraw-app/collab/Collab.tsx index 6eef30d7f1..622f92fb5e 100644 --- a/excalidraw-app/collab/Collab.tsx +++ b/excalidraw-app/collab/Collab.tsx @@ -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 { }; 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) { diff --git a/excalidraw-app/components/DebugCanvas.tsx b/excalidraw-app/components/DebugCanvas.tsx index 2c157e34e7..d9279296f7 100644 --- a/excalidraw-app/components/DebugCanvas.tsx +++ b/excalidraw-app/components/DebugCanvas.tsx @@ -414,7 +414,6 @@ export const debugRenderer = throttleRAF( ) => { _debugRenderer(canvas, appState, elements, scale); }, - { trailing: true }, ); export const loadSavedDebugState = () => { diff --git a/excalidraw-app/data/FileManager.ts b/excalidraw-app/data/FileManager.ts index 435d813252..849e47bc92 100644 --- a/excalidraw-app/data/FileManager.ts +++ b/excalidraw-app/data/FileManager.ts @@ -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; erroredFiles: Map; }>; + 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(); diff --git a/excalidraw-app/data/LocalData.ts b/excalidraw-app/data/LocalData.ts index 13cdf09ac4..d09d8aa88e 100644 --- a/excalidraw-app/data/LocalData.ts +++ b/excalidraw-app/data/LocalData.ts @@ -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)[]) => { diff --git a/excalidraw-app/data/fileStatusStore.ts b/excalidraw-app/data/fileStatusStore.ts new file mode 100644 index 0000000000..ff9363ed5d --- /dev/null +++ b/excalidraw-app/data/fileStatusStore.ts @@ -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 + >(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) { + let pending = 0; + let total = 0; + for (const status of statuses.values()) { + total++; + if (status === "loading") { + pending++; + } + } + return { pending, total }; + } +} diff --git a/excalidraw-app/vite.config.mts b/excalidraw-app/vite.config.mts index a24d0939a6..dfb213ef3a 100644 --- a/excalidraw-app/vite.config.mts +++ b/excalidraw-app/vite.config.mts @@ -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", diff --git a/package.json b/package.json index 65ff221a49..df5c4e8a79 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/excalidraw-app/debug.ts b/packages/common/debug.ts similarity index 77% rename from excalidraw-app/debug.ts rename to packages/common/debug.ts index 1ef136fb02..efb6fefb46 100644 --- a/excalidraw-app/debug.ts +++ b/packages/common/debug.ts @@ -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> = {}; + + public static logChanged(name: string, obj: Record) { + 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 = {}; + + 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); + const keysB = Object.keys(b as Record); + + if (keysA.length !== keysB.length) { + return false; + } + + for (const key of keysA) { + if ( + !deepEqual( + (a as Record)[key], + (b as Record)[key], + ) + ) { + return false; + } + } + + return true; } -//@ts-ignore -window.debug = Debug; diff --git a/packages/common/src/appEventBus.test.ts b/packages/common/src/appEventBus.test.ts new file mode 100644 index 0000000000..9a768c2ff7 --- /dev/null +++ b/packages/common/src/appEventBus.test.ts @@ -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(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(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(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(behavior); + bus.emit("initialize", 1); + + expect(() => { + bus.emit("initialize", 2); + }).toThrow('Event "initialize" can only be emitted once'); + }); +}); diff --git a/packages/common/src/appEventBus.ts b/packages/common/src/appEventBus.ts new file mode 100644 index 0000000000..e561419fed --- /dev/null +++ b/packages/common/src/appEventBus.ts @@ -0,0 +1,136 @@ +import type { UnsubscribeCallback } from "@excalidraw/excalidraw/types"; + +import { Emitter } from "./emitter"; +import { isProdEnv } from "./utils"; + +export type AppEventPayloadMap = Record; + +export type AppEventBehavior = { + cardinality: "once" | "many"; + replay: "none" | "last"; +}; + +export type AppEventBehaviorMap = { + [K in keyof Events]: AppEventBehavior; +}; + +type AwaitableAppEventKeys< + Events extends AppEventPayloadMap, + Behavior extends AppEventBehaviorMap, +> = { + [K in keyof Events]: Behavior[K]["cardinality"] extends "once" + ? Behavior[K]["replay"] extends "last" + ? K + : never + : never; +}[keyof Events]; + +type AppEventPromiseValue = Args extends [infer Only] + ? Only + : Args; + +export class AppEventBus< + Events extends AppEventPayloadMap, + Behavior extends AppEventBehaviorMap, +> { + private readonly emitters = new Map>(); + private readonly lastPayload = new Map(); + private readonly emittedOnce = new Set(); + + constructor(private readonly behavior: Behavior) {} + + private getEmitter(name: K): Emitter { + let emitter = this.emitters.get(name); + if (!emitter) { + emitter = new Emitter(); + this.emitters.set(name, emitter); + } + return emitter as Emitter; + } + + private toPromiseValue( + args: Args, + ): AppEventPromiseValue { + return (args.length === 1 ? args[0] : args) as AppEventPromiseValue; + } + + public on( + name: K, + callback: (...args: Events[K]) => void, + ): UnsubscribeCallback; + public on>( + name: K, + ): Promise>; + public on( + name: K, + callback?: (...args: Events[K]) => void, + ): UnsubscribeCallback | Promise> { + 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>((resolve) => { + this.getEmitter(name).once((...args: Events[K]) => { + resolve(this.toPromiseValue(args)); + }); + }); + } + + public emit(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(); + } +} diff --git a/packages/common/src/colors.ts b/packages/common/src/colors.ts index 763510646b..567093c7d9 100644 --- a/packages/common/src/colors.ts +++ b/packages/common/src/colors.ts @@ -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 diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index 4ff50335ef..0e94df5af6 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -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 = ` diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 7d6bf5b0dc..022f7714bb 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -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"; diff --git a/packages/common/src/utils.test.ts b/packages/common/src/utils.test.ts index 25929722f7..677aaf2245 100644 --- a/packages/common/src/utils.test.ts +++ b/packages/common/src/utils.test.ts @@ -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; + 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(); + }); + }); }); diff --git a/packages/common/src/utils.ts b/packages/common/src/utils.ts index 3727e562d3..e979323649 100644 --- a/packages/common/src/utils.ts +++ b/packages/common/src/utils.ts @@ -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 = ( return ret; }; -// throttle callback to execute once per animation frame -export const throttleRAF = ( - fn: (...args: T) => void, - opts?: { trailing?: boolean }, -) => { +// throttle callback to execute once per animation frame using the latest args +export const throttleRAF = (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 = ( 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 = ( console.error("unable to set feature flag", e); } }; + +export const oneOf = ( + needle: N, + haystack: readonly H[], +): needle is H => { + return haystack.includes(needle as any); +}; diff --git a/packages/common/src/versionedSnapshotStore.ts b/packages/common/src/versionedSnapshotStore.ts new file mode 100644 index 0000000000..979f017987 --- /dev/null +++ b/packages/common/src/versionedSnapshotStore.ts @@ -0,0 +1,70 @@ +export type VersionedSnapshot = Readonly<{ + version: number; + value: T; +}>; + +export class VersionedSnapshotStore { + private version = 0; + private value: T; + private readonly waiters = new Set< + (snapshot: VersionedSnapshot) => void + >(); + private readonly subscribers = new Set< + (snapshot: VersionedSnapshot) => void + >(); + + constructor( + initialValue: T, + private readonly isEqual: (prev: T, next: T) => boolean = Object.is, + ) { + this.value = initialValue; + } + + public getSnapshot(): VersionedSnapshot { + 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) => void, + ): () => void { + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + } + + public pull(sinceVersion = -1): Promise> { + if (this.version !== sinceVersion) { + return Promise.resolve(this.getSnapshot()); + } + + return new Promise((resolve) => { + this.waiters.add(resolve); + }); + } +} diff --git a/packages/element/package.json b/packages/element/package.json index 7dff00a750..408f2c9d10 100644 --- a/packages/element/package.json +++ b/packages/element/package.json @@ -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" } } diff --git a/packages/element/src/Scene.ts b/packages/element/src/Scene.ts index eaef257960..35b0cf4b95 100644 --- a/packages/element/src/Scene.ts +++ b/packages/element/src/Scene.ts @@ -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, diff --git a/packages/element/src/align.ts b/packages/element/src/align.ts index 17ebc3a97c..3068aee8d1 100644 --- a/packages/element/src/align.ts +++ b/packages/element/src/align.ts @@ -43,7 +43,6 @@ export const alignElements = ( // update bound elements updateBoundElements(element, scene, { simultaneouslyUpdated: group, - indirectArrowUpdate: true, }); return updatedEle; }); diff --git a/packages/element/src/arrowheads.ts b/packages/element/src/arrowheads.ts new file mode 100644 index 0000000000..7e76b2b3bf --- /dev/null +++ b/packages/element/src/arrowheads.ts @@ -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; +}; diff --git a/packages/element/src/arrows/focus.ts b/packages/element/src/arrows/focus.ts index c3f2411144..d4a76da35f 100644 --- a/packages/element/src/arrows/focus.ts +++ b/packages/element/src/arrows/focus.ts @@ -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 ) { diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index feb01768f8..6aff334a2f 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -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, -) => { - 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; - 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, + 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( 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, @@ -1700,9 +1746,7 @@ export const updateBoundPoint = ( binding: FixedPointBinding | null | undefined, bindableElement: ExcalidrawBindableElement, elementsMap: ElementsMap, - opts?: { - customIntersector?: LineSegment; - }, + 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((x1 + x2) / 2, (y1 + y2) / 2); - const edgePoint = global; - const adjacentPoint = pointRotateRads( - pointFrom( - 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 = ( +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 = ( 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 = diff --git a/packages/element/src/bounds.ts b/packages/element/src/bounds.ts index 0daa80f15d..a072b81a90 100644 --- a/packages/element/src/bounds.ts +++ b/packages/element/src/bounds.ts @@ -680,8 +680,9 @@ export const getMinMaxXYFromCurvePathOps = ( return [minX, minY, maxX, maxY]; }; -export const getBoundsFromPoints = ( - points: ExcalidrawFreeDrawElement["points"], +export const getBoundsFromPoints =

( + 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 =

( ): 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: 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(innerBounds[0], innerBounds[1]), + pointFrom(innerBounds[0], innerBounds[3]), + pointFrom(innerBounds[2], innerBounds[1]), + pointFrom(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((x1 + x2) / 2, (y1 + y2) / 2); diff --git a/packages/element/src/collision.ts b/packages/element/src/collision.ts index 9605b3197d..1f2372ec31 100644 --- a/packages/element/src/collision.ts +++ b/packages/element/src/collision.ts @@ -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, + 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); diff --git a/packages/element/src/distance.ts b/packages/element/src/distance.ts index 4766ac9eef..c94652a1aa 100644 --- a/packages/element/src/distance.ts +++ b/packages/element/src/distance.ts @@ -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)), diff --git a/packages/element/src/duplicate.ts b/packages/element/src/duplicate.ts index c2cee4c089..24135c0879 100644 --- a/packages/element/src/duplicate.ts +++ b/packages/element/src/duplicate.ts @@ -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) => { diff --git a/packages/element/src/elbowArrow.ts b/packages/element/src/elbowArrow.ts index 63b3b7926d..024b846b12 100644 --- a/packages/element/src/elbowArrow.ts +++ b/packages/element/src/elbowArrow.ts @@ -915,6 +915,8 @@ export const updateElbowArrowPoints = ( }, options?: { isDragging?: boolean; + isBindingEnabled?: boolean; + isMidpointSnappingEnabled?: boolean; }, ): ElementUpdate => { 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, ); } diff --git a/packages/element/src/embeddable.ts b/packages/element/src/embeddable.ts index 71c75cc23a..917ca0a7af 100644 --- a/packages/element/src/embeddable.ts +++ b/packages/element/src/embeddable.ts @@ -56,7 +56,7 @@ const RE_REDDIT = const RE_REDDIT_EMBED = /^ { +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= (common "open in Drive" format) + // - /uc?...&id= (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"; diff --git a/packages/element/src/fractionalIndex.ts b/packages/element/src/fractionalIndex.ts index 44ca523c80..90a2e7c217 100644 --- a/packages/element/src/fractionalIndex.ts +++ b/packages/element/src/fractionalIndex.ts @@ -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; } diff --git a/packages/element/src/frame.ts b/packages/element/src/frame.ts index 3c82099546..3d1449a072 100644 --- a/packages/element/src/frame.ts +++ b/packages/element/src/frame.ts @@ -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 = ( allElements: T, elementsToAdd: NonDeletedExcalidrawElement[], frame: ExcalidrawFrameLikeElement, - appState: AppState, ): T => { const elementsMap = arrayToMap(allElements); - const currTargetFrameChildrenMap = new Map(); - 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(); const otherFrames = new Set(); @@ -522,7 +552,8 @@ export const addElementsToFrame = ( } // - 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 = ( 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 = ( 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), + ), ); }; diff --git a/packages/element/src/index.ts b/packages/element/src/index.ts index 1ca1c1a289..c55537d451 100644 --- a/packages/element/src/index.ts +++ b/packages/element/src/index.ts @@ -99,3 +99,4 @@ export * from "./typeChecks"; export * from "./utils"; export * from "./zindex"; export * from "./arrows/helpers"; +export * from "./arrowheads"; diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index f5743b553f..6e54cd59ef 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -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, index: number, + elementsMap: ElementsMap, ): GlobalPoint { if (isElbowArrow(element)) { invariant( @@ -917,7 +949,10 @@ export class LinearElementEditor { return pointFrom(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( - element.points[pointIndex][0] + deltaX, - element.points[pointIndex][1] + deltaY, - ), + point: pointFrom(point[0] + deltaX, point[1] + deltaY), isDragging: true, }, ]; @@ -2146,14 +2192,16 @@ const pointDraggingUpdates = ( suggestedBinding: suggestedBindingElement ? { element: suggestedBindingElement, - midPoint: snapToMid( - suggestedBindingElement, - elementsMap, - pointFrom( - scenePointerX - linearElementEditor.pointerOffset.x, - scenePointerY - linearElementEditor.pointerOffset.y, - ), - ), + midPoint: app.state.isMidpointSnappingEnabled + ? snapToMid( + suggestedBindingElement, + elementsMap, + pointFrom( + 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]; diff --git a/packages/element/src/mutateElement.ts b/packages/element/src/mutateElement.ts index c45c6df08c..eb6350c2bf 100644 --- a/packages/element/src/mutateElement.ts +++ b/packages/element/src/mutateElement.ts @@ -40,6 +40,8 @@ export const mutateElement = >( updates: ElementUpdate, options?: { isDragging?: boolean; + isBindingEnabled?: boolean; + isMidpointSnappingEnabled?: boolean; }, ) => { let didChange = false; diff --git a/packages/element/src/selection.ts b/packages/element/src/selection.ts index bb94166dbd..a65d1d3ce3 100644 --- a/packages/element/src/selection.ts +++ b/packages/element/src/selection.ts @@ -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 = ( + selectedElements: readonly T[], + framesInSelection: Set, +) => { + 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( + pointFrom(selectionX1, selectionY1), + pointFrom(selectionX2, selectionY1), + ), + lineSegment( + pointFrom(selectionX2, selectionY1), + pointFrom(selectionX2, selectionY2), + ), + lineSegment( + pointFrom(selectionX2, selectionY2), + pointFrom(selectionX1, selectionY2), + ), + lineSegment( + pointFrom(selectionX1, selectionY2), + pointFrom(selectionX1, selectionY1), + ), + ]; - let elementsInSelection = elements.filter((element) => { - let [elementX1, elementY1, elementX2, elementY2] = getElementBounds( - element, - elementsMap, - ); + const framesInSelection = excludeElementsInFrames + ? new Set() + : null; + const groups: Record = {}; + const elementsInSelection: Set = 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(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( + (nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2, + nonRotatedElementBounds[1], + ), + center, + element.angle, + ), + pointRotateRads( + pointFrom( + nonRotatedElementBounds[2], + (nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2, + ), + center, + element.angle, + ), + pointRotateRads( + pointFrom( + (nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2, + nonRotatedElementBounds[3], + ), + center, + element.angle, + ), + pointRotateRads( + pointFrom( + 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[], + appState: Pick, +) => { + const activeTextElement = + appState.editingTextElement || + (selectedElements.length === 1 && + isTextElement(selectedElements[0]) && + selectedElements[0]); + + return activeTextElement || null; +}; diff --git a/packages/element/src/shape.ts b/packages/element/src/shape.ts index 176455bdf9..158ace7519 100644 --- a/packages/element/src/shape.ts +++ b/packages/element/src/shape.ts @@ -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": diff --git a/packages/element/src/sortElements.ts b/packages/element/src/sortElements.ts index c98ff9d523..0f9e8da0f1 100644 --- a/packages/element/src/sortElements.ts +++ b/packages/element/src/sortElements.ts @@ -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(); - - 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(); + const orderLevel = ( + levelElements: readonly ExcalidrawElement[], + level: number, + ): ExcalidrawElement[] => { + const buckets = new Map(); + // 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(); - 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)); }; diff --git a/packages/element/src/textElement.ts b/packages/element/src/textElement.ts index 523a8b8804..9ff53d035c 100644 --- a/packages/element/src/textElement.ts +++ b/packages/element/src/textElement.ts @@ -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, diff --git a/packages/element/src/textWrapping.ts b/packages/element/src/textWrapping.ts index 5ec9bb42a9..b580f52ed1 100644 --- a/packages/element/src/textWrapping.ts +++ b/packages/element/src/textWrapping.ts @@ -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 = []; - 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 = []; + 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 => { + 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 = []; + 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), + }; }; /** diff --git a/packages/element/src/typeChecks.ts b/packages/element/src/typeChecks.ts index b609cc3f8a..3a8f5e36ef 100644 --- a/packages/element/src/typeChecks.ts +++ b/packages/element/src/typeChecks.ts @@ -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; + } + } +}; diff --git a/packages/element/src/types.ts b/packages/element/src/types.ts index 5d39fd7097..138a60e1c7 100644 --- a/packages/element/src/types.ts +++ b/packages/element/src/types.ts @@ -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<{ diff --git a/packages/element/src/utils.ts b/packages/element/src/utils.ts index ee341b310a..819cad562f 100644 --- a/packages/element/src/utils.ts +++ b/packages/element/src/utils.ts @@ -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 = ( */ export function deconstructLinearOrFreeDrawElement( element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, + elementsMap: ElementsMap, ): [LineSegment[], Curve[]] { 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( vectorScale( vectorFromPoint(point, a), diff --git a/packages/element/tests/collision.test.tsx b/packages/element/tests/collision.test.tsx index 4061a16cb6..a44f1f7bb0 100644 --- a/packages/element/tests/collision.test.tsx +++ b/packages/element/tests/collision.test.tsx @@ -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(); }); @@ -56,6 +57,7 @@ describe("hitElementItself cache", () => { }); localStorage.clear(); + reseed(7); await render(); }); diff --git a/packages/element/tests/embeddable.test.ts b/packages/element/tests/embeddable.test.ts index 7f585e866f..35870a86f2 100644 --- a/packages/element/tests/embeddable.test.ts +++ b/packages/element/tests/embeddable.test.ts @@ -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); + }); +}); diff --git a/packages/element/tests/fractionalIndex.test.ts b/packages/element/tests/fractionalIndex.test.ts index 1cc3ca5af3..2834a831e1 100644 --- a/packages/element/tests/fractionalIndex.test.ts +++ b/packages/element/tests/fractionalIndex.test.ts @@ -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: [ diff --git a/packages/element/tests/frame.test.tsx b/packages/element/tests/frame.test.tsx index 47f2160ac3..b419d00f02 100644 --- a/packages/element/tests/frame.test.tsx +++ b/packages/element/tests/frame.test.tsx @@ -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]); diff --git a/packages/element/tests/sortElements.test.ts b/packages/element/tests/sortElements.test.ts index 0928b84f29..0554d38e35 100644 --- a/packages/element/tests/sortElements.test.ts +++ b/packages/element/tests/sortElements.test.ts @@ -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 diff --git a/packages/element/tests/textWrapping.test.ts b/packages/element/tests/textWrapping.test.ts index 87c96a4c91..2149dd5622 100644 --- a/packages/element/tests/textWrapping.test.ts +++ b/packages/element/tests/textWrapping.test.ts @@ -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" diff --git a/packages/excalidraw/CHANGELOG.md b/packages/excalidraw/CHANGELOG.md index e4a98d19e8..16a98243ee 100644 --- a/packages/excalidraw/CHANGELOG.md +++ b/packages/excalidraw/CHANGELOG.md @@ -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 + { + 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 ``, `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 + + + + ; + + function Logger() { + // initially null before the ExcalidrawAPIProvider initializes ater + // renders + // When 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 Library ## 0.18.0 (2025-03-11) diff --git a/packages/excalidraw/README.md b/packages/excalidraw/README.md index 5185185534..f7cec95f77 100644 --- a/packages/excalidraw/README.md +++ b/packages/excalidraw/README.md @@ -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 - +```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 ( +

+ +
+ ); +} +``` + +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 ( +
+ +
+ ); +} +``` + +```tsx +// app/page.tsx +import dynamic from "next/dynamic"; + +const ExcalidrawClient = dynamic( + () => import("./components/ExcalidrawClient"), + { ssr: false }, +); + +export default function Page() { + return ; +} +``` + +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 `` 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 + +``` ## 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). diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index f9c57a2851..d6986a17af 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -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" ? { diff --git a/packages/excalidraw/actions/actionDeleteSelected.tsx b/packages/excalidraw/actions/actionDeleteSelected.tsx index 9821abadc8..c25712eb94 100644 --- a/packages/excalidraw/actions/actionDeleteSelected.tsx +++ b/packages/excalidraw/actions/actionDeleteSelected.tsx @@ -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"; diff --git a/packages/excalidraw/actions/actionDeselect.ts b/packages/excalidraw/actions/actionDeselect.ts new file mode 100644 index 0000000000..c9a92c7138 --- /dev/null +++ b/packages/excalidraw/actions/actionDeselect.ts @@ -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, + 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, + 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); + + 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)) + ); + }, +}); diff --git a/packages/excalidraw/actions/actionDuplicateSelection.tsx b/packages/excalidraw/actions/actionDuplicateSelection.tsx index 462803d205..4c22a80ee7 100644 --- a/packages/excalidraw/actions/actionDuplicateSelection.tsx +++ b/packages/excalidraw/actions/actionDuplicateSelection.tsx @@ -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"; diff --git a/packages/excalidraw/actions/actionExport.tsx b/packages/excalidraw/actions/actionExport.tsx index e47a5bb84c..dceeee0e4c 100644 --- a/packages/excalidraw/actions/actionExport.tsx +++ b/packages/excalidraw/actions/actionExport.tsx @@ -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({ 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} + + + ) : ( + message + ), + duration: Infinity, + }, + }); +}; + +/** awaits host app's onExport result, and renders progress to the UI */ +async function handleOnExportResult( + onExportResult: ReturnType>, + opts: { + signal: AbortSignal; + app: AppClassProperties; + }, +): Promise { + 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 } { + const abortController = new AbortController(); + const signal = abortController.signal; + + const dataPromise = new Promise(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 }) => ( { + perform: (_elements, appState, value, app) => { + app.sessionExportThemeOverride = value ? THEME.DARK : THEME.LIGHT; return { appState: { ...appState, exportWithDarkMode: value }, captureUpdate: CaptureUpdateAction.EVENTUALLY, diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 9e529621a7..f2e5db5613 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -329,8 +329,8 @@ export const actionFinalize = register({ 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({ }; }, 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 }) => ( diff --git a/packages/excalidraw/actions/actionFrame.ts b/packages/excalidraw/actions/actionFrame.ts index 3a1b3635d3..81a43e243e 100644 --- a/packages/excalidraw/actions/actionFrame.ts +++ b/packages/excalidraw/actions/actionFrame.ts @@ -205,7 +205,6 @@ export const actionWrapSelectionInFrame = register({ [...app.scene.getElementsIncludingDeleted(), frame], selectedElements, frame, - appState, ); return { diff --git a/packages/excalidraw/actions/actionGroup.tsx b/packages/excalidraw/actions/actionGroup.tsx index c72216b761..c9bb8cf21d 100644 --- a/packages/excalidraw/actions/actionGroup.tsx +++ b/packages/excalidraw/actions/actionGroup.tsx @@ -277,7 +277,6 @@ export const actionUngroup = register({ elementsMap, ), frame, - app, ); } }); diff --git a/packages/excalidraw/actions/actionHistory.tsx b/packages/excalidraw/actions/actionHistory.tsx index 0232bb33e3..5db66670b7 100644 --- a/packages/excalidraw/actions/actionHistory.tsx +++ b/packages/excalidraw/actions/actionHistory.tsx @@ -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, diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 07e680ef7b..fe222fe6e7 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -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 ( 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 ( 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({ captureUpdate: CaptureUpdateAction.IMMEDIATELY, }; }, - PanelComponent: ({ app, updateData }) => ( - - ), + PanelComponent: ({ elements, appState, app, updateData }) => { + const opacity = getFormValue( + elements, + app, + (element) => element.opacity, + true, + (hasSelection) => (hasSelection ? null : appState.currentItemOpacity), + ); + + return ( + + ); + }, }); export const actionChangeFontSize = register( @@ -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: , - }, - { - value: "triangle", - text: t("labels.arrowhead_triangle"), - icon: , - keyBinding: "e", - }, - { - value: "triangle_outline", - text: t("labels.arrowhead_triangle_outline"), - icon: , - keyBinding: "r", - }, - { - value: "circle", - text: t("labels.arrowhead_circle"), - keyBinding: "a", - icon: , - }, - { - value: "circle_outline", - text: t("labels.arrowhead_circle_outline"), - keyBinding: "s", - icon: , - }, - { - value: "diamond", - text: t("labels.arrowhead_diamond"), - icon: , - keyBinding: "d", - }, - { - value: "diamond_outline", - text: t("labels.arrowhead_diamond_outline"), - icon: , - keyBinding: "f", - }, - { - value: "bar", - text: t("labels.arrowhead_bar"), - keyBinding: "z", - icon: , - }, - { - value: "crowfoot_one", - text: t("labels.arrowhead_crowfoot_one"), - icon: , - keyBinding: "x", - }, - { - value: "crowfoot_many", - text: t("labels.arrowhead_crowfoot_many"), - icon: , - keyBinding: "c", - }, - { - value: "crowfoot_one_or_many", - text: t("labels.arrowhead_crowfoot_one_or_many"), - icon: , - keyBinding: "v", - }, - ] as const; + return { + visibleSections: [ + { + name: "default", + options: [ + { + value: null, + text: t("labels.arrowhead_none"), + keyBinding: "q", + icon: , + }, + { + value: "arrow", + text: t("labels.arrowhead_arrow"), + keyBinding: "w", + icon: , + }, + { + value: "triangle", + text: t("labels.arrowhead_triangle"), + icon: , + keyBinding: "e", + }, + { + value: "triangle_outline", + text: t("labels.arrowhead_triangle_outline"), + icon: , + keyBinding: "r", + }, + ], + }, + ], + hiddenSections: [ + { + name: "default", + options: [ + { + value: "circle", + text: t("labels.arrowhead_circle"), + keyBinding: "a", + icon: , + }, + { + value: "circle_outline", + text: t("labels.arrowhead_circle_outline"), + keyBinding: "s", + icon: , + }, + { + value: "diamond", + text: t("labels.arrowhead_diamond"), + icon: , + keyBinding: "d", + }, + { + value: "diamond_outline", + text: t("labels.arrowhead_diamond_outline"), + icon: , + keyBinding: "f", + }, + { + value: "bar", + text: t("labels.arrowhead_bar"), + keyBinding: "z", + icon: , + }, + ], + }, + { + name: t("labels.cardinality"), + options: [ + { + value: "cardinality_one", + text: t("labels.arrowhead_cardinality_one"), + icon: , + keyBinding: "x", + }, + { + value: "cardinality_many", + text: t("labels.arrowhead_cardinality_many"), + icon: , + keyBinding: "c", + }, + { + value: "cardinality_one_or_many", + text: t("labels.arrowhead_cardinality_one_or_many"), + icon: , + keyBinding: "v", + }, + { + value: "cardinality_exactly_one", + text: t("labels.arrowhead_cardinality_exactly_one"), + icon: , + keyBinding: null, + }, + { + value: "cardinality_zero_or_one", + text: t("labels.arrowhead_cardinality_zero_or_one"), + icon: , + keyBinding: null, + }, + { + value: "cardinality_zero_or_many", + text: t("labels.arrowhead_cardinality_zero_or_many"), + icon: , + 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 (
{t("labels.arrowheads")}
( 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} /> ( 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} />
@@ -1828,6 +1897,7 @@ export const actionChangeArrowType = register({ startElement, "start", elementsMap, + appState.isBindingEnabled, ), } : null; @@ -1841,6 +1911,7 @@ export const actionChangeArrowType = register({ endElement, "end", elementsMap, + appState.isBindingEnabled, ), } : null; diff --git a/packages/excalidraw/actions/actionTextAutoResize.ts b/packages/excalidraw/actions/actionTextAutoResize.ts index d4338e7797..dcc0f16955 100644 --- a/packages/excalidraw/actions/actionTextAutoResize.ts +++ b/packages/excalidraw/actions/actionTextAutoResize.ts @@ -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), diff --git a/packages/excalidraw/actions/actionToggleArrowBinding.tsx b/packages/excalidraw/actions/actionToggleArrowBinding.tsx new file mode 100644 index 0000000000..a4e6e50ed2 --- /dev/null +++ b/packages/excalidraw/actions/actionToggleArrowBinding.tsx @@ -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", +}); diff --git a/packages/excalidraw/actions/actionToggleMidpointSnapping.tsx b/packages/excalidraw/actions/actionToggleMidpointSnapping.tsx new file mode 100644 index 0000000000..eca9df7e27 --- /dev/null +++ b/packages/excalidraw/actions/actionToggleMidpointSnapping.tsx @@ -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, +}); diff --git a/packages/excalidraw/actions/index.ts b/packages/excalidraw/actions/index.ts index 6b888e92d3..d630c6ecaa 100644 --- a/packages/excalidraw/actions/index.ts +++ b/packages/excalidraw/actions/index.ts @@ -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"; diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index c85b0639ef..02c67d34f3 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -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" diff --git a/packages/excalidraw/animated-trail.ts b/packages/excalidraw/animatedTrail.ts similarity index 83% rename from packages/excalidraw/animated-trail.ts rename to packages/excalidraw/animatedTrail.ts index 2cf5540e08..e98e6e50ee 100644 --- a/packages/excalidraw/animated-trail.ts +++ b/packages/excalidraw/animatedTrail.ts @@ -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 & Partial, ) { - 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 { diff --git a/packages/excalidraw/animation-frame-handler.ts b/packages/excalidraw/animation-frame-handler.ts deleted file mode 100644 index b1a9844669..0000000000 --- a/packages/excalidraw/animation-frame-handler.ts +++ /dev/null @@ -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(); - private rafIds = new WeakMap(); - - 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; - } -} diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 087b1b795e..a64eed8ca7 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -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 }, diff --git a/packages/excalidraw/charts.test.ts b/packages/excalidraw/charts.test.ts index 94fa92fa0c..16e161ca40 100644 --- a/packages/excalidraw/charts.test.ts +++ b/packages/excalidraw/charts.test.ts @@ -1,8 +1,40 @@ -import { tryParseCells, tryParseNumber, VALID_SPREADSHEET } from "./charts"; +import { FONT_FAMILY } from "@excalidraw/common"; +import { + DEFAULT_CHART_COLOR_INDEX, + getAllColorsSpecificShade, +} from "@excalidraw/common"; + +import type { + ExcalidrawLineElement, + ExcalidrawTextElement, +} from "@excalidraw/element/types"; + +import { + isSpreadsheetValidForChartType, + renderSpreadsheet, + tryParseCells, + tryParseNumber, +} from "./charts"; import type { Spreadsheet } from "./charts"; describe("charts", () => { + const getRotatedBounds = (element: ExcalidrawTextElement) => { + const cos = Math.abs(Math.cos(element.angle)); + const sin = Math.abs(Math.sin(element.angle)); + const rotatedWidth = element.width * cos + element.height * sin; + const rotatedHeight = element.width * sin + element.height * cos; + const centerX = element.x + element.width / 2; + const centerY = element.y + element.height / 2; + return { + left: centerX - rotatedWidth / 2, + right: centerX + rotatedWidth / 2, + top: centerY - rotatedHeight / 2, + bottom: centerY + rotatedHeight / 2, + centerX, + }; + }; + describe("tryParseNumber", () => { it.each<[string, number]>([ ["1", 1], @@ -42,11 +74,11 @@ describe("charts", () => { const result = tryParseCells(spreadsheet); - expect(result.type).toBe(VALID_SPREADSHEET); + expect(result.ok).toBe(true); - const { title, labels, values } = ( - result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet } - ).spreadsheet; + const { title, labels, series } = ( + result as { ok: true; data: Spreadsheet } + ).data; expect(title).toEqual("value"); expect(labels).toEqual([ @@ -57,7 +89,9 @@ describe("charts", () => { "05:00", "06:00", ]); - expect(values).toEqual([61, -60, 85, -67, 54, 95]); + expect(series).toEqual([ + { title: "value", values: [61, -60, 85, -67, 54, 95] }, + ]); }); it("Uses the second column as the label if it is not a number", () => { @@ -73,11 +107,11 @@ describe("charts", () => { const result = tryParseCells(spreadsheet); - expect(result.type).toBe(VALID_SPREADSHEET); + expect(result.ok).toBe(true); - const { title, labels, values } = ( - result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet } - ).spreadsheet; + const { title, labels, series } = ( + result as { ok: true; data: Spreadsheet } + ).data; expect(title).toEqual("value"); expect(labels).toEqual([ @@ -88,7 +122,9 @@ describe("charts", () => { "05:00", "06:00", ]); - expect(values).toEqual([61, -60, 85, -67, 54, 95]); + expect(series).toEqual([ + { title: "value", values: [61, -60, 85, -67, 54, 95] }, + ]); }); it("treats the first column as labels if both columns are numbers", () => { @@ -104,15 +140,1026 @@ describe("charts", () => { const result = tryParseCells(spreadsheet); - expect(result.type).toBe(VALID_SPREADSHEET); + expect(result.ok).toBe(true); - const { title, labels, values } = ( - result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet } - ).spreadsheet; + const { title, labels, series } = ( + result as { ok: true; data: Spreadsheet } + ).data; expect(title).toEqual("value"); expect(labels).toEqual(["01", "02", "03", "04", "05", "06"]); - expect(values).toEqual([61, -60, 85, -67, 54, 95]); + expect(series).toEqual([ + { title: "value", values: [61, -60, 85, -67, 54, 95] }, + ]); + }); + + it("parses multi-series cells for radar charts", () => { + const spreadsheet = [ + ["Metric", "Player A", "Player B", "Player C"], + ["Speed", "80", "60", "75"], + ["Strength", "65", "85", "70"], + ["Agility", "90", "70", "88"], + ["Intelligence", "70", "88", "92"], + ["Stamina", "85", "75", "80"], + ]; + + const result = tryParseCells(spreadsheet); + + expect(result.ok).toBe(true); + + const parsed = (result as { ok: true; data: Spreadsheet }).data; + + expect(parsed.title).toEqual("Metric"); + expect(parsed.labels).toEqual([ + "Speed", + "Strength", + "Agility", + "Intelligence", + "Stamina", + ]); + expect(parsed.series).toEqual([ + { title: "Player A", values: [80, 65, 90, 70, 85] }, + { title: "Player B", values: [60, 85, 70, 88, 75] }, + { title: "Player C", values: [75, 70, 88, 92, 80] }, + ]); + }); + + it("treats first row as title+series headers only when all cells are non-numeric", () => { + const spreadsheet = [ + ["Trait", "10", "20"], + ["Physical Strength", "4", "8"], + ["Strategy", "6", "9"], + ["Charisma", "7", "5"], + ]; + + const result = tryParseCells(spreadsheet); + expect(result.ok).toBe(true); + + const parsed = (result as { ok: true; data: Spreadsheet }).data; + + expect(parsed.title).toBeNull(); + expect(parsed.labels?.[0]).toEqual("Trait"); + expect(parsed.series[0].title).toEqual("Series 1"); + expect(parsed.series[1].title).toEqual("Series 2"); + }); + + it("supports header row with series labels but no chart title", () => { + const spreadsheet = [ + ["", "Dunk", "Egg"], + ["Physical Strength", "10", "2"], + ["Swordsmanship", "8", "1"], + ["Political Instinct", "3", "9"], + ]; + + const result = tryParseCells(spreadsheet); + expect(result.ok).toBe(true); + + const parsed = (result as { ok: true; data: Spreadsheet }).data; + + expect(parsed.title).toBeNull(); + expect(parsed.labels).toEqual([ + "Physical Strength", + "Swordsmanship", + "Political Instinct", + ]); + expect(parsed.series).toEqual([ + { title: "Dunk", values: [10, 8, 3] }, + { title: "Egg", values: [2, 1, 9] }, + ]); + }); + + it("parses 2-row multi-series data with header row", () => { + const spreadsheet = [ + ["trait", "Dunk", "Egg"], + ["Physical Strength", "10", "2"], + ["Swordsmanship skill", "8", "1"], + ]; + + const result = tryParseCells(spreadsheet); + expect(result.ok).toBe(true); + + const parsed = (result as { ok: true; data: Spreadsheet }).data; + + expect(parsed.title).toEqual("trait"); + expect(parsed.labels).toEqual([ + "Physical Strength", + "Swordsmanship skill", + ]); + expect(parsed.series).toEqual([ + { title: "Dunk", values: [10, 8] }, + { title: "Egg", values: [2, 1] }, + ]); + }); + + it("parses 2-row multi-series data without header and keeps first column as labels", () => { + const spreadsheet = [ + ["Physical Strength", "10", "2"], + ["Swordsmanship skill", "8", "1"], + ]; + + const result = tryParseCells(spreadsheet); + expect(result.ok).toBe(true); + + const parsed = (result as { ok: true; data: Spreadsheet }).data; + + expect(parsed.title).toBeNull(); + expect(parsed.labels).toEqual([ + "Physical Strength", + "Swordsmanship skill", + ]); + expect(parsed.series).toEqual([ + { title: "Series 1", values: [10, 8] }, + { title: "Series 2", values: [2, 1] }, + ]); + }); + + it("always interprets 2-column data as label in first column and numeric value in second", () => { + const spreadsheet = [ + ["10", "2"], + ["8", "Swordsmanship skill"], + ["6", "3"], + ]; + + const result = tryParseCells(spreadsheet); + expect(result).toEqual({ + ok: false, + reason: "Value is not numeric", + }); + }); + }); + + describe("isSpreadsheetValidForChartType", () => { + it("rejects radar charts with only 2 dimensions", () => { + const spreadsheet: Spreadsheet = { + title: "trait", + labels: ["Physical Strength", "Swordsmanship skill"], + series: [ + { title: "Dunk", values: [10, 8] }, + { title: "Egg", values: [2, 1] }, + ], + }; + + expect(isSpreadsheetValidForChartType(spreadsheet, "radar")).toBe(false); + expect(isSpreadsheetValidForChartType(spreadsheet, "bar")).toBe(true); + expect(isSpreadsheetValidForChartType(spreadsheet, "line")).toBe(true); + }); + + it("accepts radar charts with 3 or more dimensions", () => { + const spreadsheet: Spreadsheet = { + title: "trait", + labels: [ + "Physical Strength", + "Swordsmanship skill", + "Political Instinct", + ], + series: [ + { title: "Dunk", values: [10, 8, 3] }, + { title: "Egg", values: [2, 1, 9] }, + ], + }; + + expect(isSpreadsheetValidForChartType(spreadsheet, "radar")).toBe(true); + }); + }); + + describe("renderSpreadsheet", () => { + it("renders grouped bars and legend for multi-series bar charts", () => { + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D", "E"], + series: [ + { title: "Dunk", values: [10, 8, 3, 2.5, 5] }, + { title: "Egg", values: [2, 1, 9, 8, 9] }, + { title: "Aerion", values: [7, 8, 7, 4, 5] }, + ], + }; + + const elements = renderSpreadsheet("bar", spreadsheet, 0, 0); + const bars = elements!.filter( + (element) => + element.type === "rectangle" && + element.strokeWidth === 1 && + element.opacity === 100 && + !element.roundness, + ); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + const axisLabels = textElements.filter((element) => + spreadsheet.labels?.includes(element.originalText || ""), + ); + const legendLabels = textElements.filter((element) => + spreadsheet.series.some( + (series) => series.title === element.originalText, + ), + ); + + const axisBottomY = Math.max( + ...axisLabels.map((axisLabel) => axisLabel.y + axisLabel.height), + ); + const legendTopY = Math.min( + ...legendLabels.map((legendLabel) => legendLabel.y), + ); + + expect(bars).toHaveLength( + spreadsheet.series.length * spreadsheet.series[0].values.length, + ); + expect(legendLabels).toHaveLength(spreadsheet.series.length); + expect(legendTopY).toBeGreaterThan(axisBottomY + 2); + }); + + it("spreads grouped bar series colors across palette", () => { + const palette = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX); + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D", "E"], + series: [ + { title: "S1", values: [1, 2, 3, 4, 5] }, + { title: "S2", values: [2, 3, 4, 5, 1] }, + { title: "S3", values: [3, 4, 5, 1, 2] }, + { title: "S4", values: [4, 5, 1, 2, 3] }, + ], + }; + + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); + const elements = renderSpreadsheet("bar", spreadsheet, 0, 0); + randomSpy.mockRestore(); + + const bars = elements!.filter( + (element) => + element.type === "rectangle" && + element.strokeWidth === 1 && + element.opacity === 100 && + !element.roundness, + ); + const uniqueColors = Array.from( + new Set(bars.map((bar) => bar.backgroundColor)), + ); + const colorIndices = uniqueColors.map((color) => + palette.findIndex((paletteColor) => paletteColor === color), + ); + + expect(uniqueColors).toHaveLength(spreadsheet.series.length); + expect(colorIndices.every((index) => index >= 0)).toBe(true); + + const circularDistance = (first: number, second: number) => { + const absoluteDistance = Math.abs(first - second); + return Math.min(absoluteDistance, palette.length - absoluteDistance); + }; + const minDistance = Math.min( + ...colorIndices.flatMap((index, i) => + colorIndices + .slice(i + 1) + .map((other) => circularDistance(index, other)), + ), + ); + expect(minDistance).toBeGreaterThan(1); + }); + + it("renders grouped bars for parsed multi-series cells without header row", () => { + const cells = [ + ["Physical Strength", "10", "2", "7"], + ["Swordsmanship", "8", "1", "8"], + ["Political Instinct", "3", "9", "7"], + ["Book Knowledge", "2.5", "8", "4"], + ]; + const parsedResult = tryParseCells(cells); + expect(parsedResult.ok).toBe(true); + const parsedSpreadsheet = ( + parsedResult as { + ok: true; + data: Spreadsheet; + } + ).data; + + const elements = renderSpreadsheet("bar", parsedSpreadsheet, 0, 0); + const bars = elements!.filter( + (element) => + element.type === "rectangle" && + element.strokeWidth === 1 && + element.opacity === 100 && + !element.roundness, + ); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + const legendLabels = textElements + .map((element) => element.originalText) + .filter((text): text is string => typeof text === "string"); + + expect(bars).toHaveLength( + parsedSpreadsheet.series[0].values.length * + parsedSpreadsheet.series.length, + ); + expect(legendLabels).toContain("Series 1"); + expect(legendLabels).toContain("Series 2"); + expect(legendLabels).toContain("Series 3"); + }); + + it("makes multi-series bar charts wider than single-series bar charts", () => { + const singleSeries: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D"], + series: [{ title: "Trait", values: [10, 8, 3, 2.5] }], + }; + const multiSeries: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D"], + series: [ + { title: "Dunk", values: [10, 8, 3, 2.5] }, + { title: "Egg", values: [2, 1, 9, 8] }, + { title: "Aerion", values: [7, 8, 7, 4] }, + ], + }; + + const singleElements = renderSpreadsheet("bar", singleSeries, 0, 0); + const multiElements = renderSpreadsheet("bar", multiSeries, 0, 0); + const getXAxisWidth = (elements: ReturnType) => + elements!.find( + (element): element is ExcalidrawLineElement => + element.type === "line" && + element.strokeStyle === "solid" && + element.points[0][1] === 0 && + element.points[1][1] === 0 && + element.points[1][0] > 0, + )?.width || 0; + + expect(getXAxisWidth(multiElements)).toBeGreaterThan( + getXAxisWidth(singleElements), + ); + }); + + it("makes multi-series line charts wider than single-series line charts", () => { + const singleSeries: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D"], + series: [{ title: "Trait", values: [10, 8, 3, 2.5] }], + }; + const multiSeries: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D"], + series: [ + { title: "Dunk", values: [10, 8, 3, 2.5] }, + { title: "Egg", values: [2, 1, 9, 8] }, + { title: "Aerion", values: [7, 8, 7, 4] }, + ], + }; + + const singleElements = renderSpreadsheet("line", singleSeries, 0, 0); + const multiElements = renderSpreadsheet("line", multiSeries, 0, 0); + const getXAxisWidth = (elements: ReturnType) => + elements!.find( + (element): element is ExcalidrawLineElement => + element.type === "line" && + element.strokeStyle === "solid" && + element.points[0][1] === 0 && + element.points[1][1] === 0 && + element.points[1][0] > 0, + )?.width || 0; + + expect(getXAxisWidth(multiElements)).toBeGreaterThan( + getXAxisWidth(singleElements), + ); + }); + + it("wraps grouped bar labels with spaces and still ellipsifies long single words", () => { + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: [ + "Supercalifragilisticexpialidocious", + "Data Flow", + "Logic Layer", + ], + series: [ + { title: "Dunk", values: [8, 3, 2.5] }, + { title: "Egg", values: [1, 9, 8] }, + { title: "Aerion", values: [8, 7, 4] }, + ], + }; + + const elements = renderSpreadsheet("bar", spreadsheet, 0, 0); + const longWordLabel = elements!.find( + (element): element is ExcalidrawTextElement => + element.type === "text" && + Math.abs(element.angle) > 0 && + element.text.includes("..."), + ); + const spacedLabels = elements!.filter( + (element): element is ExcalidrawTextElement => + element.type === "text" && + (element.originalText === "Data Flow" || + element.originalText === "Logic Layer"), + ); + + expect(longWordLabel).toBeDefined(); + expect(longWordLabel?.text).toContain("..."); + expect(longWordLabel?.originalText).toBe(longWordLabel?.text); + expect( + (longWordLabel?.text || "").replace("...", "").length, + ).toBeGreaterThan(0); + expect(spacedLabels.some((label) => label.text.includes("\n"))).toBe( + true, + ); + expect( + spacedLabels.every( + (label) => !!label.originalText && !label.originalText.includes("\n"), + ), + ).toBe(true); + }); + + it("keeps single-series bar x-axis labels below axis and avoids neighbor overlap", () => { + const spreadsheet: Spreadsheet = { + title: "Dunk", + labels: [ + "Physical Strength", + "Swordsmanship", + "Political Instinct", + "Book Knowledge", + "Strategic Thinking", + "charisma", + "courage", + "Stubbornness", + "Empathy", + "Practical Survival Skills", + ], + series: [{ title: "Dunk", values: [10, 8, 3, 2.5, 5, 7, 9, 8, 8, 9] }], + }; + + const elements = renderSpreadsheet("bar", spreadsheet, 0, 0); + const axisLabels = elements!.filter( + (element): element is ExcalidrawTextElement => + element.type === "text" && Math.abs(element.angle) > 0, + ); + + expect(axisLabels).toHaveLength(spreadsheet.labels!.length); + + const bounds = axisLabels.map(getRotatedBounds); + for (const bound of bounds) { + expect(bound.top).toBeGreaterThan(0); + } + + const sortedBounds = bounds.sort( + (left, right) => left.centerX - right.centerX, + ); + for (let index = 1; index < sortedBounds.length; index++) { + expect(sortedBounds[index - 1].right).toBeLessThanOrEqual( + sortedBounds[index].left + 2, + ); + } + }); + + it("renders one line per series and one dot per data point for multi-series line charts", () => { + const spreadsheet: Spreadsheet = { + title: "Scores", + labels: ["alpha", "beta", "gamma", "delta", "epsilon"], + series: [ + { title: "Team A", values: [42150, 8300, 95400, 7820, 310500] }, + { title: "Team B", values: [63400, 3150, 51200, 4670, 125800] }, + ], + }; + + const elements = renderSpreadsheet("line", spreadsheet, 0, 0); + const seriesLines = elements!.filter( + (element): element is ExcalidrawLineElement => + element.type === "line" && element.strokeWidth === 2, + ); + const dots = elements!.filter( + (element) => element.type === "ellipse" && element.strokeWidth === 2, + ); + + expect(seriesLines).toHaveLength(spreadsheet.series.length); + expect(dots).toHaveLength( + spreadsheet.series.length * spreadsheet.series[0].values.length, + ); + }); + + it("spreads line series colors across palette to avoid similar adjacent colors", () => { + const palette = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX); + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D", "E"], + series: [ + { title: "S1", values: [1, 2, 3, 4, 5] }, + { title: "S2", values: [2, 3, 4, 5, 1] }, + { title: "S3", values: [3, 4, 5, 1, 2] }, + { title: "S4", values: [4, 5, 1, 2, 3] }, + ], + }; + + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); + const elements = renderSpreadsheet("line", spreadsheet, 0, 0); + randomSpy.mockRestore(); + + const seriesLines = elements!.filter( + (element) => element.type === "line" && element.strokeWidth === 2, + ); + const colorIndices = seriesLines.map((line) => + palette.findIndex((color) => color === line.strokeColor), + ); + + expect(colorIndices.every((index) => index >= 0)).toBe(true); + + const circularDistance = (first: number, second: number) => { + const absoluteDistance = Math.abs(first - second); + return Math.min(absoluteDistance, palette.length - absoluteDistance); + }; + const minDistance = Math.min( + ...colorIndices.flatMap((index, i) => + colorIndices + .slice(i + 1) + .map((other) => circularDistance(index, other)), + ), + ); + + expect(minDistance).toBeGreaterThan(1); + }); + + it("uses colorSeed to deterministically pick chart colors", () => { + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D"], + series: [ + { title: "S1", values: [1, 2, 3, 4] }, + { title: "S2", values: [4, 3, 2, 1] }, + { title: "S3", values: [2, 3, 4, 1] }, + ], + }; + + const getSeriesLineColors = (seed: number) => { + const elements = renderSpreadsheet("line", spreadsheet, 0, 0, seed); + return elements! + .filter( + (element): element is ExcalidrawLineElement => + element.type === "line" && element.strokeWidth === 2, + ) + .map((line) => line.strokeColor); + }; + + expect(getSeriesLineColors(0.125)).toEqual(getSeriesLineColors(0.125)); + expect(getSeriesLineColors(0.125)).not.toEqual( + getSeriesLineColors(0.875), + ); + }); + + it("renders multi-series line legend below axis labels with clearance", () => { + const spreadsheet: Spreadsheet = { + title: "Scores", + labels: ["alpha", "beta", "gamma", "delta", "epsilon"], + series: [ + { title: "Team A", values: [42150, 8300, 95400, 12600, 310500] }, + { title: "Team B", values: [63400, 3150, 51200, 9200, 125800] }, + ], + }; + + const elements = renderSpreadsheet("line", spreadsheet, 0, 0); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + const axisLabels = textElements.filter((element) => + spreadsheet.labels?.includes(element.originalText || ""), + ); + const legendLabels = textElements.filter((element) => + spreadsheet.series.some( + (series) => series.title === element.originalText, + ), + ); + + const axisBottomY = Math.max( + ...axisLabels.map((axisLabel) => axisLabel.y + axisLabel.height), + ); + const legendTopY = Math.min( + ...legendLabels.map((legendLabel) => legendLabel.y), + ); + + expect(axisLabels.length).toBeGreaterThan(0); + expect(legendLabels.length).toBe(2); + expect(legendTopY).toBeGreaterThan(axisBottomY + 2); + }); + + it("keeps multi-series line x-axis labels below axis and avoids neighbor overlap", () => { + const spreadsheet: Spreadsheet = { + title: "trait", + labels: [ + "Physical Strength", + "Swordsmanship", + "Political Instinct", + "Book Knowledge", + "Strategic Thinking", + "charisma", + "courage", + "Stubbornness", + "Empathy", + "Practical Survival Skills", + ], + series: [ + { title: "Dunk", values: [10, 8, 3, 2.5, 5, 7, 9, 8, 8, 9] }, + { title: "Egg", values: [2, 1, 9, 8, 9, 8, 7, 9, 8, 4] }, + ], + }; + + const elements = renderSpreadsheet("line", spreadsheet, 0, 0); + const axisLabels = elements!.filter( + (element): element is ExcalidrawTextElement => + element.type === "text" && Math.abs(element.angle) > 0, + ); + + expect(axisLabels).toHaveLength(spreadsheet.labels!.length); + + const bounds = axisLabels.map(getRotatedBounds); + for (const bound of bounds) { + expect(bound.top).toBeGreaterThan(0); + } + + const sortedBounds = bounds.sort( + (left, right) => left.centerX - right.centerX, + ); + for (let index = 1; index < sortedBounds.length; index++) { + expect(sortedBounds[index - 1].right).toBeLessThanOrEqual( + sortedBounds[index].left + 2, + ); + } + }); + + it("renders one closed polygon line per radar series", () => { + const spreadsheet: Spreadsheet = { + title: "Metric", + labels: ["Speed", "Strength", "Agility", "Intelligence", "Stamina"], + series: [ + { title: "Player A", values: [80, 65, 90, 70, 85] }, + { title: "Player B", values: [60, 85, 70, 88, 75] }, + { title: "Player C", values: [75, 70, 88, 92, 80] }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const seriesPolygons = elements!.filter( + (element): element is ExcalidrawLineElement => + element.type === "line" && + "polygon" in element && + element.polygon === true && + element.strokeWidth === 2, + ); + + expect(seriesPolygons).toHaveLength(3); + for (const polygon of seriesPolygons) { + expect(polygon.points[0]).toEqual( + polygon.points[polygon.points.length - 1], + ); + } + }); + + it("normalizes multi-series radar values with global scale", () => { + const spreadsheet: Spreadsheet = { + title: "Scores", + labels: ["alpha", "beta", "gamma", "delta", "epsilon"], + series: [ + { title: "Series 1", values: [40000, 8300, 95400, 7820, 5000000] }, + { title: "Series 2", values: [76000, 3150, 51200, 4670, 60000] }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const seriesPolygons = elements!.filter( + (element): element is ExcalidrawLineElement => + element.type === "line" && + "polygon" in element && + element.polygon === true && + element.strokeWidth === 2, + ); + + const series1 = seriesPolygons[0]; + const series2 = seriesPolygons[1]; + const getRadius = (point: readonly [number, number]) => + Math.hypot(point[0], point[1]); + + // On alpha axis, second series is about ~1.9x first series. + const alphaRatio = + getRadius(series2.points[0]!) / getRadius(series1.points[0]!); + expect(alphaRatio).toBeCloseTo(76000 / 40000, 1); + + // On epsilon axis, first series should dominate strongly. + const epsilonRatio = + getRadius(series1.points[4]!) / getRadius(series2.points[4]!); + expect(epsilonRatio).toBeGreaterThan(50); + }); + + // it("always renders radar step rings regardless of axis scale ratio", () => { + // const spreadsheet: Spreadsheet = { + // title: "Scores", + // labels: ["alpha", "beta", "gamma", "delta", "epsilon"], + // series: [ + // { title: "Series 1", values: [40000, 8300, 95400, 7820, 5000000] }, + // { title: "Series 2", values: [76000, 3150, 51200, 4670, 60000] }, + // ], + // }; + + // const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + // const stepRings = elements!.filter( + // (element) => + // element.type === "line" && + // "polygon" in element && + // element.polygon && + // element.strokeStyle === "solid" && + // element.strokeWidth === 1, + // ); + + // expect(stepRings).toHaveLength(4); + // }); + + it("uses log normalization for highly skewed single-series radar data", () => { + const spreadsheet: Spreadsheet = { + title: "Scores", + labels: ["alpha", "beta", "gamma", "delta", "epsilon"], + series: [ + { + title: "Scores", + values: [40000, 8300, 95400, 7820, 5000000], + }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const seriesPolygons = elements!.filter( + (element): element is ExcalidrawLineElement => + element.type === "line" && + "polygon" in element && + element.polygon === true && + element.strokeWidth === 2, + ); + + const polygon = seriesPolygons[0]; + const getRadius = (point: readonly [number, number]) => + Math.hypot(point[0], point[1]); + + const alphaRadius = getRadius(polygon.points[0]!); + const epsilonRadius = getRadius(polygon.points[4]!); + + // With linear scaling this would collapse near 0; log keeps it visible. + expect(alphaRadius).toBeGreaterThan(40); + expect(epsilonRadius).toBeGreaterThan(alphaRadius); + }); + + it("does not render 0/max value labels for radar charts", () => { + const spreadsheet: Spreadsheet = { + title: "Scores", + labels: ["alpha", "beta", "gamma", "delta", "epsilon"], + series: [ + { + title: "Scores", + values: [40000, 8300, 95400, 7820, 5000000], + }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + + expect(textElements.some((element) => element.text === "0")).toBe(false); + expect( + textElements.some( + (element) => + element.text === + Math.max(...spreadsheet.series[0].values).toLocaleString(), + ), + ).toBe(false); + }); + + it("wraps long radar axis labels instead of ellipsifying", () => { + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: [ + "Physical Strength", + "Swordsmanship", + "Political Instinct", + "Book Knowledge", + "Strategic Thinking", + "Charisma", + "Courage", + "Stubbornness", + "Empathy", + "Practical Survival Skills", + ], + series: [ + { title: "Dunk", values: [10, 8, 3, 2.5, 5, 7, 9, 8, 8, 9] }, + { title: "Egg", values: [2, 1, 9, 8, 9, 8, 7, 9, 8, 4] }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + const wrappedAxisLabels = textElements.filter( + (element) => + element.text.includes("\n") && + element.text !== "Trait" && + element.text !== "Dunk" && + element.text !== "Egg", + ); + + expect(wrappedAxisLabels.length).toBeGreaterThan(0); + expect( + wrappedAxisLabels.every( + (element) => + typeof element.originalText === "string" && + !element.originalText.includes("\n"), + ), + ).toBe(true); + expect( + textElements.some( + (element) => element.text.includes("...") && element.text !== "Dunk", + ), + ).toBe(false); + expect( + textElements.some( + (element) => + element.originalText === "Stubbornness" && + !element.text.includes("\n") && + element.text === "Stubbornness", + ), + ).toBe(true); + expect( + textElements.some( + (element) => + element.originalText === "Physical Strength" && + element.text.includes("Physical\nStrength"), + ), + ).toBe(true); + + const topLabel = textElements.find( + (element) => element.originalText === "Physical Strength", + ); + const topSpokeY = Math.min( + ...elements! + .filter( + (element): element is ExcalidrawLineElement => + element.type === "line" && + "polygon" in element && + !element.polygon && + element.strokeStyle === "solid" && + element.strokeWidth === 1, + ) + .map((element) => element.y + element.points[1][1]), + ); + expect(topLabel).toBeDefined(); + expect(topLabel!.y + topLabel!.height).toBeLessThan(topSpokeY - 2); + }); + + it("renders radar title and series legend labels in Lilita One", () => { + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: ["Physical Strength", "Swordsmanship", "Strategy", "Charisma"], + series: [ + { title: "Dunk", values: [10, 8, 5, 7] }, + { title: "Egg", values: [2, 1, 9, 8] }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + const title = textElements.find((element) => + element.text.includes("Trait"), + ); + const dunkLabel = textElements.find((element) => element.text === "Dunk"); + const eggLabel = textElements.find((element) => element.text === "Egg"); + + expect(title?.fontFamily).toBe(FONT_FAMILY["Lilita One"]); + expect(title?.originalText).toBe("Trait"); + expect(dunkLabel?.fontFamily).toBe(FONT_FAMILY["Lilita One"]); + expect(eggLabel?.fontFamily).toBe(FONT_FAMILY["Lilita One"]); + }); + + it("positions radar title with vertical clearance above axis labels", () => { + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: [ + "Physical Strength", + "Swordsmanship", + "Political Instinct", + "Book Knowledge", + "Strategic Thinking", + "Charisma", + "Courage", + "Stubbornness", + "Empathy", + "Practical Survival Skills", + ], + series: [ + { title: "Dunk", values: [10, 8, 3, 2.5, 5, 7, 9, 8, 8, 9] }, + { title: "Egg", values: [2, 1, 9, 8, 9, 8, 7, 9, 8, 4] }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + const title = textElements.find( + (element) => element.fontFamily === FONT_FAMILY["Lilita One"], + ); + const axisLabels = textElements.filter( + (element) => + element.fontFamily === FONT_FAMILY.Excalifont && + element.text !== "Dunk" && + element.text !== "Egg", + ); + const topAxisLabelY = Math.min(...axisLabels.map((element) => element.y)); + + expect(title).toBeDefined(); + expect(title!.y + title!.height).toBeLessThan(topAxisLabelY - 4); + }); + + it("spreads radar series colors across palette to avoid similar adjacent colors", () => { + const palette = getAllColorsSpecificShade(DEFAULT_CHART_COLOR_INDEX); + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: ["A", "B", "C", "D", "E"], + series: [ + { title: "S1", values: [1, 2, 3, 4, 5] }, + { title: "S2", values: [2, 3, 4, 5, 1] }, + { title: "S3", values: [3, 4, 5, 1, 2] }, + { title: "S4", values: [4, 5, 1, 2, 3] }, + ], + }; + + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + randomSpy.mockRestore(); + + const seriesPolygons = elements!.filter( + (element) => + element.type === "line" && + "polygon" in element && + element.polygon === true && + element.strokeWidth === 2, + ); + const colorIndices = seriesPolygons.map((polygon) => + palette.findIndex((color) => color === polygon.strokeColor), + ); + + expect(colorIndices.every((index) => index >= 0)).toBe(true); + + const circularDistance = (first: number, second: number) => { + const absoluteDistance = Math.abs(first - second); + return Math.min(absoluteDistance, palette.length - absoluteDistance); + }; + const minDistance = Math.min( + ...colorIndices.flatMap((index, i) => + colorIndices + .slice(i + 1) + .map((other) => circularDistance(index, other)), + ), + ); + + expect(minDistance).toBeGreaterThan(1); + }); + + it("positions series legend below the lowest axis label with clearance", () => { + const spreadsheet: Spreadsheet = { + title: "Trait", + labels: [ + "Psychological Warfare", + "Divine Favor", + "Confidence", + "Morale", + "Armor Protection long wrapped label from above", + "Accuracy", + "Agility", + "Weapon Reach", + ], + series: [ + { title: "David", values: [6, 7, 8, 9, 7, 8, 6, 9] }, + { title: "Goliath", values: [9, 3, 2, 6, 10, 2, 8, 1] }, + ], + }; + + const elements = renderSpreadsheet("radar", spreadsheet, 0, 0); + const textElements = elements!.filter( + (element) => element.type === "text", + ); + const axisLabels = textElements.filter((element) => + spreadsheet.labels?.includes(element.originalText), + ); + const legendLabels = textElements.filter((element) => + spreadsheet.series.some( + (series) => series.title === element.originalText, + ), + ); + + const axisBottomY = Math.max( + ...axisLabels.map((axisLabel) => axisLabel.y + axisLabel.height), + ); + const legendTopY = Math.min( + ...legendLabels.map((legendLabel) => legendLabel.y), + ); + + expect(axisLabels.length).toBeGreaterThan(0); + expect(legendLabels.length).toBeGreaterThan(0); + expect(legendTopY).toBeGreaterThan(axisBottomY + 2); }); }); }); diff --git a/packages/excalidraw/charts.ts b/packages/excalidraw/charts.ts deleted file mode 100644 index 26f8802936..0000000000 --- a/packages/excalidraw/charts.ts +++ /dev/null @@ -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); -}; diff --git a/packages/excalidraw/charts/charts.bar.ts b/packages/excalidraw/charts/charts.bar.ts new file mode 100644 index 0000000000..b1a7759606 --- /dev/null +++ b/packages/excalidraw/charts/charts.bar.ts @@ -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]; +}; diff --git a/packages/excalidraw/charts/charts.constants.ts b/packages/excalidraw/charts/charts.constants.ts new file mode 100644 index 0000000000..4cb23da11b --- /dev/null +++ b/packages/excalidraw/charts/charts.constants.ts @@ -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; +}; diff --git a/packages/excalidraw/charts/charts.helpers.ts b/packages/excalidraw/charts/charts.helpers.ts new file mode 100644 index 0000000000..18097b1df9 --- /dev/null +++ b/packages/excalidraw/charts/charts.helpers.ts @@ -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, + 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, + 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, + 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; + rotatedWidth: number; + rotatedHeight: number; +}; + +const isEllipsifiedLabel = (text: string) => text.includes("..."); + +const getCartesianAxisLabelSpec = ( + label: string, + maxLabelWidth: number, + maxRotatedWidth: number, + fontString: ReturnType, + 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), + ]; +}; diff --git a/packages/excalidraw/charts/charts.line.ts b/packages/excalidraw/charts/charts.line.ts new file mode 100644 index 0000000000..b08774d8b3 --- /dev/null +++ b/packages/excalidraw/charts/charts.line.ts @@ -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( + 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]; +}; diff --git a/packages/excalidraw/charts/charts.parse.ts b/packages/excalidraw/charts/charts.parse.ts new file mode 100644 index 0000000000..f6d71fdf69 --- /dev/null +++ b/packages/excalidraw/charts/charts.parse.ts @@ -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); +}; diff --git a/packages/excalidraw/charts/charts.radar.ts b/packages/excalidraw/charts/charts.radar.ts new file mode 100644 index 0000000000..6606a4af60 --- /dev/null +++ b/packages/excalidraw/charts/charts.radar.ts @@ -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( + 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( + 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, + ]; +}; diff --git a/packages/excalidraw/charts/charts.types.ts b/packages/excalidraw/charts/charts.types.ts new file mode 100644 index 0000000000..29f3971a58 --- /dev/null +++ b/packages/excalidraw/charts/charts.types.ts @@ -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 }; diff --git a/packages/excalidraw/charts/index.ts b/packages/excalidraw/charts/index.ts new file mode 100644 index 0000000000..d806546a49 --- /dev/null +++ b/packages/excalidraw/charts/index.ts @@ -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); +}; diff --git a/packages/excalidraw/clipboard.test.ts b/packages/excalidraw/clipboard.test.ts index 2115c3eff2..6f2b6fc374 100644 --- a/packages/excalidraw/clipboard.test.ts +++ b/packages/excalidraw/clipboard.test.ts @@ -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": ` - -
ab
12
45
710
- - `, - "text/plain": `a b - 1 2 - 4 5 - 7 10`, - }, - }), - ), - ); - expect(clipboardData.spreadsheet).toEqual({ - title: "b", - labels: ["1", "4", "7"], - values: [2, 5, 10], - }); - }); }); diff --git a/packages/excalidraw/clipboard.ts b/packages/excalidraw/clipboard.ts index 6033b857af..6de0b12eab 100644 --- a/packages/excalidraw/clipboard.ts +++ b/packages/excalidraw/clipboard.ts @@ -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; kind: "file"; file: File; - fileHandle: FileSystemHandle | null; + fileHandle: FileSystemFileHandle | null; } | { type: ValueOf; 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 = diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index d9f3415d64..be065d826d 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -226,7 +226,7 @@ export const SelectedShapeActions = ({ {(appState.activeTool.type === "text" || targetElements.some(isTextElement)) && ( <> - {renderAction("changeFontFamily")} +
{renderAction("changeFontFamily")}
{renderAction("changeFontSize")} {(appState.activeTool.type === "text" || suppportsHorizontalAlign(targetElements, elementsMap)) && @@ -1081,8 +1081,9 @@ export const ShapesSwitcher = ({ return ( <> {getToolbarTools(app).map( - ({ value, icon, key, numericKey, fillable }, index) => { + ({ value, icon, key, numericKey, fillable, toolbar }) => { if ( + toolbar === false || UIOptions.tools?.[ value as Extract< typeof value, @@ -1099,6 +1100,9 @@ export const ShapesSwitcher = ({ const shortcut = letter ? `${letter} ${t("helpDialog.or")} ${numericKey}` : `${numericKey}`; + const keybindingLabel = + value === "hand" ? undefined : numericKey || letter; + // when in compact styles panel mode (tablet) // use a ToolPopover for selection/lasso toggle as well if ( @@ -1143,7 +1147,7 @@ export const ShapesSwitcher = ({ checked={activeTool.type === value} name="editor-current-shape" title={`${capitalizeString(label)} — ${shortcut}`} - keyBindingLabel={numericKey || letter} + keyBindingLabel={keybindingLabel} aria-label={capitalizeString(label)} aria-keyshortcuts={shortcut} data-testid={`toolbar-${value}`} diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index f3650a35ad..6136a79a8c 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -27,7 +27,7 @@ import { KEYS, APP_NAME, CURSOR_TYPE, - DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT, + DEFAULT_TRANSFORM_HANDLE_SPACING, DEFAULT_VERTICAL_ALIGN, DRAGGING_THRESHOLD, ELEMENT_SHIFT_TRANSLATE_AMOUNT, @@ -37,7 +37,6 @@ import { IMAGE_MIME_TYPES, IMAGE_RENDER_TIMEOUT, LINE_CONFIRM_THRESHOLD, - MAX_ALLOWED_FILE_BYTES, MIME_TYPES, MQ_RIGHT_SIDEBAR_MIN_WIDTH, POINTER_BUTTON, @@ -88,6 +87,7 @@ import { isShallowEqual, arrayToMap, applyDarkModeFilter, + AppEventBus, type EXPORT_IMAGE_TYPES, randomInteger, CLASSES, @@ -108,6 +108,7 @@ import { loadDesktopUIModePreference, setDesktopUIMode, isSelectionLikeTool, + oneOf, } from "@excalidraw/common"; import { @@ -118,7 +119,6 @@ import { fixBindingsAfterDeletion, getHoveredElementForBinding, isBindingEnabled, - shouldEnableBindingForPointerEvent, updateBoundElements, LinearElementEditor, newElementWith, @@ -174,7 +174,9 @@ import { isValidTextContainer, redrawTextBoundingBox, hasBoundingBox, + getCommonFrameId, getFrameChildren, + getFrameChildrenInsertionIndex, isCursorInFrame, addElementsToFrame, replaceAllElementsInFrame, @@ -256,6 +258,8 @@ import { handleFocusPointPointerUp, maybeHandleArrowPointlikeDrag, getUncroppedWidthAndHeight, + getActiveTextElement, + isEligibleFrameChildType, } from "@excalidraw/element"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; @@ -318,6 +322,8 @@ import { actionToggleElementLock, actionToggleLinearEditor, actionToggleObjectsSnapMode, + actionToggleArrowBinding, + actionToggleMidpointSnapping, actionToggleCropEditor, } from "../actions"; import { actionWrapTextInContainer } from "../actions/actionBoundText"; @@ -337,7 +343,6 @@ import { ActionManager } from "../actions/manager"; import { actions } from "../actions/register"; import { getShortcutFromShortcutName } from "../actions/shortcuts"; import { trackEvent } from "../analytics"; -import { AnimationFrameHandler } from "../animation-frame-handler"; import { getDefaultAppState, isEraserActive, @@ -411,8 +416,9 @@ import { setCursorForShape, } from "../cursor"; import { ElementCanvasButtons } from "../components/ElementCanvasButtons"; -import { LaserTrails } from "../laser-trails"; +import { LaserTrails } from "../laserTrails"; import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils"; +import { isPointHittingTextAutoResizeHandle } from "../textAutoResizeHandle"; import { textWysiwyg } from "../wysiwyg/textWysiwyg"; import { isOverScrollBars } from "../scene/scrollbars"; @@ -424,6 +430,8 @@ import { EraserTrail } from "../eraser"; import { getShortcutKey } from "../shortcut"; +import { tryParseSpreadsheet } from "../charts"; + import ConvertElementTypePopup, { getConversionTypeFromElements, convertElementTypePopupAtom, @@ -442,12 +450,9 @@ import { searchItemInFocusAtom } from "./SearchMenu"; import { isSidebarDockedAtom } from "./Sidebar/Sidebar"; import { StaticCanvas, InteractiveCanvas } from "./canvases"; import NewElementCanvas from "./canvases/NewElementCanvas"; -import { - isPointHittingLink, - isPointHittingLinkIcon, -} from "./hyperlink/helpers"; +import { isPointHittingLink } from "./hyperlink/helpers"; import { MagicIcon, copyIcon, fullscreenIcon } from "./icons"; -import { Toast } from "./Toast"; +import { AppStateObserver, type OnStateChange } from "./AppStateObserver"; import { findShapeByKey } from "./shapes"; @@ -463,7 +468,6 @@ import type { import type { ClipboardData, PastedMixedContent } from "../clipboard"; import type { ExportedElements } from "../data"; import type { ContextMenuItems } from "./ContextMenu"; -import type { FileSystemHandle } from "../data/filesystem"; import type { AppClassProperties, @@ -487,6 +491,7 @@ import type { UnsubscribeCallback, EmbedsValidationStatus, ElementsPendingErasure, + ExcalidrawImperativeAPIEventMap, GenerateDiagramToCode, NullableGridSize, Offsets, @@ -512,6 +517,12 @@ const EditorInterfaceContext = React.createContext( ); EditorInterfaceContext.displayName = "EditorInterfaceContext"; +const editorLifecycleEventBehavior = { + "editor:mount": { cardinality: "once", replay: "last" }, + "editor:initialize": { cardinality: "once", replay: "last" }, + "editor:unmount": { cardinality: "once", replay: "last" }, +} as const; + export const ExcalidrawContainerContext = React.createContext<{ container: HTMLDivElement | null; id: string | null; @@ -544,6 +555,15 @@ const ExcalidrawActionManagerContext = React.createContext( ); ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext"; +export const ExcalidrawAPIContext = + React.createContext(null); +ExcalidrawAPIContext.displayName = "ExcalidrawAPIContext"; + +export const ExcalidrawAPISetContext = React.createContext< + ((api: ExcalidrawImperativeAPI | null) => void) | null +>(null); +ExcalidrawAPISetContext.displayName = "ExcalidrawAPISetContext"; + export const useApp = () => useContext(AppContext); export const useAppProps = () => useContext(AppPropsContext); export const useEditorInterface = () => @@ -560,6 +580,10 @@ export const useExcalidrawSetAppState = () => useContext(ExcalidrawSetAppStateContext); export const useExcalidrawActionManager = () => useContext(ExcalidrawActionManagerContext); +/** + * Requires wrapping your component in + */ +export const useExcalidrawAPI = () => useContext(ExcalidrawAPIContext); let didTapTwice: boolean = false; let tappedTwiceTimer = 0; @@ -579,6 +603,8 @@ const YOUTUBE_VIDEO_STATES = new Map< ValueOf >(); +const MAX_EMBEDDABLE_VIEWPORT_SCALE = 4; + let IS_PLAIN_PASTE = false; let IS_PLAIN_PASTE_TIMER = 0; let PLAIN_PASTE_TOAST_SHOWN = false; @@ -594,6 +620,7 @@ const gesture: Gesture = { class App extends React.Component { canvas: AppClassProperties["canvas"]; interactiveCanvas: AppClassProperties["interactiveCanvas"] = null; + public sessionExportThemeOverride: AppState["theme"] | undefined; rc: RoughCanvas; unmounted: boolean = false; actionManager: ActionManager; @@ -633,12 +660,26 @@ class App extends React.Component { * insert to DOM before user initially scrolls to them) */ private initializedEmbeds = new Set(); - private handleToastClose = () => { - this.setToast(null); - }; - private elementsPendingErasure: ElementsPendingErasure = new Set(); + private _initialized = false; + + private readonly editorLifecycleEvents = new AppEventBus< + ExcalidrawImperativeAPIEventMap, + typeof editorLifecycleEventBehavior + >(editorLifecycleEventBehavior); + + public onEvent = this.editorLifecycleEvents.on.bind( + this.editorLifecycleEvents, + ) as AppEventBus< + ExcalidrawImperativeAPIEventMap, + typeof editorLifecycleEventBehavior + >["on"]; + + private appStateObserver = new AppStateObserver(() => this.state); + + public onStateChange: OnStateChange = this.appStateObserver.onStateChange; + public flowChartCreator: FlowChartCreator = new FlowChartCreator(); private flowChartNavigator: FlowChartNavigator = new FlowChartNavigator(); @@ -648,18 +689,22 @@ class App extends React.Component { lastPointerDownEvent: React.PointerEvent | null = null; lastPointerUpEvent: React.PointerEvent | PointerEvent | null = null; + // TODO this is a hack and we should ideally unify touch and pointer events + // and implement our own double click handling end-to-end (currently we're + // using a mix of native browser for click events and manual for touch - + // and browser doubleClick sucks to begin with) + lastPointerUpIsDoubleClick: boolean = false; lastPointerMoveEvent: PointerEvent | null = null; /** current frame pointer cords */ lastPointerMoveCoords: { x: number; y: number } | null = null; + private lastCompletedCanvasClicks: { x: number; y: number }[] = []; /** previous frame pointer coords */ previousPointerMoveCoords: { x: number; y: number } | null = null; lastViewportPosition = { x: 0, y: 0 }; - animationFrameHandler = new AnimationFrameHandler(); - - laserTrails = new LaserTrails(this.animationFrameHandler, this); - eraserTrail = new EraserTrail(this.animationFrameHandler, this); - lassoTrail = new LassoTrail(this.animationFrameHandler, this); + laserTrails = new LaserTrails(this); + eraserTrail = new EraserTrail(this); + lassoTrail = new LassoTrail(this); onChangeEmitter = new Emitter< [ @@ -694,11 +739,56 @@ class App extends React.Component { >(); onRemoveEventListenersEmitter = new Emitter<[]>(); + api: ExcalidrawImperativeAPI; + + private createExcalidrawAPI(): ExcalidrawImperativeAPI { + const api: ExcalidrawImperativeAPI = { + isDestroyed: false, + updateScene: this.updateScene, + applyDeltas: this.applyDeltas, + mutateElement: this.mutateElement, + updateLibrary: this.library.updateLibrary, + addFiles: this.addFiles, + resetScene: this.resetScene, + getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted, + getSceneElementsMapIncludingDeleted: + this.getSceneElementsMapIncludingDeleted, + history: { + clear: this.resetHistory, + }, + scrollToContent: this.scrollToContent, + getSceneElements: this.getSceneElements, + getAppState: () => this.state, + getFiles: () => this.files, + getName: this.getName, + registerAction: (action: Action) => { + this.actionManager.registerAction(action); + }, + refresh: this.refresh, + setToast: this.setToast, + id: this.id, + setActiveTool: this.setActiveTool, + setCursor: this.setCursor, + resetCursor: this.resetCursor, + getEditorInterface: () => this.editorInterface, + updateFrameRendering: this.updateFrameRendering, + toggleSidebar: this.toggleSidebar, + onChange: (cb) => this.onChangeEmitter.on(cb), + onIncrement: (cb) => this.store.onStoreIncrementEmitter.on(cb), + onPointerDown: (cb) => this.onPointerDownEmitter.on(cb), + onPointerUp: (cb) => this.onPointerUpEmitter.on(cb), + onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb), + onUserFollow: (cb) => this.onUserFollowEmitter.on(cb), + onStateChange: this.onStateChange, + onEvent: this.onEvent, + }; + return api; + } + constructor(props: AppProps) { super(props); const defaultAppState = getDefaultAppState(); const { - excalidrawAPI, viewModeEnabled = false, zenModeEnabled = false, gridModeEnabled = false, @@ -706,9 +796,11 @@ class App extends React.Component { theme = defaultAppState.theme, name = `${t("labels.untitled")}-${getDateTime()}`, } = props; + this.state = { ...defaultAppState, theme, + exportWithDarkMode: theme === THEME.DARK, isLoading: true, ...this.getCanvasOffsets(), viewModeEnabled, @@ -741,51 +833,6 @@ class App extends React.Component { this.store = new Store(this); this.history = new History(this.store); - if (excalidrawAPI) { - const api: ExcalidrawImperativeAPI = { - updateScene: this.updateScene, - applyDeltas: this.applyDeltas, - mutateElement: this.mutateElement, - updateLibrary: this.library.updateLibrary, - addFiles: this.addFiles, - resetScene: this.resetScene, - getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted, - getSceneElementsMapIncludingDeleted: - this.getSceneElementsMapIncludingDeleted, - history: { - clear: this.resetHistory, - }, - scrollToContent: this.scrollToContent, - getSceneElements: this.getSceneElements, - getAppState: () => this.state, - getFiles: () => this.files, - getName: this.getName, - registerAction: (action: Action) => { - this.actionManager.registerAction(action); - }, - refresh: this.refresh, - setToast: this.setToast, - id: this.id, - setActiveTool: this.setActiveTool, - setCursor: this.setCursor, - resetCursor: this.resetCursor, - getEditorInterface: () => this.editorInterface, - updateFrameRendering: this.updateFrameRendering, - toggleSidebar: this.toggleSidebar, - onChange: (cb) => this.onChangeEmitter.on(cb), - onIncrement: (cb) => this.store.onStoreIncrementEmitter.on(cb), - onPointerDown: (cb) => this.onPointerDownEmitter.on(cb), - onPointerUp: (cb) => this.onPointerUpEmitter.on(cb), - onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb), - onUserFollow: (cb) => this.onUserFollowEmitter.on(cb), - } as const; - if (typeof excalidrawAPI === "function") { - excalidrawAPI(api); - } else { - console.error("excalidrawAPI should be a function!"); - } - } - this.excalidrawContainerValue = { container: this.excalidrawContainerRef.current, id: this.id, @@ -797,6 +844,12 @@ class App extends React.Component { this.actionManager.registerAll(actions); this.actionManager.registerAction(createUndoAction(this.history)); this.actionManager.registerAction(createRedoAction(this.history)); + + // in case internal editor APIs call this early, otherwise we need + // to construct this in componentDidMount because componentWillUnmount + // will invalidate it (so in StrictMode, doing this in constructor alone + // would be a problem) + this.api = this.createExcalidrawAPI(); } updateEditorAtom = ( @@ -1204,18 +1257,138 @@ class App extends React.Component { ) as NullableGridSize; }; + private getTextCreationGridPoint = (x: number, y: number) => { + const effectiveGridSize = this.getEffectiveGridSize(); + + if (effectiveGridSize === null) { + return null; + } + + const getTextCreationGridCoordinate = (coordinate: number) => { + const topLeftGridPoint = + Math.floor(coordinate / effectiveGridSize) * effectiveGridSize; + + return topLeftGridPoint; + }; + + return { + x: getTextCreationGridCoordinate(x), + y: getTextCreationGridCoordinate(y), + }; + }; + private getHTMLIFrameElement( element: ExcalidrawIframeLikeElement, ): HTMLIFrameElement | undefined { return this.iFrameRefs.get(element.id); } - private handleEmbeddableCenterClick(element: ExcalidrawIframeLikeElement) { + private handleIframeLikeElementHover = ({ + hitElement, + scenePointer, + moveEvent, + }: { + hitElement: NonDeleted | null; + scenePointer: { x: number; y: number }; + moveEvent: React.PointerEvent; + }): boolean => { if ( - this.state.activeEmbeddable?.element === element && + hitElement && + isIframeLikeElement(hitElement) && + (this.state.viewModeEnabled || + this.state.activeTool.type === "laser" || + this.isIframeLikeElementCenter( + hitElement, + moveEvent, + scenePointer.x, + scenePointer.y, + )) + ) { + setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); + this.setState({ + activeEmbeddable: { element: hitElement, state: "hover" }, + }); + return true; + } else if (this.state.activeEmbeddable?.state === "hover") { + this.setState({ activeEmbeddable: null }); + } + return false; + }; + + /** @returns true if iframe-like element click handled */ + private handleIframeLikeCenterClick(): boolean { + if ( + !this.lastPointerDownEvent || + !this.lastPointerUpEvent || + // middle-click or something other than primary + this.lastPointerDownEvent.button !== POINTER_BUTTON.MAIN || + // panning + isHoldingSpace || + // wrong tool + !oneOf(this.state.activeTool.type, ["laser", "selection", "lasso"]) + ) { + return false; + } + + const viewportClickStart_scenePoint = pointFrom( + viewportCoordsToSceneCoords( + { + clientX: this.lastPointerDownEvent.clientX, + clientY: this.lastPointerDownEvent.clientY, + }, + this.state, + ), + ); + const viewportClickEnd_scenePoint = pointFrom( + viewportCoordsToSceneCoords( + { + clientX: this.lastPointerUpEvent.clientX, + clientY: this.lastPointerUpEvent.clientY, + }, + this.state, + ), + ); + + const draggedDistance = pointDistance( + viewportClickStart_scenePoint, + viewportClickEnd_scenePoint, + ); + + if (draggedDistance > DRAGGING_THRESHOLD) { + return false; + } + + const hitElement = this.getElementAtPosition( + viewportClickStart_scenePoint[0], + viewportClickStart_scenePoint[1], + ); + + const shouldActivate = + hitElement && + this.lastPointerUpEvent.timeStamp - this.lastPointerDownEvent.timeStamp <= + 300 && + gesture.pointers.size < 2 && + isIframeLikeElement(hitElement) && + (this.state.viewModeEnabled || + this.state.activeTool.type === "laser" || + this.isIframeLikeElementCenter( + hitElement, + this.lastPointerUpEvent, + viewportClickEnd_scenePoint[0], + viewportClickEnd_scenePoint[1], + )); + + if (!shouldActivate) { + return false; + } + + const iframeLikeElement = hitElement; + + if ( + this.state.activeEmbeddable?.element === iframeLikeElement && this.state.activeEmbeddable?.state === "active" ) { - return; + return true; } // The delay serves two purposes @@ -1226,31 +1399,34 @@ class App extends React.Component { // in fullscreen mode setTimeout(() => { this.setState({ - activeEmbeddable: { element, state: "active" }, - selectedElementIds: { [element.id]: true }, + activeEmbeddable: { element: iframeLikeElement, state: "active" }, + selectedElementIds: { [iframeLikeElement.id]: true }, newElement: null, selectionElement: null, }); }, 100); - if (isIframeElement(element)) { - return; + if (isIframeElement(iframeLikeElement)) { + return true; } - const iframe = this.getHTMLIFrameElement(element); + const iframe = this.getHTMLIFrameElement(iframeLikeElement); if (!iframe?.contentWindow) { - return; + return true; } if (iframe.src.includes("youtube")) { - const state = YOUTUBE_VIDEO_STATES.get(element.id); + const state = YOUTUBE_VIDEO_STATES.get(iframeLikeElement.id); if (!state) { - YOUTUBE_VIDEO_STATES.set(element.id, YOUTUBE_STATES.UNSTARTED); + YOUTUBE_VIDEO_STATES.set( + iframeLikeElement.id, + YOUTUBE_STATES.UNSTARTED, + ); iframe.contentWindow.postMessage( JSON.stringify({ event: "listening", - id: element.id, + id: iframeLikeElement.id, }), "*", ); @@ -1287,8 +1463,25 @@ class App extends React.Component { "*", ); } + + return true; } + private isDoubleClick = ( + lastPointerEvent: + | PointerEvent + | React.PointerEvent + | undefined + | null, + currentPointerEvent: PointerEvent | React.PointerEvent, + ) => { + return ( + lastPointerEvent != null && + currentPointerEvent.timeStamp - lastPointerEvent.timeStamp <= + TAP_TWICE_TIMEOUT + ); + }; + private isIframeLikeElementCenter( el: ExcalidrawIframeLikeElement | null, event: React.PointerEvent | PointerEvent, @@ -1542,6 +1735,18 @@ class App extends React.Component { this.state.activeEmbeddable?.element === el && this.state.activeEmbeddable?.state === "hover"; + // scale video embeds based on zoom (capped) so that smaller embeds + // on canvas when zoomed are still of legible quality + // (note: for some embed types like gdrive, the quality is poor when + // scaling mid playback and works only when you initially start the + // playback at the higher zoom level) + const shouldScaleEmbeddableViewport = src?.type === "video"; + const embeddableViewportScale = clamp( + shouldScaleEmbeddableViewport ? scale : 1, + 0.75, + MAX_EMBEDDABLE_VIEWPORT_SCALE, + ); + return (
{ padding: `${el.strokeWidth}px`, }} > - {(isEmbeddableElement(el) - ? this.props.renderEmbeddable?.(el, this.state) - : null) ?? ( -