Compare commits
67 Commits
dwelle/oxc
...
release
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a82821ec5 | |||
| 070df27e4d | |||
| cd514d72d6 | |||
| 0642e72cfa | |||
| 28a9b1711d | |||
| 1cb9fff569 | |||
| 069982606d | |||
| b324a85ab1 | |||
| a83ac48853 | |||
| 0cf56b19c7 | |||
| 61fe15a51d | |||
| 647a264a48 | |||
| b6d80e4256 | |||
| 3372149277 | |||
| c08be69618 | |||
| b42b1a193d | |||
| f6d85bc80f | |||
| 0457ac9063 | |||
| b2b2815954 | |||
| d992c10bc1 | |||
| 091b9053a3 | |||
| 97274a74b2 | |||
| c59fb8dcbc | |||
| 7f56cc0cf3 | |||
| 974b338b7e | |||
| d2557474e2 | |||
| 3004c642da | |||
| 2dfcc6f0ce | |||
| 3f5fdec04e | |||
| 278cd35772 | |||
| 43fa4b5602 | |||
| 2e1a529c67 | |||
| b1c6bfcf40 | |||
| 1caec99b29 | |||
| e18c1dd213 | |||
| d9e8a33aa4 | |||
| 4be4cc0ed0 | |||
| 4a5c9e990c | |||
| c09e170bdd | |||
| c1082923ee | |||
| 1c292e4936 | |||
| d6f0f34fe9 | |||
| 75789f620d | |||
| a9ca16eb42 | |||
| 987173b52f | |||
| 81ab857a6f | |||
| e8b4620a96 | |||
| 2b0e4c9623 | |||
| c9ba7f839c | |||
| b4ce7c713b | |||
| 816c81c12e | |||
| 92d25446d6 | |||
| e73a5b0116 | |||
| 21dd1cfacc | |||
| fa1f7d9f22 | |||
| 3d8c12fba4 | |||
| 757dfeb6ad | |||
| a0e93b6040 | |||
| 499e9d64a5 | |||
| c1dbbdf678 | |||
| 47c254216b | |||
| d1cff91b75 | |||
| 437595fa65 | |||
| 60b275880d | |||
| cae9d2bcbd | |||
| 2874f9e48c | |||
| 0b3a5e7cc4 |
@@ -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.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# VITE_DEBUG_DOM
|
||||
# When "true", testing-library failures (waitFor / getBy*) include the full
|
||||
# serialized DOM in the error message. It's off by default because it's noisy.
|
||||
#
|
||||
# Flip it to "true" (or use `VITE_DEBUG_DOM=true yarn test`) when you need to
|
||||
# inspect the DOM of a failing test.
|
||||
VITE_DEBUG_DOM=false
|
||||
+22
-1
@@ -39,5 +39,26 @@
|
||||
"allowReferrer": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["packages/excalidraw/**/*.{ts,tsx}"],
|
||||
"excludedFiles": ["packages/excalidraw/**/*.test.{ts,tsx}", "packages/excalidraw/**/*.test.*.{ts,tsx}"],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["@excalidraw/excalidraw"],
|
||||
"message": "Do not import from the barrel 'index.tsx' files. Use direct relative imports to the specific module instead.",
|
||||
"allowTypeImports": true
|
||||
}
|
||||
],
|
||||
"paths": [".", "..", "../..", "../../..", "../../../..", "../../../../..", "../index", "../../index", "../../../index", "../../../../index"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 .
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -8,9 +8,9 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
- name: Install and test
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<a href="https://docs.excalidraw.com/docs/introduction/contributing">
|
||||
<img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" /></a>
|
||||
<a href="https://discord.gg/UexuTaE">
|
||||
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/></a>
|
||||
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widget=false"/></a>
|
||||
<a href="https://deepwiki.com/excalidraw/excalidraw">
|
||||
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" /></a>
|
||||
<a href="https://twitter.com/excalidraw">
|
||||
|
||||
@@ -172,7 +172,7 @@ convertToExcalidrawElements([
|
||||
type: "arrow",
|
||||
x: 450,
|
||||
y: 20,
|
||||
startArrowhead: "dot",
|
||||
startArrowhead: "circle",
|
||||
endArrowhead: "triangle",
|
||||
strokeColor: "#1971c2",
|
||||
strokeWidth: 2,
|
||||
|
||||
@@ -97,8 +97,8 @@ const config = {
|
||||
href: "https://discord.gg/UexuTaE",
|
||||
},
|
||||
{
|
||||
label: "Twitter",
|
||||
href: "https://twitter.com/excalidraw",
|
||||
label: "𝕏",
|
||||
href: "https://x.com/excalidraw",
|
||||
},
|
||||
{
|
||||
label: "Linkedin",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/data/transform";
|
||||
import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/element/transform";
|
||||
import type { FileId } from "@excalidraw/excalidraw/element/types";
|
||||
|
||||
const elements: ExcalidrawElementSkeleton[] = [
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"@excalidraw/excalidraw": "*",
|
||||
"browser-fs-access": "0.29.1"
|
||||
"browser-fs-access": "0.38.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "5.0.12",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vite": "5.0.12"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
|
||||
@@ -4,8 +4,6 @@ import { unstable_batchedUpdates } from "react-dom";
|
||||
|
||||
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
|
||||
|
||||
const INPUT_CHANGE_INTERVAL_MS = 500;
|
||||
|
||||
export type ResolvablePromise<T> = Promise<T> & {
|
||||
resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void;
|
||||
reject: (error: Error) => void;
|
||||
@@ -54,40 +52,6 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
||||
extensions,
|
||||
mimeTypes,
|
||||
multiple: opts.multiple ?? false,
|
||||
legacySetup: (resolve, reject, input) => {
|
||||
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
|
||||
const focusHandler = () => {
|
||||
checkForFile();
|
||||
document.addEventListener("keyup", scheduleRejection);
|
||||
document.addEventListener("pointerup", scheduleRejection);
|
||||
scheduleRejection();
|
||||
};
|
||||
const checkForFile = () => {
|
||||
// this hack might not work when expecting multiple files
|
||||
if (input.files?.length) {
|
||||
const ret = opts.multiple ? [...input.files] : input.files[0];
|
||||
resolve(ret as RetType);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(() => {
|
||||
window.addEventListener("focus", focusHandler);
|
||||
});
|
||||
const interval = window.setInterval(() => {
|
||||
checkForFile();
|
||||
}, INPUT_CHANGE_INTERVAL_MS);
|
||||
return (rejectPromise) => {
|
||||
clearInterval(interval);
|
||||
scheduleRejection.cancel();
|
||||
window.removeEventListener("focus", focusHandler);
|
||||
document.removeEventListener("keyup", scheduleRejection);
|
||||
document.removeEventListener("pointerup", scheduleRejection);
|
||||
if (rejectPromise) {
|
||||
// so that something is shown in console if we need to debug this
|
||||
console.warn("Opening the file was canceled (legacy-fs).");
|
||||
rejectPromise(new Error("Request Aborted"));
|
||||
}
|
||||
};
|
||||
},
|
||||
}) as Promise<RetType>;
|
||||
};
|
||||
|
||||
|
||||
+93
-30
@@ -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";
|
||||
@@ -20,7 +22,6 @@ import Trans from "@excalidraw/excalidraw/components/Trans";
|
||||
import {
|
||||
APP_NAME,
|
||||
EVENT,
|
||||
THEME,
|
||||
VERSION_TIMEOUT,
|
||||
debounce,
|
||||
getVersion,
|
||||
@@ -34,7 +35,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 +74,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 +115,7 @@ import {
|
||||
} from "./data";
|
||||
|
||||
import { updateStaleImageStatuses } from "./data/FileManager";
|
||||
import { FileStatusStore } from "./data/fileStatusStore";
|
||||
import {
|
||||
importFromLocalStorage,
|
||||
importUsernameFromLocalStorage,
|
||||
@@ -369,6 +371,8 @@ const initializeScene = async (opts: {
|
||||
};
|
||||
|
||||
const ExcalidrawWrapper = () => {
|
||||
const excalidrawAPI = useExcalidrawAPI();
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const isCollabDisabled = isRunningInIframe();
|
||||
|
||||
@@ -399,9 +403,6 @@ const ExcalidrawWrapper = () => {
|
||||
}, VERSION_TIMEOUT);
|
||||
}, []);
|
||||
|
||||
const [excalidrawAPI, excalidrawRefCallback] =
|
||||
useCallbackRefState<ExcalidrawImperativeAPI>();
|
||||
|
||||
const [, setShareDialogState] = useAtom(shareDialogStateAtom);
|
||||
const [collabAPI] = useAtom(collabAPIAtom);
|
||||
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
|
||||
@@ -433,18 +434,15 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
}, [excalidrawAPI]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadImages = (
|
||||
data: ResolutionType<typeof initializeScene>,
|
||||
isInitialLoad = false,
|
||||
) => {
|
||||
if (!data.scene) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoisted loadImages
|
||||
// ---------------------------------------------------------------------------
|
||||
const loadImages = useCallback(
|
||||
(data: ResolutionType<typeof initializeScene>, isInitialLoad = false) => {
|
||||
if (!data.scene || !excalidrawAPI) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (collabAPI?.isCollaborating()) {
|
||||
if (data.scene.elements) {
|
||||
collabAPI
|
||||
@@ -471,6 +469,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 +486,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 +510,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 +647,7 @@ const ExcalidrawWrapper = () => {
|
||||
false,
|
||||
);
|
||||
};
|
||||
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
||||
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode, loadImages]);
|
||||
|
||||
useEffect(() => {
|
||||
const unloadHandler = (event: BeforeUnloadEvent) => {
|
||||
@@ -773,6 +792,56 @@ const ExcalidrawWrapper = () => {
|
||||
[setShareDialogState],
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// onExport — intercepts file save to wait for pending image loads
|
||||
// ---------------------------------------------------------------------------
|
||||
const onExport: Required<ExcalidrawProps>["onExport"] = useCallback(
|
||||
async function* () {
|
||||
let snapshot = FileStatusStore.getSnapshot();
|
||||
const { pending, total } = FileStatusStore.getPendingCount(
|
||||
snapshot.value,
|
||||
);
|
||||
if (pending === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Yield initial progress
|
||||
yield {
|
||||
type: "progress",
|
||||
progress: (total - pending) / total,
|
||||
message: `Loading images (${total - pending}/${total})...`,
|
||||
};
|
||||
|
||||
// Wait for all pending images to finish
|
||||
while (true) {
|
||||
snapshot = await FileStatusStore.pull(snapshot.version);
|
||||
const { pending: nowPending, total: nowTotal } =
|
||||
FileStatusStore.getPendingCount(snapshot.value);
|
||||
|
||||
yield {
|
||||
type: "progress",
|
||||
progress: (nowTotal - nowPending) / nowTotal,
|
||||
message: `Loading images (${nowTotal - nowPending}/${nowTotal})...`,
|
||||
};
|
||||
|
||||
if (nowPending === 0) {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
yield {
|
||||
type: "progress",
|
||||
message: `Preparing export...`,
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// const onExport = () => {
|
||||
// return new Promise((r) => setTimeout(r, 2500));
|
||||
// // console.log("onExport");
|
||||
// };
|
||||
|
||||
// browsers generally prevent infinite self-embedding, there are
|
||||
// cases where it still happens, and while we disallow self-embedding
|
||||
// by not whitelisting our own origin, this serves as an additional guard
|
||||
@@ -839,8 +908,8 @@ const ExcalidrawWrapper = () => {
|
||||
})}
|
||||
>
|
||||
<Excalidraw
|
||||
excalidrawAPI={excalidrawRefCallback}
|
||||
onChange={onChange}
|
||||
onExport={onExport}
|
||||
initialData={initialStatePromiseRef.current.promise}
|
||||
isCollaborating={isCollaborating}
|
||||
onPointerUpdate={collabAPI?.onPointerUpdate}
|
||||
@@ -882,6 +951,7 @@ const ExcalidrawWrapper = () => {
|
||||
handleKeyboardGlobally={true}
|
||||
autoFocus={true}
|
||||
theme={editorTheme}
|
||||
onThemeChange={setAppTheme}
|
||||
renderTopRightUI={(isMobile) => {
|
||||
if (isMobile || !collabAPI || isCollabDisabled) {
|
||||
return null;
|
||||
@@ -918,7 +988,6 @@ const ExcalidrawWrapper = () => {
|
||||
isCollaborating={isCollaborating}
|
||||
isCollabEnabled={!isCollabDisabled}
|
||||
theme={appTheme}
|
||||
setTheme={(theme) => setAppTheme(theme)}
|
||||
refresh={() => forceRefresh((prev) => !prev)}
|
||||
/>
|
||||
<AppWelcomeScreen
|
||||
@@ -1159,14 +1228,6 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
...CommandPalette.defaultItems.toggleTheme,
|
||||
perform: () => {
|
||||
setAppTheme(
|
||||
editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("labels.installPWA"),
|
||||
category: DEFAULT_CATEGORIES.app,
|
||||
@@ -1206,7 +1267,9 @@ const ExcalidrawApp = () => {
|
||||
return (
|
||||
<TopErrorBoundary>
|
||||
<Provider store={appJotaiStore}>
|
||||
<ExcalidrawWrapper />
|
||||
<ExcalidrawAPIProvider>
|
||||
<ExcalidrawWrapper />
|
||||
</ExcalidrawAPIProvider>
|
||||
</Provider>
|
||||
</TopErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
FileManager,
|
||||
updateStaleImageStatuses,
|
||||
} from "../data/FileManager";
|
||||
import { FileStatusStore } from "../data/fileStatusStore";
|
||||
import { LocalData } from "../data/LocalData";
|
||||
import {
|
||||
isSavedToFirebase,
|
||||
@@ -149,6 +150,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
};
|
||||
this.portal = new Portal(this);
|
||||
this.fileManager = new FileManager({
|
||||
onFileStatusChange: FileStatusStore.updateStatuses.bind(FileStatusStore),
|
||||
getFiles: async (fileIds) => {
|
||||
const { roomId, roomKey } = this.portal;
|
||||
if (!roomId || !roomKey) {
|
||||
|
||||
@@ -20,7 +20,6 @@ export const AppMainMenu: React.FC<{
|
||||
isCollaborating: boolean;
|
||||
isCollabEnabled: boolean;
|
||||
theme: Theme | "system";
|
||||
setTheme: (theme: Theme | "system") => void;
|
||||
refresh: () => void;
|
||||
}> = React.memo((props) => {
|
||||
return (
|
||||
@@ -78,11 +77,7 @@ export const AppMainMenu: React.FC<{
|
||||
)}
|
||||
<MainMenu.Separator />
|
||||
<MainMenu.DefaultItems.Preferences />
|
||||
<MainMenu.DefaultItems.ToggleTheme
|
||||
allowSystemTheme
|
||||
theme={props.theme}
|
||||
onSelect={props.setTheme}
|
||||
/>
|
||||
<MainMenu.DefaultItems.ToggleTheme allowSystemTheme theme={props.theme} />
|
||||
<MainMenu.ItemCustom>
|
||||
<LanguageList style={{ width: "100%" }} />
|
||||
</MainMenu.ItemCustom>
|
||||
|
||||
@@ -414,7 +414,6 @@ export const debugRenderer = throttleRAF(
|
||||
) => {
|
||||
_debugRenderer(canvas, appState, elements, scale);
|
||||
},
|
||||
{ trailing: true },
|
||||
);
|
||||
|
||||
export const loadSavedDebugState = () => {
|
||||
|
||||
@@ -40,10 +40,12 @@ export class FileManager {
|
||||
|
||||
private _getFiles;
|
||||
private _saveFiles;
|
||||
private _onFileStatusChange;
|
||||
|
||||
constructor({
|
||||
getFiles,
|
||||
saveFiles,
|
||||
onFileStatusChange,
|
||||
}: {
|
||||
getFiles: (fileIds: FileId[]) => Promise<{
|
||||
loadedFiles: BinaryFileData[];
|
||||
@@ -53,9 +55,13 @@ export class FileManager {
|
||||
savedFiles: Map<FileId, BinaryFileData>;
|
||||
erroredFiles: Map<FileId, BinaryFileData>;
|
||||
}>;
|
||||
onFileStatusChange?: (
|
||||
updates: Array<[FileId, "loading" | "loaded" | "error"]>,
|
||||
) => void;
|
||||
}) {
|
||||
this._getFiles = getFiles;
|
||||
this._saveFiles = saveFiles;
|
||||
this._onFileStatusChange = onFileStatusChange;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,6 +152,8 @@ export class FileManager {
|
||||
this.fetchingFiles.set(id, true);
|
||||
}
|
||||
|
||||
this._onFileStatusChange?.(ids.map((id) => [id, "loading"]));
|
||||
|
||||
try {
|
||||
const { loadedFiles, erroredFiles } = await this._getFiles(ids);
|
||||
|
||||
@@ -156,6 +164,13 @@ export class FileManager {
|
||||
this.erroredFiles_fetch.set(fileId, true);
|
||||
}
|
||||
|
||||
this._onFileStatusChange?.([
|
||||
...loadedFiles.map((f) => [f.id, "loaded"] as [FileId, "loaded"]),
|
||||
...[...erroredFiles.keys()].map(
|
||||
(id) => [id, "error"] as [FileId, "error"],
|
||||
),
|
||||
]);
|
||||
|
||||
return { loadedFiles, erroredFiles };
|
||||
} finally {
|
||||
for (const id of ids) {
|
||||
@@ -195,6 +210,13 @@ export class FileManager {
|
||||
};
|
||||
|
||||
reset() {
|
||||
if (this._onFileStatusChange && this.fetchingFiles.size) {
|
||||
this._onFileStatusChange(
|
||||
[...this.fetchingFiles.keys()].map(
|
||||
(id) => [id, "error"] as [FileId, "error"],
|
||||
),
|
||||
);
|
||||
}
|
||||
this.fetchingFiles.clear();
|
||||
this.savingFiles.clear();
|
||||
this.savedFiles.clear();
|
||||
|
||||
@@ -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)[]) => {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { VersionedSnapshotStore } from "@excalidraw/common";
|
||||
|
||||
import type { FileId } from "@excalidraw/element/types";
|
||||
|
||||
export type FileLoadingStatus = "loading" | "loaded" | "error";
|
||||
|
||||
export class FileStatusStore {
|
||||
private static store = new VersionedSnapshotStore<
|
||||
Map<FileId, FileLoadingStatus>
|
||||
>(new Map());
|
||||
|
||||
static getSnapshot() {
|
||||
return this.store.getSnapshot();
|
||||
}
|
||||
|
||||
static pull(sinceVersion?: number) {
|
||||
return this.store.pull(sinceVersion);
|
||||
}
|
||||
|
||||
static updateStatuses(updates: Array<[FileId, FileLoadingStatus]>) {
|
||||
if (!updates.length) {
|
||||
return;
|
||||
}
|
||||
this.store.update((prev) => {
|
||||
let changed = false;
|
||||
const next = new Map(prev);
|
||||
for (const [id, status] of updates) {
|
||||
if (next.get(id) !== status) {
|
||||
next.set(id, status);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}
|
||||
|
||||
static getPendingCount(statuses: Map<FileId, FileLoadingStatus>) {
|
||||
let pending = 0;
|
||||
let total = 0;
|
||||
for (const status of statuses.values()) {
|
||||
total++;
|
||||
if (status === "loading") {
|
||||
pending++;
|
||||
}
|
||||
}
|
||||
return { pending, total };
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { THEME } from "@excalidraw/excalidraw";
|
||||
import { EVENT, CODES, KEYS } from "@excalidraw/common";
|
||||
import { useEffect, useLayoutEffect, useState } from "react";
|
||||
|
||||
import type { Theme } from "@excalidraw/element/types";
|
||||
@@ -31,28 +30,10 @@ export const useHandleAppTheme = () => {
|
||||
mediaQuery?.addEventListener("change", handleChange);
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
!event[KEYS.CTRL_OR_CMD] &&
|
||||
event.altKey &&
|
||||
event.shiftKey &&
|
||||
event.code === CODES.D
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
setAppTheme(editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener(EVENT.KEYDOWN, handleKeydown, { capture: true });
|
||||
|
||||
return () => {
|
||||
mediaQuery?.removeEventListener("change", handleChange);
|
||||
document.removeEventListener(EVENT.KEYDOWN, handleKeydown, {
|
||||
capture: true,
|
||||
});
|
||||
};
|
||||
}, [appTheme, editorTheme, setAppTheme]);
|
||||
}, [appTheme]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, appTheme);
|
||||
|
||||
@@ -75,6 +75,20 @@ 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",
|
||||
),
|
||||
},
|
||||
{
|
||||
find: /^@excalidraw\/laser-pointer$/,
|
||||
replacement: path.resolve(
|
||||
__dirname,
|
||||
"../packages/laser-pointer/src/index.ts",
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
build: {
|
||||
@@ -106,6 +120,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 +168,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 +212,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: new RegExp(".chunk-.+.js"),
|
||||
urlPattern: new RegExp("(.chunk-.+|CodeMirrorEditor-.+)\\.js"),
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "chunk",
|
||||
|
||||
+3
-1
@@ -56,7 +56,9 @@
|
||||
"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:laser-pointer": "yarn --cwd ./packages/laser-pointer build:esm",
|
||||
"build:packages": "yarn build:common && yarn build:fractional-indexing && yarn build:laser-pointer && 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",
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { AppEventBus } from "./appEventBus";
|
||||
|
||||
type TestEvents = {
|
||||
initialize: [api: number];
|
||||
pointerUp: [pointerId: string];
|
||||
viewState: [zoom: number];
|
||||
};
|
||||
|
||||
const behavior = {
|
||||
initialize: { cardinality: "once", replay: "last" },
|
||||
pointerUp: { cardinality: "many", replay: "none" },
|
||||
viewState: { cardinality: "many", replay: "last" },
|
||||
} as const;
|
||||
|
||||
const flushMicrotasks = async () => Promise.resolve();
|
||||
|
||||
describe("AppEventBus", () => {
|
||||
it("replays once events to late callback and Promise subscribers", async () => {
|
||||
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
|
||||
bus.emit("initialize", 42);
|
||||
|
||||
const calls: number[] = [];
|
||||
bus.on("initialize", (value) => {
|
||||
calls.push(value);
|
||||
});
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
await flushMicrotasks();
|
||||
expect(calls).toEqual([42]);
|
||||
|
||||
await expect(bus.on("initialize")).resolves.toBe(42);
|
||||
});
|
||||
|
||||
it("does not replay stream events to late subscribers", async () => {
|
||||
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
|
||||
bus.emit("pointerUp", "first");
|
||||
|
||||
const calls: string[] = [];
|
||||
bus.on("pointerUp", (pointerId) => {
|
||||
calls.push(pointerId);
|
||||
});
|
||||
|
||||
await flushMicrotasks();
|
||||
expect(calls).toEqual([]);
|
||||
|
||||
bus.emit("pointerUp", "second");
|
||||
expect(calls).toEqual(["second"]);
|
||||
});
|
||||
|
||||
it("replays replay-last stream events and stays subscribed", async () => {
|
||||
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
|
||||
bus.emit("viewState", 1);
|
||||
|
||||
const calls: number[] = [];
|
||||
bus.on("viewState", (zoom) => {
|
||||
calls.push(zoom);
|
||||
});
|
||||
|
||||
await flushMicrotasks();
|
||||
expect(calls).toEqual([1]);
|
||||
|
||||
bus.emit("viewState", 2);
|
||||
expect(calls).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("throws when emitting a once event twice", () => {
|
||||
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
|
||||
bus.emit("initialize", 1);
|
||||
|
||||
expect(() => {
|
||||
bus.emit("initialize", 2);
|
||||
}).toThrow('Event "initialize" can only be emitted once');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { UnsubscribeCallback } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { Emitter } from "./emitter";
|
||||
import { isProdEnv } from "./utils";
|
||||
|
||||
export type AppEventPayloadMap = Record<string, unknown[]>;
|
||||
|
||||
export type AppEventBehavior = {
|
||||
cardinality: "once" | "many";
|
||||
replay: "none" | "last";
|
||||
};
|
||||
|
||||
export type AppEventBehaviorMap<Events extends AppEventPayloadMap> = {
|
||||
[K in keyof Events]: AppEventBehavior;
|
||||
};
|
||||
|
||||
type AwaitableAppEventKeys<
|
||||
Events extends AppEventPayloadMap,
|
||||
Behavior extends AppEventBehaviorMap<Events>,
|
||||
> = {
|
||||
[K in keyof Events]: Behavior[K]["cardinality"] extends "once"
|
||||
? Behavior[K]["replay"] extends "last"
|
||||
? K
|
||||
: never
|
||||
: never;
|
||||
}[keyof Events];
|
||||
|
||||
type AppEventPromiseValue<Args extends any[]> = Args extends [infer Only]
|
||||
? Only
|
||||
: Args;
|
||||
|
||||
export class AppEventBus<
|
||||
Events extends AppEventPayloadMap,
|
||||
Behavior extends AppEventBehaviorMap<Events>,
|
||||
> {
|
||||
private readonly emitters = new Map<keyof Events, Emitter<any>>();
|
||||
private readonly lastPayload = new Map<keyof Events, any[]>();
|
||||
private readonly emittedOnce = new Set<keyof Events>();
|
||||
|
||||
constructor(private readonly behavior: Behavior) {}
|
||||
|
||||
private getEmitter<K extends keyof Events>(name: K): Emitter<Events[K]> {
|
||||
let emitter = this.emitters.get(name);
|
||||
if (!emitter) {
|
||||
emitter = new Emitter<any>();
|
||||
this.emitters.set(name, emitter);
|
||||
}
|
||||
return emitter as Emitter<Events[K]>;
|
||||
}
|
||||
|
||||
private toPromiseValue<Args extends any[]>(
|
||||
args: Args,
|
||||
): AppEventPromiseValue<Args> {
|
||||
return (args.length === 1 ? args[0] : args) as AppEventPromiseValue<Args>;
|
||||
}
|
||||
|
||||
public on<K extends keyof Events>(
|
||||
name: K,
|
||||
callback: (...args: Events[K]) => void,
|
||||
): UnsubscribeCallback;
|
||||
public on<K extends AwaitableAppEventKeys<Events, Behavior>>(
|
||||
name: K,
|
||||
): Promise<AppEventPromiseValue<Events[K]>>;
|
||||
public on<K extends keyof Events>(
|
||||
name: K,
|
||||
callback?: (...args: Events[K]) => void,
|
||||
): UnsubscribeCallback | Promise<AppEventPromiseValue<Events[K]>> {
|
||||
const eventBehavior = this.behavior[name];
|
||||
const cachedPayload = this.lastPayload.get(name) as Events[K] | undefined;
|
||||
|
||||
if (callback) {
|
||||
if (eventBehavior.replay === "last" && cachedPayload) {
|
||||
queueMicrotask(() => callback(...cachedPayload));
|
||||
|
||||
if (eventBehavior.cardinality === "once") {
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
return this.getEmitter(name).on(callback);
|
||||
}
|
||||
|
||||
if (
|
||||
eventBehavior.cardinality !== "once" ||
|
||||
eventBehavior.replay !== "last"
|
||||
) {
|
||||
throw new Error(`Event "${String(name)}" requires a callback`);
|
||||
}
|
||||
|
||||
if (cachedPayload) {
|
||||
return Promise.resolve(this.toPromiseValue(cachedPayload));
|
||||
}
|
||||
|
||||
return new Promise<AppEventPromiseValue<Events[K]>>((resolve) => {
|
||||
this.getEmitter(name).once((...args: Events[K]) => {
|
||||
resolve(this.toPromiseValue(args));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public emit<K extends keyof Events>(name: K, ...args: Events[K]) {
|
||||
const eventBehavior = this.behavior[name];
|
||||
|
||||
if (!isProdEnv()) {
|
||||
if (eventBehavior.cardinality === "once") {
|
||||
if (this.emittedOnce.has(name)) {
|
||||
throw new Error(`Event "${String(name)}" can only be emitted once`);
|
||||
}
|
||||
this.emittedOnce.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (eventBehavior.replay === "last") {
|
||||
this.lastPayload.set(name, args);
|
||||
}
|
||||
|
||||
try {
|
||||
this.getEmitter(name).trigger(...args);
|
||||
} finally {
|
||||
if (eventBehavior.cardinality === "once") {
|
||||
this.getEmitter(name).clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public clear() {
|
||||
this.lastPayload.clear();
|
||||
this.emittedOnce.clear();
|
||||
|
||||
for (const emitter of this.emitters.values()) {
|
||||
emitter.clear();
|
||||
}
|
||||
|
||||
this.emitters.clear();
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,11 @@ const cssInvert = (
|
||||
return { r: invertedR, g: invertedG, b: invertedB };
|
||||
};
|
||||
|
||||
export const applyDarkModeFilter = (color: string): string => {
|
||||
export const applyDarkModeFilter = (color: string, enable = true): string => {
|
||||
if (!enable) {
|
||||
return color;
|
||||
}
|
||||
|
||||
const cached = DARK_MODE_COLORS_CACHE?.get(color);
|
||||
if (cached) {
|
||||
return cached;
|
||||
@@ -240,22 +244,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
|
||||
|
||||
@@ -337,9 +337,10 @@ export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
|
||||
export const EXPORT_SCALES = [1, 2, 3];
|
||||
export const DEFAULT_EXPORT_PADDING = 10; // px
|
||||
|
||||
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
|
||||
|
||||
export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024;
|
||||
export const DEFAULT_IMAGE_OPTIONS: AppProps["imageOptions"] = {
|
||||
maxWidthOrHeight: 1440,
|
||||
maxFileSizeBytes: 4 * 1024 * 1024,
|
||||
};
|
||||
|
||||
export const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
export const SVG_DOCUMENT_PREAMBLE = `<?xml version="1.0" standalone="no"?>
|
||||
@@ -403,11 +404,47 @@ export const ROUGHNESS = {
|
||||
cartoonist: 2,
|
||||
} as const;
|
||||
|
||||
export const STROKE_WIDTH = {
|
||||
export type StrokeWidthKey = "thin" | "medium" | "bold";
|
||||
|
||||
export const STROKE_WIDTH_KEYS: readonly StrokeWidthKey[] = [
|
||||
"thin",
|
||||
"medium",
|
||||
"bold",
|
||||
];
|
||||
|
||||
export const STROKE_WIDTH: Readonly<
|
||||
Record<StrokeWidthKey | "extraBold", ExcalidrawElement["strokeWidth"]>
|
||||
> = {
|
||||
thin: 1,
|
||||
medium: 2,
|
||||
bold: 4,
|
||||
extraBold: 8, // unused (may be introduced in the future)
|
||||
};
|
||||
|
||||
// freedraw schema 2.0 uses thinner stroke, but to maintain backwards and
|
||||
// forwards compatibility, instead of changing the shape renderer, we scale
|
||||
// the stroke width by 1/2 (previous, thin was 1, medium 2 etc.)
|
||||
//
|
||||
// note that in the UI, STROKE_WIDTH.thin == FREEDRAW_STROKE_WIDTH.thin still
|
||||
export const FREEDRAW_STROKE_WIDTH: Readonly<
|
||||
Record<StrokeWidthKey | "extraBold", ExcalidrawElement["strokeWidth"]>
|
||||
> = {
|
||||
thin: 0.5,
|
||||
medium: 1,
|
||||
bold: 2,
|
||||
extraBold: 4,
|
||||
} as const;
|
||||
extraBold: 4, // legacy (may be used again in the future)
|
||||
};
|
||||
|
||||
export const getStrokeWidthByKey = (
|
||||
elementType: ExcalidrawElement["type"],
|
||||
strokeWidthKey: StrokeWidthKey,
|
||||
): ExcalidrawElement["strokeWidth"] => {
|
||||
return elementType === "freedraw"
|
||||
? FREEDRAW_STROKE_WIDTH[strokeWidthKey]
|
||||
: STROKE_WIDTH[strokeWidthKey];
|
||||
};
|
||||
|
||||
export const DEFAULT_ELEMENT_STROKE_WIDTH_KEY: StrokeWidthKey = "medium";
|
||||
|
||||
export const DEFAULT_ELEMENT_PROPS: {
|
||||
strokeColor: ExcalidrawElement["strokeColor"];
|
||||
@@ -422,7 +459,7 @@ export const DEFAULT_ELEMENT_PROPS: {
|
||||
strokeColor: COLOR_PALETTE.black,
|
||||
backgroundColor: COLOR_PALETTE.transparent,
|
||||
fillStyle: "solid",
|
||||
strokeWidth: 2,
|
||||
strokeWidth: STROKE_WIDTH[DEFAULT_ELEMENT_STROKE_WIDTH_KEY],
|
||||
strokeStyle: "solid",
|
||||
roughness: ROUGHNESS.artist,
|
||||
opacity: 100,
|
||||
@@ -513,3 +550,6 @@ export const BIND_MODE_TIMEOUT = 700; // ms
|
||||
export const MOBILE_ACTION_BUTTON_BG = {
|
||||
background: "var(--mobile-action-button-bg)",
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_STROKE_STREAMLINE = 0.5;
|
||||
export const DEFAULT_STROKE_STREAMLINE_PRECISE = 0.2;
|
||||
|
||||
@@ -11,5 +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";
|
||||
|
||||
@@ -3,6 +3,12 @@ import {
|
||||
mapFind,
|
||||
reduceToCommonValue,
|
||||
} from "@excalidraw/common";
|
||||
import { vi } from "vitest";
|
||||
|
||||
// Import directly to avoid the @excalidraw/common throttleRAF mock from setupTests.ts.
|
||||
import { throttleRAF } from "./utils";
|
||||
|
||||
type RafCallback = FrameRequestCallback;
|
||||
|
||||
describe("@excalidraw/common/utils", () => {
|
||||
describe("isTransparent()", () => {
|
||||
@@ -79,4 +85,87 @@ describe("@excalidraw/common/utils", () => {
|
||||
expect(mapFind([1, 2], () => null)).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("throttleRAF()", () => {
|
||||
let frameCallbacks: Map<number, RafCallback>;
|
||||
let nextFrameId: number;
|
||||
|
||||
const runScheduledFrame = (timestamp = 16) => {
|
||||
const callbacks = [...frameCallbacks.values()];
|
||||
frameCallbacks.clear();
|
||||
callbacks.forEach((callback) => callback(timestamp));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
frameCallbacks = new Map();
|
||||
nextFrameId = 0;
|
||||
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation(
|
||||
(callback) => {
|
||||
const frameId = ++nextFrameId;
|
||||
frameCallbacks.set(frameId, callback);
|
||||
return frameId;
|
||||
},
|
||||
);
|
||||
|
||||
vi.spyOn(window, "cancelAnimationFrame").mockImplementation((frameId) => {
|
||||
frameCallbacks.delete(frameId);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should invoke the callback with the last args from the same frame", () => {
|
||||
const fn = vi.fn();
|
||||
const throttled = throttleRAF(fn);
|
||||
|
||||
throttled("first", 1);
|
||||
throttled("second", 2);
|
||||
throttled("last", 3);
|
||||
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1);
|
||||
|
||||
runScheduledFrame();
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(fn).toHaveBeenCalledWith("last", 3);
|
||||
});
|
||||
|
||||
it("should flush the pending callback immediately", () => {
|
||||
const fn = vi.fn();
|
||||
const throttled = throttleRAF(fn);
|
||||
|
||||
throttled("first");
|
||||
throttled("last");
|
||||
|
||||
throttled.flush();
|
||||
|
||||
expect(window.cancelAnimationFrame).toHaveBeenCalledTimes(1);
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(fn).toHaveBeenCalledWith("last");
|
||||
|
||||
runScheduledFrame();
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should cancel the pending callback", () => {
|
||||
const fn = vi.fn();
|
||||
const throttled = throttleRAF(fn);
|
||||
|
||||
throttled("first");
|
||||
throttled("last");
|
||||
|
||||
throttled.cancel();
|
||||
|
||||
expect(window.cancelAnimationFrame).toHaveBeenCalledTimes(1);
|
||||
|
||||
runScheduledFrame();
|
||||
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,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,
|
||||
@@ -150,38 +151,27 @@ export const debounce = <T extends any[]>(
|
||||
return ret;
|
||||
};
|
||||
|
||||
// throttle callback to execute once per animation frame
|
||||
export const throttleRAF = <T extends any[]>(
|
||||
fn: (...args: T) => void,
|
||||
opts?: { trailing?: boolean },
|
||||
) => {
|
||||
// throttle callback to execute once per animation frame using the latest args
|
||||
export const throttleRAF = <T extends any[]>(fn: (...args: T) => void) => {
|
||||
let timerId: number | null = null;
|
||||
let lastArgs: T | null = null;
|
||||
let lastArgsTrailing: T | null = null;
|
||||
|
||||
const scheduleFunc = (args: T) => {
|
||||
const scheduleFunc = () => {
|
||||
timerId = window.requestAnimationFrame(() => {
|
||||
timerId = null;
|
||||
fn(...args);
|
||||
const args = lastArgs;
|
||||
lastArgs = null;
|
||||
if (lastArgsTrailing) {
|
||||
lastArgs = lastArgsTrailing;
|
||||
lastArgsTrailing = null;
|
||||
scheduleFunc(lastArgs);
|
||||
|
||||
if (args) {
|
||||
fn(...args);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const ret = (...args: T) => {
|
||||
if (isTestEnv()) {
|
||||
fn(...args);
|
||||
return;
|
||||
}
|
||||
lastArgs = args;
|
||||
if (timerId === null) {
|
||||
scheduleFunc(lastArgs);
|
||||
} else if (opts?.trailing) {
|
||||
lastArgsTrailing = args;
|
||||
scheduleFunc();
|
||||
}
|
||||
};
|
||||
ret.flush = () => {
|
||||
@@ -190,12 +180,12 @@ export const throttleRAF = <T extends any[]>(
|
||||
timerId = null;
|
||||
}
|
||||
if (lastArgs) {
|
||||
fn(...(lastArgsTrailing || lastArgs));
|
||||
lastArgs = lastArgsTrailing = null;
|
||||
fn(...lastArgs);
|
||||
lastArgs = null;
|
||||
}
|
||||
};
|
||||
ret.cancel = () => {
|
||||
lastArgs = lastArgsTrailing = null;
|
||||
lastArgs = null;
|
||||
if (timerId !== null) {
|
||||
cancelAnimationFrame(timerId);
|
||||
timerId = null;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
export type VersionedSnapshot<T> = Readonly<{
|
||||
version: number;
|
||||
value: T;
|
||||
}>;
|
||||
|
||||
export class VersionedSnapshotStore<T> {
|
||||
private version = 0;
|
||||
private value: T;
|
||||
private readonly waiters = new Set<
|
||||
(snapshot: VersionedSnapshot<T>) => void
|
||||
>();
|
||||
private readonly subscribers = new Set<
|
||||
(snapshot: VersionedSnapshot<T>) => void
|
||||
>();
|
||||
|
||||
constructor(
|
||||
initialValue: T,
|
||||
private readonly isEqual: (prev: T, next: T) => boolean = Object.is,
|
||||
) {
|
||||
this.value = initialValue;
|
||||
}
|
||||
|
||||
public getSnapshot(): VersionedSnapshot<T> {
|
||||
return { version: this.version, value: this.value };
|
||||
}
|
||||
|
||||
public set(nextValue: T): boolean {
|
||||
if (this.isEqual(this.value, nextValue)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.value = nextValue;
|
||||
this.version += 1;
|
||||
|
||||
const snapshot = this.getSnapshot();
|
||||
|
||||
for (const subscriber of this.subscribers) {
|
||||
subscriber(snapshot);
|
||||
}
|
||||
for (const waiter of this.waiters) {
|
||||
waiter(snapshot);
|
||||
}
|
||||
this.waiters.clear();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public update(updater: (prev: T) => T): boolean {
|
||||
return this.set(updater(this.value));
|
||||
}
|
||||
|
||||
public subscribe(
|
||||
subscriber: (snapshot: VersionedSnapshot<T>) => void,
|
||||
): () => void {
|
||||
this.subscribers.add(subscriber);
|
||||
return () => {
|
||||
this.subscribers.delete(subscriber);
|
||||
};
|
||||
}
|
||||
|
||||
public pull(sinceVersion = -1): Promise<VersionedSnapshot<T>> {
|
||||
if (this.version !== sinceVersion) {
|
||||
return Promise.resolve(this.getSnapshot());
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.waiters.add(resolve);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { BinaryHeap } from "../src/binary-heap";
|
||||
|
||||
describe("BinaryHeap", () => {
|
||||
const numberHeap = () => new BinaryHeap<number>((n) => n);
|
||||
|
||||
const drain = (heap: BinaryHeap<number>) => {
|
||||
const out: number[] = [];
|
||||
while (heap.size() > 0) {
|
||||
out.push(heap.pop()!);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
describe("empty heap", () => {
|
||||
it("has size 0", () => {
|
||||
expect(numberHeap().size()).toBe(0);
|
||||
});
|
||||
|
||||
it("pop() returns null", () => {
|
||||
expect(numberHeap().pop()).toBe(null);
|
||||
});
|
||||
|
||||
it("remove() is a no-op and does not throw", () => {
|
||||
const heap = numberHeap();
|
||||
expect(() => heap.remove(1)).not.toThrow();
|
||||
expect(heap.size()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("push / pop", () => {
|
||||
it("tracks size as items are added and removed", () => {
|
||||
const heap = numberHeap();
|
||||
[3, 1, 2].forEach((n) => heap.push(n));
|
||||
expect(heap.size()).toBe(3);
|
||||
|
||||
heap.pop();
|
||||
expect(heap.size()).toBe(2);
|
||||
});
|
||||
|
||||
it("pops a single pushed element back out", () => {
|
||||
const heap = numberHeap();
|
||||
heap.push(42);
|
||||
expect(heap.pop()).toBe(42);
|
||||
expect(heap.pop()).toBe(null);
|
||||
});
|
||||
|
||||
it("always pops the smallest score first", () => {
|
||||
const heap = numberHeap();
|
||||
[5, 3, 8, 1, 9, 2, 7].forEach((n) => heap.push(n));
|
||||
expect(drain(heap)).toEqual([1, 2, 3, 5, 7, 8, 9]);
|
||||
});
|
||||
|
||||
it("handles duplicate scores", () => {
|
||||
const heap = numberHeap();
|
||||
[4, 1, 4, 1, 2].forEach((n) => heap.push(n));
|
||||
expect(drain(heap)).toEqual([1, 1, 2, 4, 4]);
|
||||
});
|
||||
|
||||
it("maintains the heap invariant for a large adversarial (reverse-sorted) input", () => {
|
||||
const heap = numberHeap();
|
||||
// pushing in descending order forces a sift-up on every insert
|
||||
const input = Array.from({ length: 1000 }, (_, i) => 1000 - i);
|
||||
input.forEach((n) => heap.push(n));
|
||||
expect(drain(heap)).toEqual([...input].sort((a, b) => a - b));
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove", () => {
|
||||
it("removes an interior element and keeps the rest ordered", () => {
|
||||
const heap = numberHeap();
|
||||
[5, 3, 8, 1, 9].forEach((n) => heap.push(n));
|
||||
|
||||
heap.remove(8);
|
||||
|
||||
expect(heap.size()).toBe(4);
|
||||
expect(drain(heap)).toEqual([1, 3, 5, 9]);
|
||||
});
|
||||
|
||||
it("can remove the current minimum", () => {
|
||||
const heap = numberHeap();
|
||||
[5, 3, 8, 1, 9].forEach((n) => heap.push(n));
|
||||
|
||||
heap.remove(1);
|
||||
|
||||
expect(heap.size()).toBe(4);
|
||||
expect(heap.pop()).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rescoreElement", () => {
|
||||
type Node = { id: string; f: number };
|
||||
|
||||
it("re-sorts a node after its score is lowered", () => {
|
||||
const heap = new BinaryHeap<Node>((node) => node.f);
|
||||
|
||||
const a = { id: "a", f: 10 };
|
||||
const b = { id: "b", f: 20 };
|
||||
const c = { id: "c", f: 30 };
|
||||
[a, b, c].forEach((node) => heap.push(node));
|
||||
|
||||
c.f = 5;
|
||||
heap.rescoreElement(c);
|
||||
|
||||
expect(heap.pop()).toBe(c);
|
||||
expect(heap.pop()).toBe(a);
|
||||
expect(heap.pop()).toBe(b);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/types"
|
||||
"outDir": "./dist/types",
|
||||
"rootDir": "../"
|
||||
},
|
||||
"include": ["src/**/*", "global.d.ts"],
|
||||
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
KEYS,
|
||||
arrayToMap,
|
||||
getFeatureFlag,
|
||||
invariant,
|
||||
@@ -137,12 +136,6 @@ export const maxBindingDistance_simple = (zoom?: AppState["zoom"]): number => {
|
||||
);
|
||||
};
|
||||
|
||||
export const shouldEnableBindingForPointerEvent = (
|
||||
event: React.PointerEvent<HTMLElement>,
|
||||
) => {
|
||||
return !event[KEYS.CTRL_OR_CMD];
|
||||
};
|
||||
|
||||
export const isBindingEnabled = (appState: {
|
||||
isBindingEnabled: AppState["isBindingEnabled"];
|
||||
}): boolean => {
|
||||
@@ -177,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.
|
||||
@@ -221,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,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -629,10 +643,13 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
|
||||
let start: BindingStrategy = { mode: undefined };
|
||||
let end: BindingStrategy = { mode: undefined };
|
||||
|
||||
invariant(
|
||||
arrow.points.length > 1,
|
||||
"Do not attempt to bind linear elements with a single point",
|
||||
);
|
||||
if (arrow.points.length < 2) {
|
||||
console.error(
|
||||
"Attempting to bind a linear element with less than 2 points",
|
||||
);
|
||||
// a single-point can't be bound -> cancel
|
||||
return { start: { mode: undefined }, end: { mode: undefined } };
|
||||
}
|
||||
|
||||
// If none of the ends are dragged, we don't change anything
|
||||
if (!startDragged && !endDragged) {
|
||||
@@ -720,12 +737,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: "inside",
|
||||
@@ -798,6 +814,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
|
||||
startDragged ? "start" : "end",
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
appState.isMidpointSnappingEnabled,
|
||||
) || globalPoint,
|
||||
}
|
||||
: { mode: null };
|
||||
@@ -842,6 +859,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
|
||||
startDragged ? "end" : "start",
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
appState.isMidpointSnappingEnabled,
|
||||
) || otherEndpoint,
|
||||
}
|
||||
: { mode: undefined }
|
||||
@@ -875,10 +893,13 @@ const getBindingStrategyForDraggingBindingElementEndpoints_complex = (
|
||||
let start: BindingStrategy = { mode: undefined };
|
||||
let end: BindingStrategy = { mode: undefined };
|
||||
|
||||
invariant(
|
||||
arrow.points.length > 1,
|
||||
"Do not attempt to bind linear elements with a single point",
|
||||
);
|
||||
if (arrow.points.length < 2) {
|
||||
console.error(
|
||||
"Attempting to bind a linear element with less than 2 points",
|
||||
);
|
||||
// a single-point can't be bound -> cancel
|
||||
return { start: { mode: undefined }, end: { mode: undefined } };
|
||||
}
|
||||
|
||||
// If none of the ends are dragged, we don't change anything
|
||||
if (!startDragged && !endDragged) {
|
||||
@@ -1005,6 +1026,7 @@ export const bindBindingElement = (
|
||||
startOrEnd: "start" | "end",
|
||||
scene: Scene,
|
||||
focusPoint?: GlobalPoint,
|
||||
shouldSnapToOutline = true,
|
||||
): void => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
|
||||
@@ -1019,6 +1041,7 @@ export const bindBindingElement = (
|
||||
hoveredElement,
|
||||
startOrEnd,
|
||||
elementsMap,
|
||||
shouldSnapToOutline,
|
||||
),
|
||||
};
|
||||
} else {
|
||||
@@ -1352,6 +1375,7 @@ export const bindPointToSnapToElementOutline = (
|
||||
startOrEnd: "start" | "end",
|
||||
elementsMap: ElementsMap,
|
||||
customIntersector?: LineSegment<GlobalPoint>,
|
||||
isMidpointSnappingEnabled = true,
|
||||
): GlobalPoint => {
|
||||
const elbowed = isElbowArrow(arrowElement);
|
||||
const point = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
@@ -1391,13 +1415,9 @@ export const bindPointToSnapToElementOutline = (
|
||||
const isHorizontal = headingIsHorizontal(
|
||||
headingForPointFromElement(bindableElement, aabb, point),
|
||||
);
|
||||
const snapPoint = snapToMid(
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
edgePoint,
|
||||
0.05,
|
||||
arrowElement,
|
||||
);
|
||||
const snapPoint = isMidpointSnappingEnabled
|
||||
? snapToMid(bindableElement, elementsMap, edgePoint, 0.05, arrowElement)
|
||||
: undefined;
|
||||
const resolved = snapPoint || point;
|
||||
const otherPoint = pointFrom<GlobalPoint>(
|
||||
isHorizontal ? bindableCenter[0] : resolved[0],
|
||||
@@ -1776,10 +1796,13 @@ export const updateBoundPoint = (
|
||||
);
|
||||
const otherArrowPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
arrow,
|
||||
startOrEnd === "startBinding" ? -1 : 0,
|
||||
startOrEnd === "startBinding" ? 1 : -2,
|
||||
elementsMap,
|
||||
);
|
||||
const otherFocusPointOrArrowPoint = otherFocusPoint || otherArrowPoint;
|
||||
const otherFocusPointOrArrowPoint =
|
||||
arrow.points.length === 2
|
||||
? otherFocusPoint || otherArrowPoint
|
||||
: otherArrowPoint;
|
||||
const intersector =
|
||||
otherFocusPointOrArrowPoint &&
|
||||
lineSegment(focusPoint, otherFocusPointOrArrowPoint);
|
||||
@@ -1889,6 +1912,8 @@ export const calculateFixedPointForElbowArrowBinding = (
|
||||
hoveredElement: ExcalidrawBindableElement,
|
||||
startOrEnd: "start" | "end",
|
||||
elementsMap: ElementsMap,
|
||||
shouldSnapToOutline = true,
|
||||
isMidpointSnappingEnabled = true,
|
||||
): { fixedPoint: FixedPoint } => {
|
||||
const bounds = [
|
||||
hoveredElement.x,
|
||||
@@ -1896,12 +1921,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,
|
||||
@@ -1915,9 +1948,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),
|
||||
]),
|
||||
};
|
||||
};
|
||||
@@ -1948,9 +1981,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]),
|
||||
|
||||
+357
-38
@@ -1,5 +1,4 @@
|
||||
import rough from "roughjs/bin/rough";
|
||||
|
||||
import {
|
||||
arrayToMap,
|
||||
type Bounds,
|
||||
@@ -7,7 +6,6 @@ import {
|
||||
rescalePoints,
|
||||
sizeOf,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
degreesToRadians,
|
||||
lineSegment,
|
||||
@@ -16,9 +14,7 @@ import {
|
||||
pointFromArray,
|
||||
pointRotateRads,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { getCurvePathOps } from "@excalidraw/utils/shape";
|
||||
|
||||
import { pointsOnBezierCurves } from "points-on-curve";
|
||||
|
||||
import type {
|
||||
@@ -29,9 +25,7 @@ import type {
|
||||
LocalPoint,
|
||||
Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { generateRoughOptions } from "./shape";
|
||||
@@ -41,18 +35,20 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import {
|
||||
isArrowElement,
|
||||
isBoundToContainer,
|
||||
isFrameLikeElement,
|
||||
isFreeDrawElement,
|
||||
isLinearElement,
|
||||
isLineElement,
|
||||
isTextElement,
|
||||
isExcalidrawElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import { getElementShape } from "./shape";
|
||||
|
||||
import {
|
||||
deconstructDiamondElement,
|
||||
deconstructRectanguloidElement,
|
||||
} from "./utils";
|
||||
import { intersectElementWithLineSegment } from "./collision";
|
||||
import { elementOverlapsWithFrame, getContainingFrame } from "./frame";
|
||||
|
||||
import type { Drawable, Op } from "roughjs/bin/core";
|
||||
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||
@@ -67,6 +63,7 @@ import type {
|
||||
ExcalidrawRectanguloidElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
|
||||
export type RectangleBox = {
|
||||
@@ -680,8 +677,9 @@ export const getMinMaxXYFromCurvePathOps = (
|
||||
return [minX, minY, maxX, maxY];
|
||||
};
|
||||
|
||||
export const getBoundsFromPoints = (
|
||||
points: ExcalidrawFreeDrawElement["points"],
|
||||
export const getBoundsFromPoints = <P extends GlobalPoint | LocalPoint>(
|
||||
points: readonly P[],
|
||||
padding: number = 0,
|
||||
): Bounds => {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
@@ -695,7 +693,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 +707,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 +718,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 +748,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 +834,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 +867,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 +885,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 +896,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 +1259,17 @@ export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
|
||||
): boolean =>
|
||||
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
|
||||
|
||||
// TODO make pointInsideBounds inclusive and remove this function once we
|
||||
// test nothing is breaking
|
||||
export const pointInsideBoundsInclusive = <P extends GlobalPoint | LocalPoint>(
|
||||
p: P,
|
||||
bounds: Bounds,
|
||||
): boolean =>
|
||||
p[0] >= bounds[0] &&
|
||||
p[0] <= bounds[2] &&
|
||||
p[1] >= bounds[1] &&
|
||||
p[1] <= bounds[3];
|
||||
|
||||
export const doBoundsIntersect = (
|
||||
bounds1: Bounds | null,
|
||||
bounds2: Bounds | null,
|
||||
@@ -1262,13 +1284,310 @@ export const doBoundsIntersect = (
|
||||
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
|
||||
};
|
||||
|
||||
export const boundsContainBounds = (outerBounds: Bounds, innerBounds: Bounds) =>
|
||||
[
|
||||
pointFrom<GlobalPoint>(innerBounds[0], innerBounds[1]),
|
||||
pointFrom<GlobalPoint>(innerBounds[0], innerBounds[3]),
|
||||
pointFrom<GlobalPoint>(innerBounds[2], innerBounds[1]),
|
||||
pointFrom<GlobalPoint>(innerBounds[2], innerBounds[3]),
|
||||
].every((point) => pointInsideBoundsInclusive(point, outerBounds));
|
||||
|
||||
/**
|
||||
* High level helper to get elements overlapping a bounding box.
|
||||
* It can be used to get elements overlapping a selection box, for example.
|
||||
*
|
||||
*/
|
||||
export const elementsOverlappingBBox = ({
|
||||
elements,
|
||||
elementsMap,
|
||||
bounds,
|
||||
type,
|
||||
excludeElementsInFrames,
|
||||
shouldIgnoreElementFromSelection,
|
||||
}: {
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
elementsMap?: ElementsMap;
|
||||
bounds: Bounds | ExcalidrawElement;
|
||||
/**
|
||||
* - overlap: elements overlapping or inside bounds
|
||||
* - contain: elements inside bounds
|
||||
**/
|
||||
type: "contain" | "overlap";
|
||||
excludeElementsInFrames?: boolean;
|
||||
shouldIgnoreElementFromSelection?: (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
) => boolean;
|
||||
}) => {
|
||||
if (!elementsMap) {
|
||||
elementsMap = arrayToMap(elements) as ElementsMap;
|
||||
}
|
||||
const selectionBounds = isExcalidrawElement(bounds)
|
||||
? getElementBounds(bounds, elementsMap)
|
||||
: bounds;
|
||||
const [selectionX1, selectionY1, selectionX2, selectionY2] = selectionBounds;
|
||||
const selectionEdges = [
|
||||
lineSegment<GlobalPoint>(
|
||||
pointFrom(selectionX1, selectionY1),
|
||||
pointFrom(selectionX2, selectionY1),
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
pointFrom(selectionX2, selectionY1),
|
||||
pointFrom(selectionX2, selectionY2),
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
pointFrom(selectionX2, selectionY2),
|
||||
pointFrom(selectionX1, selectionY2),
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
pointFrom(selectionX1, selectionY2),
|
||||
pointFrom(selectionX1, selectionY1),
|
||||
),
|
||||
];
|
||||
|
||||
const framesInSelection = excludeElementsInFrames
|
||||
? new Set<NonDeletedExcalidrawElement["id"]>()
|
||||
: null;
|
||||
const groups: Record<string, NonDeletedExcalidrawElement[]> = {};
|
||||
const elementsInSelection: Set<NonDeletedExcalidrawElement> = new Set();
|
||||
|
||||
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,
|
||||
);
|
||||
labelAABB = [
|
||||
x,
|
||||
y,
|
||||
x + boundTextElement.width,
|
||||
y + boundTextElement.height,
|
||||
] as Bounds;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// ============== 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 (
|
||||
type === "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 (type === "overlap" && doBoundsIntersect(selectionBounds, elementAABB)) {
|
||||
let hasIntersection = false;
|
||||
|
||||
// Preliminary check potential intersection imprecision
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
hasIntersection = element.points.some((point) => {
|
||||
const rotatedPoint = pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
|
||||
return pointInsideBounds(rotatedPoint, selectionBounds);
|
||||
});
|
||||
} else {
|
||||
const nonRotatedElementBounds = getElementBounds(
|
||||
element,
|
||||
elementsMap,
|
||||
true,
|
||||
);
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
hasIntersection = [
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
|
||||
nonRotatedElementBounds[1],
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
nonRotatedElementBounds[2],
|
||||
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
|
||||
nonRotatedElementBounds[3],
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
nonRotatedElementBounds[0],
|
||||
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
].some((point) => {
|
||||
return pointInsideBounds(
|
||||
pointRotateRads(point, center, element.angle),
|
||||
selectionBounds,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasIntersection) {
|
||||
hasIntersection = selectionEdges.some(
|
||||
(selectionEdge) =>
|
||||
intersectElementWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
selectionEdge,
|
||||
strokeWidth / 2,
|
||||
true, // Stop at first hit for better performance
|
||||
).length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
if (hasIntersection) {
|
||||
if (framesInSelection && isFrameLikeElement(element)) {
|
||||
framesInSelection.add(element.id);
|
||||
}
|
||||
|
||||
elementsInSelection.add(element);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. We don't need to handle when the selection is inside the element
|
||||
// as it is separately handled in App.
|
||||
}
|
||||
|
||||
if (framesInSelection) {
|
||||
elementsInSelection.forEach((element) => {
|
||||
if (element.frameId && framesInSelection.has(element.frameId)) {
|
||||
elementsInSelection.delete(element);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (type === "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 (type === "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 elementCenterPoint = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
xOffset: number = 0,
|
||||
yOffset: number = 0,
|
||||
) => {
|
||||
if (isLinearElement(element)) {
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
const [x, y] = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
|
||||
|
||||
|
||||
@@ -61,6 +61,8 @@ import { distanceToElement } from "./distance";
|
||||
|
||||
import { getBindingGap } from "./binding";
|
||||
|
||||
import { hasBackground } from "./comparisons";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
@@ -83,7 +85,7 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
|
||||
}
|
||||
|
||||
const isDraggableFromInside =
|
||||
!isTransparent(element.backgroundColor) ||
|
||||
(hasBackground(element.type) && !isTransparent(element.backgroundColor)) ||
|
||||
hasBoundTextElement(element) ||
|
||||
isIframeLikeElement(element) ||
|
||||
isTextElement(element);
|
||||
@@ -154,14 +156,11 @@ export const hitElementItself = ({
|
||||
|
||||
// Hit test against the extended, rotated bounding box of the element first
|
||||
const bounds = getElementBounds(element, elementsMap, true);
|
||||
const hitBounds = isPointWithinBounds(
|
||||
pointFrom(bounds[0] - threshold, bounds[1] - threshold),
|
||||
pointRotateRads(
|
||||
point,
|
||||
getCenterForBounds(bounds),
|
||||
-element.angle as Radians,
|
||||
),
|
||||
pointFrom(bounds[2] + threshold, bounds[3] + threshold),
|
||||
const hitBounds = isPointInRotatedBounds(
|
||||
point,
|
||||
bounds,
|
||||
element.angle,
|
||||
threshold,
|
||||
);
|
||||
|
||||
// PERF: Bail out early if the point is not even in the
|
||||
@@ -192,18 +191,32 @@ export const hitElementItself = ({
|
||||
return result;
|
||||
};
|
||||
|
||||
const isPointInRotatedBounds = (
|
||||
point: GlobalPoint,
|
||||
bounds: Bounds,
|
||||
angle: Radians,
|
||||
tolerance = 0,
|
||||
) => {
|
||||
const adjustedPoint =
|
||||
angle === 0
|
||||
? point
|
||||
: pointRotateRads(point, getCenterForBounds(bounds), -angle as Radians);
|
||||
|
||||
return isPointWithinBounds(
|
||||
pointFrom(bounds[0] - tolerance, bounds[1] - tolerance),
|
||||
adjustedPoint,
|
||||
pointFrom(bounds[2] + tolerance, bounds[3] + tolerance),
|
||||
);
|
||||
};
|
||||
|
||||
export const hitElementBoundingBox = (
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
tolerance = 0,
|
||||
) => {
|
||||
let [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
|
||||
x1 -= tolerance;
|
||||
y1 -= tolerance;
|
||||
x2 += tolerance;
|
||||
y2 += tolerance;
|
||||
return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
|
||||
const bounds = getElementBounds(element, elementsMap, true);
|
||||
return isPointInRotatedBounds(point, bounds, element.angle, tolerance);
|
||||
};
|
||||
|
||||
export const hitElementBoundingBoxOnly = (
|
||||
@@ -313,7 +326,10 @@ export const getAllHoveredElementAtPoint = (
|
||||
) {
|
||||
candidateElements.push(element);
|
||||
|
||||
if (!isTransparent(element.backgroundColor)) {
|
||||
if (
|
||||
hasBackground(element.type) &&
|
||||
!isTransparent(element.backgroundColor)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -465,7 +481,12 @@ export const intersectElementWithLineSegment = (
|
||||
case "line":
|
||||
case "freedraw":
|
||||
case "arrow":
|
||||
return intersectLinearOrFreeDrawWithLineSegment(element, line, onlyFirst);
|
||||
return intersectLinearOrFreeDrawWithLineSegment(
|
||||
element,
|
||||
line,
|
||||
elementsMap,
|
||||
onlyFirst,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -532,11 +553,15 @@ const lineIntersections = (
|
||||
const intersectLinearOrFreeDrawWithLineSegment = (
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
elementsMap: ElementsMap,
|
||||
onlyFirst = false,
|
||||
): GlobalPoint[] => {
|
||||
// NOTE: This is the only one which return the decomposed elements
|
||||
// rotated! This is due to taking advantage of roughjs definitions.
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
const intersections: GlobalPoint[] = [];
|
||||
|
||||
for (const l of lines) {
|
||||
@@ -564,7 +589,9 @@ const intersectLinearOrFreeDrawWithLineSegment = (
|
||||
continue;
|
||||
}
|
||||
|
||||
const hits = curveIntersectLineSegment(c, segment);
|
||||
const hits = curveIntersectLineSegment(c, segment, {
|
||||
iterLimit: 10,
|
||||
});
|
||||
|
||||
if (hits.length > 0) {
|
||||
intersections.push(...hits);
|
||||
|
||||
@@ -38,6 +38,8 @@ export const hasStrokeStyle = (type: ElementOrToolType) =>
|
||||
type === "arrow" ||
|
||||
type === "line";
|
||||
|
||||
export const hasFreedrawMode = (type: ElementOrToolType) => type === "freedraw";
|
||||
|
||||
export const canChangeRoundness = (type: ElementOrToolType) =>
|
||||
type === "rectangle" ||
|
||||
type === "iframe" ||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -915,6 +915,8 @@ export const updateElbowArrowPoints = (
|
||||
},
|
||||
options?: {
|
||||
isDragging?: boolean;
|
||||
isBindingEnabled?: boolean;
|
||||
isMidpointSnappingEnabled?: boolean;
|
||||
},
|
||||
): ElementUpdate<ExcalidrawElbowArrowElement> => {
|
||||
if (arrow.points.length < 2) {
|
||||
@@ -1202,6 +1204,8 @@ const getElbowArrowData = (
|
||||
options?: {
|
||||
isDragging?: boolean;
|
||||
zoom?: AppState["zoom"];
|
||||
isBindingEnabled?: boolean;
|
||||
isMidpointSnappingEnabled?: boolean;
|
||||
},
|
||||
) => {
|
||||
const origStartGlobalPoint: GlobalPoint = pointTranslate<
|
||||
@@ -1215,7 +1219,7 @@ const getElbowArrowData = (
|
||||
|
||||
let hoveredStartElement = null;
|
||||
let hoveredEndElement = null;
|
||||
if (options?.isDragging) {
|
||||
if (options?.isDragging && options?.isBindingEnabled !== false) {
|
||||
const elements = Array.from(elementsMap.values());
|
||||
hoveredStartElement =
|
||||
getHoveredElement(
|
||||
@@ -1255,6 +1259,8 @@ const getElbowArrowData = (
|
||||
hoveredStartElement,
|
||||
elementsMap,
|
||||
options?.isDragging,
|
||||
options?.isBindingEnabled,
|
||||
options?.isMidpointSnappingEnabled,
|
||||
);
|
||||
const endGlobalPoint = getGlobalPoint(
|
||||
{
|
||||
@@ -1270,6 +1276,8 @@ const getElbowArrowData = (
|
||||
hoveredEndElement,
|
||||
elementsMap,
|
||||
options?.isDragging,
|
||||
options?.isBindingEnabled,
|
||||
options?.isMidpointSnappingEnabled,
|
||||
);
|
||||
const startHeading = getBindPointHeading(
|
||||
startGlobalPoint,
|
||||
@@ -2116,8 +2124,8 @@ const normalizeArrowElementUpdate = (
|
||||
offsetY < -MAX_POS ||
|
||||
offsetY > MAX_POS ||
|
||||
offsetX + points[points.length - 1][0] < -MAX_POS ||
|
||||
offsetY + points[points.length - 1][0] > MAX_POS ||
|
||||
offsetX + points[points.length - 1][1] < -MAX_POS ||
|
||||
offsetX + points[points.length - 1][0] > MAX_POS ||
|
||||
offsetY + points[points.length - 1][1] < -MAX_POS ||
|
||||
offsetY + points[points.length - 1][1] > MAX_POS
|
||||
) {
|
||||
console.error(
|
||||
@@ -2213,14 +2221,18 @@ const getGlobalPoint = (
|
||||
element?: ExcalidrawBindableElement | null,
|
||||
elementsMap?: ElementsMap,
|
||||
isDragging?: boolean,
|
||||
isBindingEnabled = true,
|
||||
isMidpointSnappingEnabled = true,
|
||||
): GlobalPoint => {
|
||||
if (isDragging) {
|
||||
if (element && elementsMap) {
|
||||
if (isBindingEnabled && element && elementsMap) {
|
||||
return bindPointToSnapToElementOutline(
|
||||
arrow,
|
||||
element,
|
||||
startOrEnd,
|
||||
elementsMap,
|
||||
undefined,
|
||||
isMidpointSnappingEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+113
-54
@@ -1,7 +1,9 @@
|
||||
import { arrayToMap } from "@excalidraw/common";
|
||||
import { isPointWithinBounds, pointFrom } from "@excalidraw/math";
|
||||
import { doLineSegmentsIntersect } from "@excalidraw/utils/bbox";
|
||||
import { elementsOverlappingBBox } from "@excalidraw/utils/withinBounds";
|
||||
import {
|
||||
isPointWithinBounds,
|
||||
pointFrom,
|
||||
segmentsIntersectAt,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
AppClassProperties,
|
||||
@@ -18,9 +20,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,
|
||||
@@ -75,7 +81,7 @@ export function isElementIntersectingFrame(
|
||||
|
||||
const intersecting = frameLineSegments.some((frameLineSegment) =>
|
||||
elementLineSegments.some((elementLineSegment) =>
|
||||
doLineSegmentsIntersect(frameLineSegment, elementLineSegment),
|
||||
segmentsIntersectAt(frameLineSegment, elementLineSegment),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -100,8 +106,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 +495,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 +540,11 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
|
||||
allElements: T,
|
||||
elementsToAdd: NonDeletedExcalidrawElement[],
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
appState: AppState,
|
||||
): T => {
|
||||
const elementsMap = arrayToMap(allElements);
|
||||
const currTargetFrameChildrenMap = new Map<ExcalidrawElement["id"], true>();
|
||||
for (const element of allElements.values()) {
|
||||
if (element.frameId === frame.id) {
|
||||
currTargetFrameChildrenMap.set(element.id, true);
|
||||
}
|
||||
}
|
||||
const commonFrameId = getCommonFrameId(elementsToAdd);
|
||||
|
||||
const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id));
|
||||
|
||||
const finalElementsToAdd: ExcalidrawElement[] = [];
|
||||
const finalElementsToAdd = new Set<ExcalidrawElement>();
|
||||
|
||||
const otherFrames = new Set<ExcalidrawFrameLikeElement["id"]>();
|
||||
|
||||
@@ -522,7 +555,8 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
|
||||
}
|
||||
|
||||
// - add bound text elements if not already in the array
|
||||
// - filter out elements that are already in the frame
|
||||
// - keep elements already in the frame so mixed selections can be reordered
|
||||
// together
|
||||
for (const element of omitGroupsContainingFrameLikes(
|
||||
allElements,
|
||||
elementsToAdd,
|
||||
@@ -535,38 +569,64 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
|
||||
continue;
|
||||
}
|
||||
|
||||
// if the element is already in another frame (which is also in elementsToAdd),
|
||||
// it means that frame and children are selected at the same time
|
||||
// => keep original frame membership, do not add to the target frame
|
||||
if (
|
||||
element.frameId &&
|
||||
appState.selectedElementIds[element.id] &&
|
||||
appState.selectedElementIds[element.frameId]
|
||||
) {
|
||||
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 +680,11 @@ export const replaceAllElementsInFrame = <T extends ExcalidrawElement>(
|
||||
allElements: readonly T[],
|
||||
nextElementsInFrame: ExcalidrawElement[],
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
app: AppClassProperties,
|
||||
): T[] => {
|
||||
return addElementsToFrame(
|
||||
removeAllElementsFromFrame(allElements, frame),
|
||||
nextElementsInFrame,
|
||||
frame,
|
||||
app.state,
|
||||
).slice();
|
||||
};
|
||||
|
||||
@@ -920,16 +978,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),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -99,3 +99,4 @@ export * from "./typeChecks";
|
||||
export * from "./utils";
|
||||
export * from "./zindex";
|
||||
export * from "./arrows/helpers";
|
||||
export * from "./arrowheads";
|
||||
|
||||
@@ -359,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)) {
|
||||
@@ -418,6 +427,7 @@ export class LinearElementEditor {
|
||||
"start",
|
||||
elementsMap,
|
||||
app.state.zoom,
|
||||
app.state.isMidpointSnappingEnabled,
|
||||
)
|
||||
: linearElementEditor.initialState.altFocusPoint,
|
||||
},
|
||||
@@ -466,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];
|
||||
@@ -538,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)) {
|
||||
@@ -636,6 +661,7 @@ export class LinearElementEditor {
|
||||
"start",
|
||||
elementsMap,
|
||||
app.state.zoom,
|
||||
app.state.isMidpointSnappingEnabled,
|
||||
)
|
||||
: linearElementEditor.initialState.altFocusPoint,
|
||||
},
|
||||
@@ -774,6 +800,7 @@ export class LinearElementEditor {
|
||||
element.points[index + 1],
|
||||
index,
|
||||
appState.zoom,
|
||||
elementsMap,
|
||||
)
|
||||
) {
|
||||
midpoints.push(null);
|
||||
@@ -783,6 +810,7 @@ export class LinearElementEditor {
|
||||
const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
|
||||
element,
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
midpoints.push(segmentMidPoint);
|
||||
index++;
|
||||
@@ -870,6 +898,7 @@ export class LinearElementEditor {
|
||||
endPoint: P,
|
||||
index: number,
|
||||
zoom: Zoom,
|
||||
elementsMap: ElementsMap,
|
||||
) {
|
||||
if (isElbowArrow(element)) {
|
||||
if (index >= 0 && index < element.points.length) {
|
||||
@@ -884,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,
|
||||
@@ -904,6 +936,7 @@ export class LinearElementEditor {
|
||||
static getSegmentMidPoint(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
index: number,
|
||||
elementsMap: ElementsMap,
|
||||
): GlobalPoint {
|
||||
if (isElbowArrow(element)) {
|
||||
invariant(
|
||||
@@ -916,7 +949,10 @@ export class LinearElementEditor {
|
||||
return pointFrom<GlobalPoint>(element.x + p[0], element.y + p[1]);
|
||||
}
|
||||
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
invariant(
|
||||
(lines.length === 0 && curves.length > 0) ||
|
||||
@@ -1524,6 +1560,10 @@ export class LinearElementEditor {
|
||||
endBinding?: FixedPointBinding | null;
|
||||
moveMidPointsWithElement?: boolean | null;
|
||||
},
|
||||
options?: {
|
||||
isBindingEnabled?: boolean;
|
||||
isMidpointSnappingEnabled?: boolean;
|
||||
},
|
||||
) {
|
||||
const { points } = element;
|
||||
|
||||
@@ -1592,6 +1632,8 @@ export class LinearElementEditor {
|
||||
otherUpdates,
|
||||
{
|
||||
isDragging: Array.from(pointUpdates.values()).some((t) => t.isDragging),
|
||||
isBindingEnabled: options?.isBindingEnabled,
|
||||
isMidpointSnappingEnabled: options?.isMidpointSnappingEnabled,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1706,6 +1748,8 @@ export class LinearElementEditor {
|
||||
isDragging?: boolean;
|
||||
zoom?: AppState["zoom"];
|
||||
sceneElementsMap?: NonDeletedSceneElementsMap;
|
||||
isBindingEnabled?: boolean;
|
||||
isMidpointSnappingEnabled?: boolean;
|
||||
},
|
||||
) {
|
||||
if (isElbowArrow(element)) {
|
||||
@@ -1726,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?
|
||||
@@ -1821,6 +1867,7 @@ export class LinearElementEditor {
|
||||
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||
element,
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
|
||||
@@ -2092,13 +2139,13 @@ const pointDraggingUpdates = (
|
||||
} => {
|
||||
const naiveDraggingPoints = new Map(
|
||||
selectedPointsIndices.map((pointIndex) => {
|
||||
// NOTE: Avoid stale point index issue potentially caused by elbow
|
||||
// arrows unpredictably changing the number of points during dragging
|
||||
const point = element.points[pointIndex] ?? element.points.at(-1);
|
||||
return [
|
||||
pointIndex,
|
||||
{
|
||||
point: pointFrom<LocalPoint>(
|
||||
element.points[pointIndex][0] + deltaX,
|
||||
element.points[pointIndex][1] + deltaY,
|
||||
),
|
||||
point: pointFrom<LocalPoint>(point[0] + deltaX, point[1] + deltaY),
|
||||
isDragging: true,
|
||||
},
|
||||
];
|
||||
@@ -2145,14 +2192,16 @@ const pointDraggingUpdates = (
|
||||
suggestedBinding: suggestedBindingElement
|
||||
? {
|
||||
element: suggestedBindingElement,
|
||||
midPoint: snapToMid(
|
||||
suggestedBindingElement,
|
||||
elementsMap,
|
||||
pointFrom<GlobalPoint>(
|
||||
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||
),
|
||||
),
|
||||
midPoint: app.state.isMidpointSnappingEnabled
|
||||
? snapToMid(
|
||||
suggestedBindingElement,
|
||||
elementsMap,
|
||||
pointFrom<GlobalPoint>(
|
||||
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
@@ -2368,7 +2417,7 @@ const pointDraggingUpdates = (
|
||||
? nextArrow.points[0]
|
||||
: endBindable
|
||||
? updateBoundPoint(
|
||||
element,
|
||||
nextArrow,
|
||||
"endBinding",
|
||||
nextArrow.endBinding,
|
||||
endBindable,
|
||||
@@ -2399,7 +2448,7 @@ const pointDraggingUpdates = (
|
||||
? endLocalPoint
|
||||
: startBindable
|
||||
? updateBoundPoint(
|
||||
element,
|
||||
nextArrow,
|
||||
"startBinding",
|
||||
nextArrow.startBinding,
|
||||
startBindable,
|
||||
|
||||
@@ -40,6 +40,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
updates: ElementUpdate<TElement>,
|
||||
options?: {
|
||||
isDragging?: boolean;
|
||||
isBindingEnabled?: boolean;
|
||||
isMidpointSnappingEnabled?: boolean;
|
||||
},
|
||||
) => {
|
||||
let didChange = false;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
DEFAULT_VERTICAL_ALIGN,
|
||||
DEFAULT_STROKE_STREAMLINE,
|
||||
VERTICAL_ALIGN,
|
||||
randomInteger,
|
||||
randomId,
|
||||
@@ -444,6 +445,7 @@ export const newFreeDrawElement = (
|
||||
type: "freedraw";
|
||||
points?: ExcalidrawFreeDrawElement["points"];
|
||||
simulatePressure: boolean;
|
||||
strokeOptions?: ExcalidrawFreeDrawElement["strokeOptions"];
|
||||
pressures?: ExcalidrawFreeDrawElement["pressures"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawFreeDrawElement> => {
|
||||
@@ -452,6 +454,10 @@ export const newFreeDrawElement = (
|
||||
points: opts.points || [],
|
||||
pressures: opts.pressures || [],
|
||||
simulatePressure: opts.simulatePressure,
|
||||
strokeOptions: opts.strokeOptions ?? {
|
||||
variability: "variable",
|
||||
streamline: DEFAULT_STROKE_STREAMLINE,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -422,10 +422,10 @@ const drawElementOnCanvas = (
|
||||
|
||||
for (const shape of shapes) {
|
||||
if (typeof shape === "string") {
|
||||
context.fillStyle =
|
||||
renderConfig.theme === THEME.DARK
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
: element.strokeColor;
|
||||
context.fillStyle = applyDarkModeFilter(
|
||||
element.strokeColor,
|
||||
renderConfig.theme === THEME.DARK,
|
||||
);
|
||||
context.fill(new Path2D(shape));
|
||||
} else {
|
||||
rc.draw(shape);
|
||||
@@ -555,10 +555,10 @@ const drawElementOnCanvas = (
|
||||
context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr");
|
||||
context.save();
|
||||
context.font = getFontString(element);
|
||||
context.fillStyle =
|
||||
renderConfig.theme === THEME.DARK
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
: element.strokeColor;
|
||||
context.fillStyle = applyDarkModeFilter(
|
||||
element.strokeColor,
|
||||
renderConfig.theme === THEME.DARK,
|
||||
);
|
||||
context.textAlign = element.textAlign as CanvasTextAlign;
|
||||
|
||||
// Canvas does not support multiline text by default
|
||||
@@ -811,10 +811,10 @@ export const renderElement = (
|
||||
context.fillStyle = "rgba(0, 0, 200, 0.04)";
|
||||
|
||||
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
|
||||
context.strokeStyle =
|
||||
appState.theme === THEME.DARK
|
||||
? applyDarkModeFilter(FRAME_STYLE.strokeColor)
|
||||
: FRAME_STYLE.strokeColor;
|
||||
context.strokeStyle = applyDarkModeFilter(
|
||||
FRAME_STYLE.strokeColor,
|
||||
appState.theme === THEME.DARK,
|
||||
);
|
||||
|
||||
// TODO change later to only affect AI frames
|
||||
if (isMagicFrameElement(element)) {
|
||||
@@ -889,8 +889,10 @@ export const renderElement = (
|
||||
case "embeddable": {
|
||||
if (renderConfig.isExporting) {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
const cx = (x1 + x2) / 2 + appState.scrollX;
|
||||
const cy = (y1 + y2) / 2 + appState.scrollY;
|
||||
const centerX = (x1 + x2) / 2;
|
||||
const centerY = (y1 + y2) / 2;
|
||||
const cx = centerX + appState.scrollX;
|
||||
const cy = centerY + appState.scrollY;
|
||||
let shiftX = (x2 - x1) / 2 - (element.x - x1);
|
||||
let shiftY = (y2 - y1) / 2 - (element.y - y1);
|
||||
if (isTextElement(element)) {
|
||||
@@ -912,64 +914,49 @@ export const renderElement = (
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
|
||||
if (isArrowElement(element) && boundTextElement) {
|
||||
const tempCanvas = document.createElement("canvas");
|
||||
|
||||
const tempCanvasContext = tempCanvas.getContext("2d")!;
|
||||
|
||||
// Take max dimensions of arrow canvas so that when canvas is rotated
|
||||
// the arrow doesn't get clipped
|
||||
const maxDim = Math.max(distance(x1, x2), distance(y1, y2));
|
||||
const padding = getCanvasPadding(element);
|
||||
tempCanvas.width =
|
||||
maxDim * appState.exportScale + padding * 10 * appState.exportScale;
|
||||
tempCanvas.height =
|
||||
maxDim * appState.exportScale + padding * 10 * appState.exportScale;
|
||||
|
||||
tempCanvasContext.translate(
|
||||
tempCanvas.width / 2,
|
||||
tempCanvas.height / 2,
|
||||
);
|
||||
tempCanvasContext.scale(appState.exportScale, appState.exportScale);
|
||||
|
||||
// Shift the canvas to left most point of the arrow
|
||||
// Draw arrow directly as vector and clear label hole separately.
|
||||
// This avoids temp-canvas bitmap blit which introduces resampling blur.
|
||||
shiftX = element.width / 2 - (element.x - x1);
|
||||
shiftY = element.height / 2 - (element.y - y1);
|
||||
|
||||
tempCanvasContext.rotate(element.angle);
|
||||
const tempRc = rough.canvas(tempCanvas);
|
||||
context.save();
|
||||
context.rotate(element.angle);
|
||||
context.translate(-shiftX, -shiftY);
|
||||
drawElementOnCanvas(element, rc, context, renderConfig);
|
||||
context.restore();
|
||||
|
||||
tempCanvasContext.translate(-shiftX, -shiftY);
|
||||
|
||||
drawElementOnCanvas(element, tempRc, tempCanvasContext, renderConfig);
|
||||
|
||||
tempCanvasContext.translate(shiftX, shiftY);
|
||||
|
||||
tempCanvasContext.rotate(-element.angle);
|
||||
|
||||
// Shift the canvas to center of bound text
|
||||
const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords(
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
const boundTextShiftX = (x1 + x2) / 2 - boundTextCx;
|
||||
const boundTextShiftY = (y1 + y2) / 2 - boundTextCy;
|
||||
tempCanvasContext.translate(-boundTextShiftX, -boundTextShiftY);
|
||||
const holeX =
|
||||
boundTextCx -
|
||||
centerX -
|
||||
boundTextElement.width / 2 -
|
||||
BOUND_TEXT_PADDING;
|
||||
const holeY =
|
||||
boundTextCy -
|
||||
centerY -
|
||||
boundTextElement.height / 2 -
|
||||
BOUND_TEXT_PADDING;
|
||||
const holeWidth = boundTextElement.width + BOUND_TEXT_PADDING * 2;
|
||||
const holeHeight = boundTextElement.height + BOUND_TEXT_PADDING * 2;
|
||||
|
||||
// Clear the bound text area
|
||||
tempCanvasContext.clearRect(
|
||||
-boundTextElement.width / 2,
|
||||
-boundTextElement.height / 2,
|
||||
boundTextElement.width,
|
||||
boundTextElement.height,
|
||||
);
|
||||
context.scale(1 / appState.exportScale, 1 / appState.exportScale);
|
||||
context.drawImage(
|
||||
tempCanvas,
|
||||
-tempCanvas.width / 2,
|
||||
-tempCanvas.height / 2,
|
||||
tempCanvas.width,
|
||||
tempCanvas.height,
|
||||
);
|
||||
const isTransparentHole =
|
||||
"viewBackgroundColor" in appState &&
|
||||
(appState.viewBackgroundColor === "transparent" ||
|
||||
!appState.viewBackgroundColor);
|
||||
if (!isTransparentHole) {
|
||||
context.save();
|
||||
context.fillStyle = applyDarkModeFilter(
|
||||
renderConfig.canvasBackgroundColor,
|
||||
renderConfig.theme === THEME.DARK,
|
||||
);
|
||||
context.fillRect(holeX, holeY, holeWidth, holeHeight);
|
||||
context.restore();
|
||||
} else {
|
||||
context.clearRect(holeX, holeY, holeWidth, holeHeight);
|
||||
}
|
||||
} else {
|
||||
context.rotate(element.angle);
|
||||
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import { arrayToMap, isShallowEqual } from "@excalidraw/common";
|
||||
import { arrayToMap, isShallowEqual, type Bounds } from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
AppState,
|
||||
BoxSelectionMode,
|
||||
InteractiveCanvasAppState,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
|
||||
import { elementsOverlappingBBox, getElementAbsoluteCoords } from "./bounds";
|
||||
import { isElementInViewport } from "./sizeHelpers";
|
||||
import {
|
||||
isBoundToContainer,
|
||||
isFrameLikeElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import {
|
||||
elementOverlapsWithFrame,
|
||||
getContainingFrame,
|
||||
getFrameChildren,
|
||||
} from "./frame";
|
||||
import { getFrameChildren } from "./frame";
|
||||
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { selectGroupsForSelectedElements } from "./groups";
|
||||
@@ -25,9 +23,27 @@ import type {
|
||||
ElementsMap,
|
||||
ElementsMapOrArray,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameLikeElement,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
|
||||
const shouldIgnoreElementFromSelection = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
) => element.locked || isBoundToContainer(element);
|
||||
|
||||
const excludeElementsFromFrames = <T extends ExcalidrawElement>(
|
||||
selectedElements: readonly T[],
|
||||
framesInSelection: Set<ExcalidrawFrameLikeElement["id"]>,
|
||||
) => {
|
||||
return selectedElements.filter((element) => {
|
||||
if (element.frameId && framesInSelection.has(element.frameId)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Frames and their containing elements are not to be selected at the same time.
|
||||
* Given an array of selected elements, if there are frames and their containing elements
|
||||
@@ -47,68 +63,38 @@ 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;
|
||||
|
||||
let elementsInSelection = elements.filter((element) => {
|
||||
let [elementX1, elementY1, elementX2, elementY2] = getElementBounds(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
const containingFrame = getContainingFrame(element, elementsMap);
|
||||
if (containingFrame) {
|
||||
const [fx1, fy1, fx2, fy2] = getElementBounds(
|
||||
containingFrame,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
elementX1 = Math.max(fx1, elementX1);
|
||||
elementY1 = Math.max(fy1, elementY1);
|
||||
elementX2 = Math.min(fx2, elementX2);
|
||||
elementY2 = Math.min(fy2, elementY2);
|
||||
}
|
||||
|
||||
return (
|
||||
element.locked === false &&
|
||||
element.type !== "selection" &&
|
||||
!isBoundToContainer(element) &&
|
||||
selectionX1 <= elementX1 &&
|
||||
selectionY1 <= elementY1 &&
|
||||
selectionX2 >= elementX2 &&
|
||||
selectionY2 >= elementY2
|
||||
);
|
||||
return elementsOverlappingBBox({
|
||||
elements,
|
||||
bounds: selectionBounds,
|
||||
elementsMap,
|
||||
type: boxSelectionMode,
|
||||
shouldIgnoreElementFromSelection,
|
||||
excludeElementsInFrames,
|
||||
});
|
||||
|
||||
elementsInSelection = excludeElementsInFrames
|
||||
? excludeElementsInFramesFromSelection(elementsInSelection)
|
||||
: elementsInSelection;
|
||||
|
||||
elementsInSelection = elementsInSelection.filter((element) => {
|
||||
const containingFrame = getContainingFrame(element, elementsMap);
|
||||
|
||||
if (containingFrame) {
|
||||
return elementOverlapsWithFrame(element, containingFrame, elementsMap);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return elementsInSelection;
|
||||
};
|
||||
|
||||
export const getVisibleAndNonSelectedElements = (
|
||||
@@ -288,3 +274,19 @@ export const getSelectionStateForElements = (
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns editing or single-selected text element, if any.
|
||||
*/
|
||||
export const getActiveTextElement = (
|
||||
selectedElements: readonly NonDeleted<ExcalidrawElement>[],
|
||||
appState: Pick<AppState, "editingTextElement">,
|
||||
) => {
|
||||
const activeTextElement =
|
||||
appState.editingTextElement ||
|
||||
(selectedElements.length === 1 &&
|
||||
isTextElement(selectedElements[0]) &&
|
||||
selectedElements[0]);
|
||||
|
||||
return activeTextElement || null;
|
||||
};
|
||||
|
||||
+304
-121
@@ -1,5 +1,6 @@
|
||||
import { simplify } from "points-on-curve";
|
||||
import { getStroke } from "perfect-freehand";
|
||||
import { LaserPointer } from "@excalidraw/laser-pointer";
|
||||
|
||||
import {
|
||||
type GeometricShape,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
COLOR_PALETTE,
|
||||
LINE_POLYGON_POINT_MERGE_DISTANCE,
|
||||
applyDarkModeFilter,
|
||||
DEFAULT_STROKE_STREAMLINE,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { RoughGenerator } from "roughjs/bin/generator";
|
||||
@@ -57,8 +59,8 @@ import { headingForPointIsHorizontal } from "./heading";
|
||||
|
||||
import { canChangeRoundness } from "./comparisons";
|
||||
import {
|
||||
elementCenterPoint,
|
||||
getArrowheadPoints,
|
||||
getCenterForBounds,
|
||||
getDiamondPoints,
|
||||
getElementAbsoluteCoords,
|
||||
} from "./bounds";
|
||||
@@ -69,10 +71,10 @@ import type {
|
||||
NonDeletedExcalidrawElement,
|
||||
ExcalidrawSelectionElement,
|
||||
ExcalidrawLinearElement,
|
||||
Arrowhead,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ElementsMap,
|
||||
ExcalidrawLineElement,
|
||||
Arrowhead,
|
||||
} from "./types";
|
||||
|
||||
import type { Drawable, Options } from "roughjs/bin/core";
|
||||
@@ -218,9 +220,7 @@ export const generateRoughOptions = (
|
||||
fillWeight: element.strokeWidth / 2,
|
||||
hachureGap: element.strokeWidth * 4,
|
||||
roughness: adjustRoughness(element),
|
||||
stroke: isDarkMode
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
: element.strokeColor,
|
||||
stroke: applyDarkModeFilter(element.strokeColor, isDarkMode),
|
||||
preserveVertices:
|
||||
continuousPath || element.roughness < ROUGHNESS.cartoonist,
|
||||
};
|
||||
@@ -234,9 +234,7 @@ export const generateRoughOptions = (
|
||||
options.fillStyle = element.fillStyle;
|
||||
options.fill = isTransparent(element.backgroundColor)
|
||||
? undefined
|
||||
: isDarkMode
|
||||
? applyDarkModeFilter(element.backgroundColor)
|
||||
: element.backgroundColor;
|
||||
: applyDarkModeFilter(element.backgroundColor, isDarkMode);
|
||||
if (element.type === "ellipse") {
|
||||
options.curveFitting = 1;
|
||||
}
|
||||
@@ -249,9 +247,7 @@ export const generateRoughOptions = (
|
||||
options.fill =
|
||||
element.backgroundColor === "transparent"
|
||||
? undefined
|
||||
: isDarkMode
|
||||
? applyDarkModeFilter(element.backgroundColor)
|
||||
: element.backgroundColor;
|
||||
: applyDarkModeFilter(element.backgroundColor, isDarkMode);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
@@ -296,6 +292,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 +378,53 @@ 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 strokeColor = applyDarkModeFilter(element.strokeColor, isDarkMode);
|
||||
const backgroundFillColor = applyDarkModeFilter(
|
||||
canvasBackgroundColor,
|
||||
isDarkMode,
|
||||
);
|
||||
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 +434,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 +472,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 +591,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":
|
||||
@@ -1050,27 +1173,87 @@ const getFreeDrawSvgPath = (element: ExcalidrawFreeDrawElement) => {
|
||||
) as SVGPathString;
|
||||
};
|
||||
|
||||
export const getFreedrawOutlinePoints = (
|
||||
/**
|
||||
* Freedraw stroke geometry tuning constants.
|
||||
*
|
||||
* These factors are not derived analytically — they were tuned empirically by
|
||||
* visually comparing rendered strokes until they matched the desired feel.
|
||||
* Treat them as magic numbers backed by visual verification.
|
||||
*/
|
||||
const VARIABLE_WIDTH_FREEDRAW = {
|
||||
/** Stroke size relative to `strokeWidth` for pressure-sensitive strokes. */
|
||||
SIZE_FACTOR: 4.25,
|
||||
THINNING: 0.6,
|
||||
SMOOTHING: 0.5,
|
||||
} as const;
|
||||
|
||||
const CONSTANT_WIDTH_FREEDRAW = {
|
||||
/** Stroke size relative to `strokeWidth` for uniform (laser) strokes. */
|
||||
SIZE_FACTOR: 1.4,
|
||||
} as const;
|
||||
|
||||
const getFreedrawStreamline = (element: ExcalidrawFreeDrawElement) =>
|
||||
element.strokeOptions?.streamline ?? DEFAULT_STROKE_STREAMLINE;
|
||||
|
||||
/**
|
||||
* Pressure-sensitive (variable width) freedraw outline, rendered with
|
||||
* perfect-freehand. This is the original Excalidraw freedraw look.
|
||||
*/
|
||||
const getVariableWidthFreedrawOutline = (
|
||||
element: ExcalidrawFreeDrawElement,
|
||||
) => {
|
||||
): [number, number][] => {
|
||||
// If input points are empty (should they ever be?) return a dot
|
||||
const inputPoints = element.simulatePressure
|
||||
? element.points
|
||||
: element.points.length
|
||||
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
|
||||
? element.points.map(
|
||||
([x, y], i) => [x, y, element.pressures[i]] as [number, number, number],
|
||||
)
|
||||
: [[0, 0, 0.5]];
|
||||
|
||||
return getStroke(inputPoints as number[][], {
|
||||
simulatePressure: element.simulatePressure,
|
||||
size: element.strokeWidth * 4.25,
|
||||
thinning: 0.6,
|
||||
smoothing: 0.5,
|
||||
streamline: 0.5,
|
||||
size: element.strokeWidth * VARIABLE_WIDTH_FREEDRAW.SIZE_FACTOR,
|
||||
thinning: VARIABLE_WIDTH_FREEDRAW.THINNING,
|
||||
smoothing: VARIABLE_WIDTH_FREEDRAW.SMOOTHING,
|
||||
streamline: getFreedrawStreamline(element),
|
||||
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
|
||||
last: true,
|
||||
}) as [number, number][];
|
||||
};
|
||||
|
||||
const createLaserPointer = (element: ExcalidrawFreeDrawElement) =>
|
||||
new LaserPointer({
|
||||
size: element.strokeWidth * CONSTANT_WIDTH_FREEDRAW.SIZE_FACTOR,
|
||||
streamline: getFreedrawStreamline(element),
|
||||
simplify: 0,
|
||||
sizeMapping: (details) => Math.max(0.1, details.pressure),
|
||||
});
|
||||
|
||||
/**
|
||||
* Uniform (constant width) freedraw outline, rendered with the laser-pointer
|
||||
* geometry. Pressure is pinned to 1 so the stroke keeps a constant width.
|
||||
*/
|
||||
const getConstantWidthFreedrawOutline = (
|
||||
element: ExcalidrawFreeDrawElement,
|
||||
): [number, number][] => {
|
||||
const laserPointer = createLaserPointer(element);
|
||||
element.points.map(([x, y]) => laserPointer.addPoint([x, y, 1]));
|
||||
|
||||
return laserPointer
|
||||
.getStrokeOutline()
|
||||
.map(([x, y]) => [x, y] as [number, number]);
|
||||
};
|
||||
|
||||
export const getFreedrawOutlinePoints = (
|
||||
element: ExcalidrawFreeDrawElement,
|
||||
): [number, number][] => {
|
||||
// Unknown/absent variability falls back to the original variable rendering.
|
||||
return element.strokeOptions?.variability === "constant"
|
||||
? getConstantWidthFreedrawOutline(element)
|
||||
: getVariableWidthFreedrawOutline(element);
|
||||
};
|
||||
|
||||
const med = (A: number[], B: number[]) => {
|
||||
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
|
||||
};
|
||||
|
||||
@@ -1,59 +1,56 @@
|
||||
import { arrayToMapWithIndex } from "@excalidraw/common";
|
||||
import { arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import type { ExcalidrawElement } from "./types";
|
||||
|
||||
const normalizeGroupElementOrder = (elements: readonly ExcalidrawElement[]) => {
|
||||
const origElements: ExcalidrawElement[] = elements.slice();
|
||||
const sortedElements = new Set<ExcalidrawElement>();
|
||||
|
||||
const orderInnerGroups = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): ExcalidrawElement[] => {
|
||||
const firstGroupSig = elements[0]?.groupIds?.join("");
|
||||
const aGroup: ExcalidrawElement[] = [elements[0]];
|
||||
const bGroup: ExcalidrawElement[] = [];
|
||||
for (const element of elements.slice(1)) {
|
||||
if (element.groupIds?.join("") === firstGroupSig) {
|
||||
aGroup.push(element);
|
||||
} else {
|
||||
bGroup.push(element);
|
||||
}
|
||||
}
|
||||
return bGroup.length ? [...aGroup, ...orderInnerGroups(bGroup)] : aGroup;
|
||||
const defragmentGroups = (elements: readonly ExcalidrawElement[]) => {
|
||||
const groupIdAtLevel = (element: ExcalidrawElement, level: number) => {
|
||||
return element.groupIds[element.groupIds.length - level - 1];
|
||||
};
|
||||
|
||||
const groupHandledElements = new Map<string, true>();
|
||||
const orderLevel = (
|
||||
levelElements: readonly ExcalidrawElement[],
|
||||
level: number,
|
||||
): ExcalidrawElement[] => {
|
||||
const buckets = new Map<string, ExcalidrawElement[]>();
|
||||
// Slots preserve first-occurrence order: a groupId reserves its slot
|
||||
// the first time one of its members is seen; loose elements occupy
|
||||
// their own slot. Groups are then expanded (and recursed into) in place.
|
||||
const slots: (ExcalidrawElement | string)[] = [];
|
||||
|
||||
origElements.forEach((element, idx) => {
|
||||
if (groupHandledElements.has(element.id)) {
|
||||
return;
|
||||
}
|
||||
if (element.groupIds?.length) {
|
||||
const topGroup = element.groupIds[element.groupIds.length - 1];
|
||||
const groupElements = origElements.slice(idx).filter((element) => {
|
||||
const ret = element?.groupIds?.some((id) => id === topGroup);
|
||||
if (ret) {
|
||||
groupHandledElements.set(element!.id, true);
|
||||
}
|
||||
return ret;
|
||||
});
|
||||
|
||||
for (const elem of orderInnerGroups(groupElements)) {
|
||||
sortedElements.add(elem);
|
||||
for (const element of levelElements) {
|
||||
const groupId = groupIdAtLevel(element, level);
|
||||
if (groupId === undefined) {
|
||||
slots.push(element);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
sortedElements.add(element);
|
||||
let bucket = buckets.get(groupId);
|
||||
if (!bucket) {
|
||||
bucket = [];
|
||||
buckets.set(groupId, bucket);
|
||||
slots.push(groupId);
|
||||
}
|
||||
bucket.push(element);
|
||||
}
|
||||
});
|
||||
|
||||
return slots.flatMap((slot) =>
|
||||
typeof slot === "string"
|
||||
? orderLevel(buckets.get(slot)!, level + 1)
|
||||
: [slot],
|
||||
);
|
||||
};
|
||||
|
||||
// `groupIds` is stored innermost-first, so the outermost group is the
|
||||
// last entry. We recurse from level 0 (outermost) inward.
|
||||
const sortedElements = orderLevel(elements, 0);
|
||||
|
||||
// if there's a bug which resulted in losing some of the elements, return
|
||||
// original instead as that's better than losing data
|
||||
if (sortedElements.size !== elements.length) {
|
||||
console.error("normalizeGroupElementOrder: lost some elements... bailing!");
|
||||
if (sortedElements.length !== elements.length) {
|
||||
console.error("defragmentGroups: lost some elements... bailing!");
|
||||
return elements;
|
||||
}
|
||||
|
||||
return [...sortedElements];
|
||||
return sortedElements;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -68,39 +65,40 @@ const normalizeGroupElementOrder = (elements: readonly ExcalidrawElement[]) => {
|
||||
const normalizeBoundElementsOrder = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => {
|
||||
const elementsMap = arrayToMapWithIndex(elements);
|
||||
const elementsMap = arrayToMap(elements);
|
||||
|
||||
const origElements: (ExcalidrawElement | null)[] = elements.slice();
|
||||
const sortedElements = new Set<ExcalidrawElement>();
|
||||
|
||||
origElements.forEach((element, idx) => {
|
||||
if (!element) {
|
||||
return;
|
||||
for (const element of elements) {
|
||||
if (sortedElements.has(element)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (element.boundElements?.length) {
|
||||
sortedElements.add(element);
|
||||
origElements[idx] = null;
|
||||
element.boundElements.forEach((boundElement) => {
|
||||
for (const boundElement of element.boundElements) {
|
||||
const child = elementsMap.get(boundElement.id);
|
||||
if (child && boundElement.type === "text") {
|
||||
sortedElements.add(child[0]);
|
||||
origElements[child[1]] = null;
|
||||
sortedElements.add(child);
|
||||
}
|
||||
});
|
||||
} else if (element.type === "text" && element.containerId) {
|
||||
const parent = elementsMap.get(element.containerId);
|
||||
if (!parent?.[0].boundElements?.find((x) => x.id === element.id)) {
|
||||
sortedElements.add(element);
|
||||
origElements[idx] = null;
|
||||
|
||||
// if element has a container and container lists it, skip this element
|
||||
// as it'll be taken care of by the container
|
||||
}
|
||||
} else {
|
||||
sortedElements.add(element);
|
||||
origElements[idx] = null;
|
||||
continue;
|
||||
}
|
||||
});
|
||||
|
||||
// if element has a container and container lists it, skip this element
|
||||
// as it'll be taken care of by the container
|
||||
if (
|
||||
element.type === "text" &&
|
||||
element.containerId &&
|
||||
elementsMap
|
||||
.get(element.containerId)
|
||||
?.boundElements?.some((el) => el.id === element.id)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
sortedElements.add(element);
|
||||
}
|
||||
|
||||
// if there's a bug which resulted in losing some of the elements, return
|
||||
// original instead as that's better than losing data
|
||||
@@ -117,5 +115,5 @@ const normalizeBoundElementsOrder = (
|
||||
export const normalizeElementOrder = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => {
|
||||
return normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
|
||||
return normalizeBoundElementsOrder(defragmentGroups(elements));
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,6 +4,22 @@ import { charWidth, getLineWidth } from "./textMeasurements";
|
||||
|
||||
import type { FontString } from "./types";
|
||||
|
||||
/**
|
||||
* This module approximates browser-like soft wrapping for Excalidraw text.
|
||||
*
|
||||
* The flow is:
|
||||
* 1. `parseTokens()` splits a hard line into breakable tokens using a unicode-aware regex.
|
||||
* 2. `getWrappedTextLines()` reflows each hard line into one or more visual lines and
|
||||
* records where each visual line came from in the source text.
|
||||
* 3. `wrapLine()` assembles tokens into lines, and `wrapWord()` handles a single token
|
||||
* that is wider than the available width.
|
||||
* 4. `trimLine()` / `trimLineEndAtSoftBreak()` mirror browser behavior around trailing
|
||||
* whitespace so the rendered text stays consistent with what users see on canvas.
|
||||
*
|
||||
* Mostly, you'll want to use wrapText(). getWrappedTextLines() is for callers
|
||||
* that need metadata such as mapping visual lines back to `originalText`
|
||||
* for caret placement or future editor features.
|
||||
*/
|
||||
let cachedCjkRegex: RegExp | undefined;
|
||||
let cachedLineBreakRegex: RegExp | undefined;
|
||||
let cachedEmojiRegex: RegExp | undefined;
|
||||
@@ -358,6 +374,10 @@ const Break = {
|
||||
|
||||
/**
|
||||
* Breaks the line into the tokens based on the found line break opporutnities.
|
||||
*
|
||||
* Note: tokenization normalizes to NFC first so decomposed graphemes are treated as
|
||||
* their composed variants for wrapping. Any code that needs exact source offsets should
|
||||
* keep in mind that this assumes the input text is already NFC-normalized.
|
||||
*/
|
||||
export const parseTokens = (line: string) => {
|
||||
const breakLineRegex = getLineBreakRegex();
|
||||
@@ -370,56 +390,120 @@ export const parseTokens = (line: string) => {
|
||||
|
||||
/**
|
||||
* Wraps the original text into the lines based on the given width.
|
||||
*
|
||||
* This is a convenience adapter over `getWrappedTextLines()` for call sites
|
||||
* that only need the rendered wrapped string and not the source offsets.
|
||||
*/
|
||||
export const wrapText = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
maxWidth: number,
|
||||
): string => {
|
||||
return getWrappedTextLines(text, font, maxWidth)
|
||||
.map((line) => line.text)
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
/**
|
||||
* A single rendered visual line produced from the original text.
|
||||
*
|
||||
* `start` and `end` are end-exclusive code-unit offsets into the original text, and do
|
||||
* not include synthetic soft line breaks inserted by this module. If trailing whitespace
|
||||
* was trimmed away at a wrap boundary, `end` points to the last rendered character.
|
||||
*/
|
||||
export type WrappedTextLine = {
|
||||
text: string;
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Splits only on existing hard line breaks and preserves original offsets.
|
||||
*/
|
||||
const getHardLineBreaks = (text: string): WrappedTextLine[] => {
|
||||
let offset = 0;
|
||||
|
||||
return text.split("\n").map((line) => {
|
||||
const start = offset;
|
||||
const end = start + line.length;
|
||||
|
||||
offset = end + 1;
|
||||
|
||||
return {
|
||||
text: line,
|
||||
start,
|
||||
end,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the rendered visual lines together with their source offsets.
|
||||
*
|
||||
* This is the source-of-truth wrapping pipeline for callers that need more than the
|
||||
* final wrapped string, for example caret placement or future editor/rich-text mapping.
|
||||
*/
|
||||
export const getWrappedTextLines = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
maxWidth: number,
|
||||
): WrappedTextLine[] => {
|
||||
// if maxWidth is not finite or NaN which can happen in case of bugs in
|
||||
// computation, we need to make sure we don't continue as we'll end up
|
||||
// in an infinite loop
|
||||
if (!Number.isFinite(maxWidth) || maxWidth < 0) {
|
||||
return text;
|
||||
return getHardLineBreaks(text);
|
||||
}
|
||||
|
||||
const lines: Array<string> = [];
|
||||
const originalLines = text.split("\n");
|
||||
const lines: WrappedTextLine[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const originalLine of originalLines) {
|
||||
const currentLineWidth = getLineWidth(originalLine, font);
|
||||
for (const originalLine of text.split("\n")) {
|
||||
const originalLineWidth = getLineWidth(originalLine, font);
|
||||
|
||||
if (currentLineWidth <= maxWidth) {
|
||||
lines.push(originalLine);
|
||||
continue;
|
||||
if (originalLineWidth <= maxWidth) {
|
||||
lines.push({
|
||||
text: originalLine,
|
||||
start: offset,
|
||||
end: offset + originalLine.length,
|
||||
});
|
||||
} else {
|
||||
lines.push(...wrapLine(originalLine, font, maxWidth, offset));
|
||||
}
|
||||
|
||||
const wrappedLine = wrapLine(originalLine, font, maxWidth);
|
||||
lines.push(...wrappedLine);
|
||||
offset += originalLine.length + 1;
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
return lines;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps the original line into the lines based on the given width.
|
||||
* Wraps a single hard line into one or more visual lines.
|
||||
*
|
||||
* The line-local offsets are tracked in original-text code units so
|
||||
* we can map the visual line back to the source.
|
||||
*/
|
||||
const wrapLine = (
|
||||
line: string,
|
||||
font: FontString,
|
||||
maxWidth: number,
|
||||
): string[] => {
|
||||
const lines: Array<string> = [];
|
||||
lineStart: number,
|
||||
): WrappedTextLine[] => {
|
||||
const lines: WrappedTextLine[] = [];
|
||||
const tokens = parseTokens(line);
|
||||
const tokenIterator = tokens[Symbol.iterator]();
|
||||
|
||||
let currentLine = "";
|
||||
let currentLineStart = lineStart;
|
||||
let currentLineEnd = lineStart;
|
||||
let currentLineWidth = 0;
|
||||
// Tracks the next token's code-unit position in the original source string.
|
||||
let tokenOffset = lineStart;
|
||||
let tokenIndex = 0;
|
||||
|
||||
let iterator = tokenIterator.next();
|
||||
|
||||
while (!iterator.done) {
|
||||
const token = iterator.value;
|
||||
while (tokenIndex < tokens.length) {
|
||||
const token = tokens[tokenIndex];
|
||||
const tokenStart = tokenOffset;
|
||||
const tokenEnd = tokenStart + token.length;
|
||||
const testLine = currentLine + token;
|
||||
|
||||
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
|
||||
@@ -429,37 +513,59 @@ const wrapLine = (
|
||||
|
||||
// build up the current line, skipping length check for possibly trailing whitespaces
|
||||
if (/\s/.test(token) || testLineWidth <= maxWidth) {
|
||||
if (!currentLine) {
|
||||
currentLineStart = tokenStart;
|
||||
}
|
||||
currentLine = testLine;
|
||||
currentLineEnd = tokenEnd;
|
||||
currentLineWidth = testLineWidth;
|
||||
iterator = tokenIterator.next();
|
||||
tokenOffset = tokenEnd;
|
||||
tokenIndex++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped
|
||||
if (!currentLine) {
|
||||
const wrappedWord = wrapWord(token, font, maxWidth);
|
||||
const trailingLine = wrappedWord[wrappedWord.length - 1] ?? "";
|
||||
const wrappedWord = wrapWord(token, font, maxWidth, tokenStart);
|
||||
const trailingLine = wrappedWord[wrappedWord.length - 1] ?? {
|
||||
text: "",
|
||||
start: tokenStart,
|
||||
end: tokenStart,
|
||||
};
|
||||
const precedingLines = wrappedWord.slice(0, -1);
|
||||
|
||||
lines.push(...precedingLines);
|
||||
|
||||
// trailing line of the wrapped word might still be joined with next token/s
|
||||
currentLine = trailingLine;
|
||||
currentLineWidth = getLineWidth(trailingLine, font);
|
||||
iterator = tokenIterator.next();
|
||||
currentLine = trailingLine.text;
|
||||
currentLineStart = trailingLine.start;
|
||||
currentLineEnd = trailingLine.end;
|
||||
currentLineWidth = getLineWidth(trailingLine.text, font);
|
||||
tokenOffset = tokenEnd;
|
||||
tokenIndex++;
|
||||
} else {
|
||||
// push & reset, but don't iterate on the next token, as we didn't use it yet!
|
||||
lines.push(currentLine.trimEnd());
|
||||
lines.push(
|
||||
trimLineEndAtSoftBreak(currentLine, currentLineStart, currentLineEnd),
|
||||
);
|
||||
|
||||
// purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above
|
||||
currentLine = "";
|
||||
currentLineStart = tokenStart;
|
||||
currentLineEnd = tokenStart;
|
||||
currentLineWidth = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// iterator done, push the trailing line if exists
|
||||
if (currentLine) {
|
||||
const trailingLine = trimLine(currentLine, font, maxWidth);
|
||||
const trailingLine = trimLine(
|
||||
currentLine,
|
||||
currentLineStart,
|
||||
currentLineEnd,
|
||||
font,
|
||||
maxWidth,
|
||||
);
|
||||
lines.push(trailingLine);
|
||||
}
|
||||
|
||||
@@ -467,59 +573,100 @@ const wrapLine = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps the word into the lines based on the given width.
|
||||
* Wraps a single word that could not be placed on an empty line as-is.
|
||||
*/
|
||||
const wrapWord = (
|
||||
word: string,
|
||||
font: FontString,
|
||||
maxWidth: number,
|
||||
): Array<string> => {
|
||||
wordStart: number,
|
||||
): WrappedTextLine[] => {
|
||||
// multi-codepoint emojis are already broken apart and shouldn't be broken further
|
||||
if (getEmojiRegex().test(word)) {
|
||||
return [word];
|
||||
return [
|
||||
{
|
||||
text: word,
|
||||
start: wordStart,
|
||||
end: wordStart + word.length,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
satisfiesWordInvariant(word);
|
||||
|
||||
const lines: Array<string> = [];
|
||||
const lines: WrappedTextLine[] = [];
|
||||
const chars = Array.from(word);
|
||||
|
||||
let currentLine = "";
|
||||
let currentLineStart = wordStart;
|
||||
let currentLineEnd = wordStart;
|
||||
let currentLineWidth = 0;
|
||||
let offset = wordStart;
|
||||
|
||||
for (const char of chars) {
|
||||
const charStart = offset;
|
||||
const charEnd = charStart + char.length;
|
||||
const _charWidth = charWidth.calculate(char, font);
|
||||
const testLineWidth = currentLineWidth + _charWidth;
|
||||
|
||||
if (testLineWidth <= maxWidth) {
|
||||
if (!currentLine) {
|
||||
currentLineStart = charStart;
|
||||
}
|
||||
currentLine = currentLine + char;
|
||||
currentLineEnd = charEnd;
|
||||
currentLineWidth = testLineWidth;
|
||||
offset = charEnd;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
lines.push(currentLine);
|
||||
lines.push({
|
||||
text: currentLine,
|
||||
start: currentLineStart,
|
||||
end: currentLineEnd,
|
||||
});
|
||||
}
|
||||
|
||||
currentLine = char;
|
||||
currentLineStart = charStart;
|
||||
currentLineEnd = charEnd;
|
||||
currentLineWidth = _charWidth;
|
||||
offset = charEnd;
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
lines.push(currentLine);
|
||||
lines.push({
|
||||
text: currentLine,
|
||||
start: currentLineStart,
|
||||
end: currentLineEnd,
|
||||
});
|
||||
}
|
||||
|
||||
return lines;
|
||||
};
|
||||
|
||||
/**
|
||||
* Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`.
|
||||
* Trims trailing whitespace that is exceeding the `maxWidth`.
|
||||
*
|
||||
* Used for the trailing visual line of a hard line, where some trailing
|
||||
* whitespace may still be visible if it fits into the available width.
|
||||
*/
|
||||
const trimLine = (line: string, font: FontString, maxWidth: number) => {
|
||||
const trimLine = (
|
||||
line: string,
|
||||
start: number,
|
||||
end: number,
|
||||
font: FontString,
|
||||
maxWidth: number,
|
||||
): WrappedTextLine => {
|
||||
const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth;
|
||||
|
||||
if (!shouldTrimWhitespaces) {
|
||||
return line;
|
||||
return {
|
||||
text: line,
|
||||
start,
|
||||
end,
|
||||
};
|
||||
}
|
||||
|
||||
// defensively default to `trimeEnd` in case the regex does not match
|
||||
@@ -543,7 +690,30 @@ const trimLine = (line: string, font: FontString, maxWidth: number) => {
|
||||
trimmedLineWidth = testLineWidth;
|
||||
}
|
||||
|
||||
return trimmedLine;
|
||||
return {
|
||||
text: trimmedLine,
|
||||
start,
|
||||
end: end - (line.length - trimmedLine.length),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Used for internal soft-wrap boundaries, where trailing whitespace should not
|
||||
* survive into the rendered line even though it still exists in the original
|
||||
* text.
|
||||
*/
|
||||
const trimLineEndAtSoftBreak = (
|
||||
line: string,
|
||||
start: number,
|
||||
end: number,
|
||||
): WrappedTextLine => {
|
||||
const trimmedLine = line.trimEnd();
|
||||
|
||||
return {
|
||||
text: trimmedLine,
|
||||
start,
|
||||
end: end - (line.length - trimmedLine.length),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<{
|
||||
@@ -371,12 +384,20 @@ export type ExcalidrawElbowArrowElement = Merge<
|
||||
}
|
||||
>;
|
||||
|
||||
export type StrokeVariability = "variable" | "constant";
|
||||
|
||||
export type StrokeOptions = Readonly<{
|
||||
variability: StrokeVariability;
|
||||
streamline: number;
|
||||
}>;
|
||||
|
||||
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "freedraw";
|
||||
points: readonly LocalPoint[];
|
||||
pressures: readonly number[];
|
||||
simulatePressure: boolean;
|
||||
strokeOptions: StrokeOptions;
|
||||
}>;
|
||||
|
||||
export type FileId = string & { _brand: "FileId" };
|
||||
|
||||
@@ -124,6 +124,7 @@ const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
|
||||
*/
|
||||
export function deconstructLinearOrFreeDrawElement(
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
const cachedShape = getElementShapesCacheEntry(element, 0);
|
||||
|
||||
@@ -131,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 = [];
|
||||
|
||||
@@ -659,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
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
pointFrom,
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
type LineSegment,
|
||||
} from "@excalidraw/math";
|
||||
import { type Bounds, isBounds } from "@excalidraw/common";
|
||||
import {
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
|
||||
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
|
||||
import type { Curve } from "@excalidraw/math";
|
||||
import type { LineSegment } from "@excalidraw/utils";
|
||||
|
||||
// The global data holder to collect the debug operations
|
||||
declare global {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { arrayToMap, findIndex, findLastIndex } from "@excalidraw/common";
|
||||
|
||||
import { isFiniteNumber } from "@excalidraw/math";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
import type { GlobalPoint } from "@excalidraw/math";
|
||||
|
||||
@@ -313,12 +315,46 @@ const getTargetElementsMap = <T extends ExcalidrawElement>(
|
||||
}, new Map<string, ExcalidrawElement>());
|
||||
};
|
||||
|
||||
const hasSameElementIds = (
|
||||
prevElements: readonly ExcalidrawElement[],
|
||||
nextElements: readonly ExcalidrawElement[],
|
||||
) => {
|
||||
if (prevElements.length !== nextElements.length) {
|
||||
console.error(
|
||||
"z-index reordering failed: resulting array have different lengths",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const prevElementIdCounts = new Map<ExcalidrawElement["id"], number>();
|
||||
for (const element of prevElements) {
|
||||
prevElementIdCounts.set(
|
||||
element.id,
|
||||
(prevElementIdCounts.get(element.id) || 0) + 1,
|
||||
);
|
||||
}
|
||||
|
||||
for (const element of nextElements) {
|
||||
const count = prevElementIdCounts.get(element.id);
|
||||
if (!count) {
|
||||
console.error(
|
||||
"z-index reordering failed: element id mismatch / duplicate ids",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
prevElementIdCounts.set(element.id, count - 1);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const shiftElementsByOne = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
direction: "left" | "right",
|
||||
scene: Scene,
|
||||
) => {
|
||||
const originalElements = elements;
|
||||
const indicesToMove = getIndicesToMove(elements, appState);
|
||||
const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
|
||||
|
||||
@@ -389,6 +425,10 @@ const shiftElementsByOne = (
|
||||
];
|
||||
});
|
||||
|
||||
if (!hasSameElementIds(originalElements, elements)) {
|
||||
return originalElements;
|
||||
}
|
||||
|
||||
syncMovedIndices(elements, targetElementsMap);
|
||||
|
||||
return elements;
|
||||
@@ -402,11 +442,20 @@ const shiftElementsToEnd = (
|
||||
elementsToBeMoved?: readonly ExcalidrawElement[],
|
||||
) => {
|
||||
const indicesToMove = getIndicesToMove(elements, appState, elementsToBeMoved);
|
||||
|
||||
// Nothing to move (e.g. `elementsToBeMoved` is empty because all selected
|
||||
// elements were frame children handled in a prior pass). Bail out early —
|
||||
// otherwise `leadingIndex`/`trailingIndex` below resolve to `undefined` and
|
||||
// the resulting `slice()` calls overlap, duplicating elements.
|
||||
if (indicesToMove.length === 0) {
|
||||
return elements;
|
||||
}
|
||||
|
||||
const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
|
||||
const displacedElements: ExcalidrawElement[] = [];
|
||||
|
||||
let leadingIndex: number;
|
||||
let trailingIndex: number;
|
||||
let leadingIndex: number | undefined;
|
||||
let trailingIndex: number | undefined;
|
||||
if (direction === "left") {
|
||||
if (containingFrame) {
|
||||
leadingIndex = findIndex(elements, (el) =>
|
||||
@@ -451,6 +500,19 @@ const shiftElementsToEnd = (
|
||||
leadingIndex = 0;
|
||||
}
|
||||
|
||||
const isValidIndex = (index: number | undefined): index is number => {
|
||||
return isFiniteNumber(index) && index >= 0;
|
||||
};
|
||||
|
||||
if (
|
||||
!isValidIndex(leadingIndex) ||
|
||||
!isValidIndex(trailingIndex) ||
|
||||
leadingIndex > trailingIndex ||
|
||||
indicesToMove.some((index) => index < leadingIndex || index > trailingIndex)
|
||||
) {
|
||||
return elements;
|
||||
}
|
||||
|
||||
for (let index = leadingIndex; index < trailingIndex + 1; index++) {
|
||||
if (!indicesToMove.includes(index)) {
|
||||
displacedElements.push(elements[index]);
|
||||
@@ -475,6 +537,10 @@ const shiftElementsToEnd = (
|
||||
...trailingElements,
|
||||
];
|
||||
|
||||
if (!hasSameElementIds(elements, nextElements)) {
|
||||
return elements;
|
||||
}
|
||||
|
||||
syncMovedIndices(nextElements, targetElementsMap);
|
||||
|
||||
return nextElements;
|
||||
@@ -543,7 +609,7 @@ function shiftElementsAccountingForFrames(
|
||||
|
||||
for (const [frameId, children] of frameChildrenSets) {
|
||||
nextElements = shiftFunction(
|
||||
allElements,
|
||||
nextElements,
|
||||
appState,
|
||||
direction,
|
||||
frameId,
|
||||
|
||||
@@ -178,6 +178,64 @@ describe("binding for simple arrows", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("self-binding (both ends to the same element) single-click finalize", () => {
|
||||
// rect spans x:200..400, y:200..400; orbit ring is ~15px outside the outline
|
||||
const INSIDE: [number, number] = [250, 250];
|
||||
const ORBIT_LEFT: [number, number] = [187, 300];
|
||||
const ORBIT_RIGHT: [number, number] = [413, 300];
|
||||
const MIDDLE: [number, number] = [550, 100];
|
||||
|
||||
beforeEach(async () => {
|
||||
mouse.reset();
|
||||
await act(() => setLanguage(defaultLang));
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
UI.createElement("rectangle", {
|
||||
x: 200,
|
||||
y: 200,
|
||||
width: 200,
|
||||
height: 200,
|
||||
});
|
||||
});
|
||||
|
||||
const drawSelfArrow = (start: [number, number], end: [number, number]) => {
|
||||
UI.clickTool("arrow");
|
||||
mouse.reset();
|
||||
mouse.clickAt(...start);
|
||||
mouse.moveTo(...MIDDLE);
|
||||
mouse.clickAt(...MIDDLE); // commit a middle point so it's a multi-point arrow
|
||||
mouse.moveTo(...end);
|
||||
mouse.clickAt(...end); // single click at the end
|
||||
};
|
||||
|
||||
it("orbit -> orbit finalizes on a single click", () => {
|
||||
drawSelfArrow(ORBIT_LEFT, ORBIT_RIGHT);
|
||||
|
||||
const arrow = h.elements[h.elements.length - 1] as ExcalidrawArrowElement;
|
||||
expect(h.state.multiElement).toBe(null);
|
||||
expect(h.state.activeTool.type).toBe("selection");
|
||||
expect(arrow.startBinding?.elementId).toBe(arrow.endBinding?.elementId);
|
||||
expect(arrow.endBinding?.elementId).not.toBe(undefined);
|
||||
});
|
||||
|
||||
it("inside -> orbit finalizes on a single click", () => {
|
||||
drawSelfArrow(INSIDE, ORBIT_RIGHT);
|
||||
|
||||
const arrow = h.elements[h.elements.length - 1] as ExcalidrawArrowElement;
|
||||
expect(h.state.multiElement).toBe(null);
|
||||
expect(h.state.activeTool.type).toBe("selection");
|
||||
expect(arrow.startBinding?.elementId).toBe(arrow.endBinding?.elementId);
|
||||
expect(arrow.endBinding?.elementId).not.toBe(undefined);
|
||||
});
|
||||
|
||||
it("inside -> inside keep in multi-point mode (no single-click finalize)", () => {
|
||||
drawSelfArrow(INSIDE, [INSIDE[0] + 50, INSIDE[1] + 50]); // end dropped inside the rect
|
||||
|
||||
// ambiguous → must be confirmed with a second click, so still in progress
|
||||
expect(h.state.multiElement).not.toBe(null);
|
||||
expect(h.state.activeTool.type).toBe("arrow");
|
||||
});
|
||||
});
|
||||
|
||||
describe("when arrow is outside of shape", () => {
|
||||
beforeEach(async () => {
|
||||
mouse.reset();
|
||||
@@ -403,6 +461,7 @@ describe("binding for simple arrows", () => {
|
||||
mouse.moveTo(340, 251);
|
||||
mouse.moveTo(410, 251);
|
||||
mouse.clickAt(410, 251);
|
||||
mouse.clickAt(410, 251);
|
||||
const arrow = h.elements[h.elements.length - 1] as any;
|
||||
|
||||
expect(arrow.startBinding?.elementId).toBe(rectLeft.id);
|
||||
@@ -447,6 +506,7 @@ describe("binding for simple arrows", () => {
|
||||
mouse.moveTo(350, 251);
|
||||
mouse.moveTo(410, 251);
|
||||
mouse.clickAt(410, 251);
|
||||
mouse.clickAt(410, 251);
|
||||
|
||||
const arrow = API.getSelectedElement() as ExcalidrawArrowElement;
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
|
||||
import { arrayToMap, ROUNDNESS } from "@excalidraw/common";
|
||||
import { arrayToMap, type Bounds, ROUNDNESS } from "@excalidraw/common";
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { getElementAbsoluteCoords, getElementBounds } from "../src/bounds";
|
||||
import {
|
||||
elementsOverlappingBBox,
|
||||
getElementAbsoluteCoords,
|
||||
getElementBounds,
|
||||
} from "../src/bounds";
|
||||
|
||||
import type { ExcalidrawElement, ExcalidrawLinearElement } from "../src/types";
|
||||
|
||||
@@ -141,3 +145,65 @@ describe("getElementBounds", () => {
|
||||
expect(y2).toEqual(319.8162855827246);
|
||||
});
|
||||
});
|
||||
|
||||
const makeElement = (x: number, y: number, width: number, height: number) =>
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
const makeBBox = (
|
||||
minX: number,
|
||||
minY: number,
|
||||
maxX: number,
|
||||
maxY: number,
|
||||
): Bounds => [minX, minY, maxX, maxY];
|
||||
|
||||
describe("elementsOverlappingBBox()", () => {
|
||||
it("should return elements that overlap bbox", () => {
|
||||
const bbox = makeBBox(0, 0, 100, 100);
|
||||
|
||||
const rectOutside = makeElement(110, 110, 100, 100);
|
||||
const rectInside = makeElement(10, 10, 85, 85);
|
||||
const rectContainingBBox = makeElement(-10, -10, 110, 110);
|
||||
const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
|
||||
|
||||
expect(
|
||||
elementsOverlappingBBox({
|
||||
bounds: bbox,
|
||||
type: "overlap",
|
||||
elements: [
|
||||
rectOutside,
|
||||
rectInside,
|
||||
rectContainingBBox,
|
||||
rectOverlappingTopLeft,
|
||||
],
|
||||
}),
|
||||
).toEqual([rectInside, rectOverlappingTopLeft]);
|
||||
});
|
||||
|
||||
it("should return elements inside/containing bbox", () => {
|
||||
const bbox = makeBBox(0, 0, 100, 100);
|
||||
|
||||
const rectOutside = makeElement(110, 110, 100, 100);
|
||||
const rectInside = makeElement(10, 10, 85, 85);
|
||||
const rectContainingBBox = makeElement(-10, -10, 110, 110);
|
||||
const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
|
||||
|
||||
expect(
|
||||
elementsOverlappingBBox({
|
||||
bounds: bbox,
|
||||
type: "contain",
|
||||
elements: [
|
||||
rectOutside,
|
||||
rectInside,
|
||||
rectContainingBBox,
|
||||
rectOverlappingTopLeft,
|
||||
],
|
||||
}),
|
||||
).toEqual([rectInside]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { arrayToMap } from "@excalidraw/common";
|
||||
import { arrayToMap, reseed } from "@excalidraw/common";
|
||||
import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
@@ -12,6 +12,7 @@ import { hitElementItself } from "../src/collision";
|
||||
describe("check rotated elements can be hit:", () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
reseed(7);
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
@@ -56,6 +57,7 @@ describe("hitElementItself cache", () => {
|
||||
});
|
||||
|
||||
localStorage.clear();
|
||||
reseed(7);
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,373 @@ 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 move an element dragged from one frame into another", () => {
|
||||
const otherFrame = API.createElement({
|
||||
id: "otherFrame",
|
||||
type: "frame",
|
||||
x: 300,
|
||||
y: 0,
|
||||
width: 150,
|
||||
height: 150,
|
||||
});
|
||||
const frameChild = API.createElement({
|
||||
id: "frameChild",
|
||||
type: "rectangle",
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 20,
|
||||
height: 20,
|
||||
frameId: frame.id,
|
||||
});
|
||||
|
||||
API.setElements([frame, frameChild, otherFrame]);
|
||||
|
||||
expect(frameChild.frameId).toBe(frame.id);
|
||||
|
||||
dragElementIntoFrame(otherFrame, frameChild);
|
||||
|
||||
expect(frameChild.frameId).toBe(otherFrame.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]);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1509,4 +1509,190 @@ describe("z-indexing with frames", () => {
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("bringing to front / sending to back children of MULTIPLE frames at once moves all of them", () => {
|
||||
assertZindex({
|
||||
elements: [
|
||||
{ id: "F1_1", frameId: "F1", isSelected: true },
|
||||
{ id: "F1_2", frameId: "F1" },
|
||||
{ id: "F1", type: "frame" },
|
||||
{ id: "F2_1", frameId: "F2", isSelected: true },
|
||||
{ id: "F2_2", frameId: "F2" },
|
||||
{ id: "F2", type: "frame" },
|
||||
],
|
||||
operations: [
|
||||
// +∞: each selected child moves to the front of its own frame
|
||||
[actionBringToFront, ["F1_2", "F1", "F1_1", "F2_2", "F2", "F2_1"]],
|
||||
// -∞: each selected child moves to the back of its own frame
|
||||
[actionSendToBack, ["F1_1", "F1_2", "F1", "F2_1", "F2_2", "F2"]],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("send to back / bring to front of a grouped frame child (in group-editing mode) must not duplicate elements", () => {
|
||||
assertZindex({
|
||||
elements: [
|
||||
{ id: "F1_1", frameId: "F1", groupIds: ["g1"] },
|
||||
{ id: "F1_2", frameId: "F1", groupIds: ["g1"], isSelected: true },
|
||||
{ id: "F1", type: "frame" },
|
||||
{ id: "F2_1", frameId: "F2", groupIds: ["g2"] },
|
||||
{ id: "F2_2", frameId: "F2", groupIds: ["g2"] },
|
||||
{ id: "F2", type: "frame" },
|
||||
],
|
||||
appState: { editingGroupId: "g1" },
|
||||
operations: [
|
||||
// -∞ (send to back, within the frame)
|
||||
[actionSendToBack, ["F1_2", "F1_1", "F1", "F2_1", "F2_2", "F2"]],
|
||||
// +∞ (bring to front, within the frame)
|
||||
[actionBringToFront, ["F1_1", "F1", "F1_2", "F2_1", "F2_2", "F2"]],
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The inputs in this block intentionally VIOLATE the (soft) invariant that a
|
||||
* frame's children — and a group's members — are contiguous in the elements
|
||||
* array. Such states shouldn't occur in normal use, but they CAN arise from
|
||||
* bugs or broken input, because nothing re-defragments element order during
|
||||
* a reorder (`normalizeElementOrder` only runs on duplication). We keep these
|
||||
* tests so the reordering ops stay exercised against malformed order.
|
||||
*
|
||||
* HARD CONTRACT (a failure here is a real bug): a reorder must never throw,
|
||||
* duplicate, or drop elements. `assertReorderPreservesElements` checks this.
|
||||
*
|
||||
* SOFT SNAPSHOT (read before "fixing"): the exact resulting ORDER is NOT a
|
||||
* contract for invalid input — it's whatever the slice math happens to
|
||||
* produce. If a future change alters an `expected` order below, that is NOT
|
||||
* necessarily a functional regression. First confirm from the diff that the
|
||||
* hard contract still holds (nothing duplicated/lost), then update the
|
||||
* expected order to match, provided it's deemed an improvement over the
|
||||
* previous order, or it's an acceptable change given the underlying logic
|
||||
* change.
|
||||
*/
|
||||
describe("z-index reordering with broken contiguity (invariant-violating input)", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw />);
|
||||
});
|
||||
|
||||
const assertReorderPreservesElements = (
|
||||
elements: Parameters<typeof populateElements>[0],
|
||||
appState: Parameters<typeof populateElements>[1],
|
||||
// each op is applied to a freshly-populated (broken) state
|
||||
cases: [Actions, string[]][],
|
||||
) => {
|
||||
for (const [action, expected] of cases) {
|
||||
populateElements(elements, appState);
|
||||
const before = h.elements.map((el) => el.id);
|
||||
|
||||
expect(() => API.executeAction(action)).not.toThrow();
|
||||
|
||||
const after = h.elements.map((el) => el.id);
|
||||
// hard contract:
|
||||
expect(after.length).toBe(before.length); // no loss
|
||||
expect(new Set(after).size).toBe(after.length); // no duplication
|
||||
// soft snapshot (see block comment before changing):
|
||||
expect(after).toEqual(expected);
|
||||
}
|
||||
};
|
||||
|
||||
it("discontiguous frame children (foreign frame's child interleaved in span)", () => {
|
||||
// F2_1 (a child of frame F2) sits INSIDE frame F1's z-span. Reordering F1's
|
||||
// child sweeps F2_1 along (span-based frame handling) — wrong ordering, but
|
||||
// never a duplication/loss, and the op does not throw.
|
||||
const elements: Parameters<typeof populateElements>[0] = [
|
||||
{ id: "F1_1", frameId: "F1", isSelected: true },
|
||||
{ id: "F2_1", frameId: "F2" },
|
||||
{ id: "F1_2", frameId: "F1" },
|
||||
{ id: "F1", type: "frame" },
|
||||
{ id: "F2", type: "frame" },
|
||||
];
|
||||
assertReorderPreservesElements(elements, undefined, [
|
||||
[actionBringForward, ["F2_1", "F1_2", "F1_1", "F1", "F2"]],
|
||||
[actionSendBackward, ["F1_1", "F2_1", "F1_2", "F1", "F2"]],
|
||||
[actionBringToFront, ["F2_1", "F1_2", "F1", "F1_1", "F2"]],
|
||||
[actionSendToBack, ["F1_1", "F2_1", "F1_2", "F1", "F2"]],
|
||||
]);
|
||||
});
|
||||
|
||||
it("discontiguous group, whole group selected", () => {
|
||||
// g1 = {A, C}, scattered by the loose elements B and D.
|
||||
const elements: Parameters<typeof populateElements>[0] = [
|
||||
{ id: "A", groupIds: ["g1"], isSelected: true },
|
||||
{ id: "B" },
|
||||
{ id: "C", groupIds: ["g1"], isSelected: true },
|
||||
{ id: "D" },
|
||||
];
|
||||
assertReorderPreservesElements(elements, undefined, [
|
||||
// move-by-one leaves the group scattered (each run moves independently)
|
||||
[actionBringForward, ["B", "A", "D", "C"]],
|
||||
[actionSendBackward, ["A", "C", "B", "D"]],
|
||||
// to-front / to-back gather the scattered members back into one block
|
||||
[actionBringToFront, ["B", "D", "A", "C"]],
|
||||
[actionSendToBack, ["A", "C", "B", "D"]],
|
||||
]);
|
||||
});
|
||||
|
||||
it("discontiguous group, single member selected in group-editing mode", () => {
|
||||
const elements: Parameters<typeof populateElements>[0] = [
|
||||
{ id: "A", groupIds: ["g1"] },
|
||||
{ id: "B" },
|
||||
{ id: "C", groupIds: ["g1"], isSelected: true },
|
||||
{ id: "D" },
|
||||
];
|
||||
assertReorderPreservesElements(elements, { editingGroupId: "g1" }, [
|
||||
[actionBringForward, ["A", "B", "C", "D"]],
|
||||
[actionSendBackward, ["C", "A", "B", "D"]],
|
||||
[actionBringToFront, ["A", "B", "C", "D"]],
|
||||
[actionSendToBack, ["C", "A", "B", "D"]],
|
||||
]);
|
||||
});
|
||||
|
||||
it("two interleaved groups, both fully selected", () => {
|
||||
const elements: Parameters<typeof populateElements>[0] = [
|
||||
{ id: "A", groupIds: ["g1"], isSelected: true },
|
||||
{ id: "X", groupIds: ["g2"], isSelected: true },
|
||||
{ id: "C", groupIds: ["g1"], isSelected: true },
|
||||
{ id: "Y", groupIds: ["g2"], isSelected: true },
|
||||
{ id: "Z" },
|
||||
];
|
||||
assertReorderPreservesElements(elements, undefined, [
|
||||
[actionBringForward, ["Z", "A", "X", "C", "Y"]],
|
||||
[actionSendBackward, ["A", "X", "C", "Y", "Z"]],
|
||||
[actionBringToFront, ["Z", "A", "X", "C", "Y"]],
|
||||
[actionSendToBack, ["A", "X", "C", "Y", "Z"]],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("z-index reordering with inconsistent group-editing state", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw />);
|
||||
});
|
||||
|
||||
it("does not duplicate or drop elements when selected elements fall outside the edited group scope", () => {
|
||||
assertZindex({
|
||||
elements: [
|
||||
{ id: "A", groupIds: ["g1"], isSelected: true },
|
||||
{ id: "C", groupIds: ["g1"] },
|
||||
{ id: "X", groupIds: ["g2"] },
|
||||
{ id: "Y", groupIds: ["g2"] },
|
||||
{ id: "R" },
|
||||
],
|
||||
appState: { editingGroupId: "g2" },
|
||||
operations: [[actionSendToBack, ["A", "C", "X", "Y", "R"]]],
|
||||
});
|
||||
|
||||
assertZindex({
|
||||
elements: [
|
||||
{ id: "A", groupIds: ["g1"] },
|
||||
{ id: "C", groupIds: ["g1"] },
|
||||
{ id: "X", groupIds: ["g2"], isSelected: true },
|
||||
{ id: "Y", groupIds: ["g2"] },
|
||||
{ id: "R" },
|
||||
],
|
||||
appState: { editingGroupId: "g1" },
|
||||
operations: [[actionBringToFront, ["A", "C", "X", "Y", "R"]]],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "../",
|
||||
"outDir": "./dist/types"
|
||||
},
|
||||
"include": ["src/**/*", "global.d.ts"],
|
||||
|
||||
@@ -11,6 +11,94 @@ 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
|
||||
|
||||
- Theme changes initiated by the default UI are now delegated to `<Excalidraw onThemeChange={(theme) => ...} />` when supplied. If `onThemeChange` is not supplied, light/dark theme toggling still falls back to updating the internal editor state.
|
||||
|
||||
- `MainMenu.DefaultItems.ToggleTheme` no longer accepts the item-level `onSelect` callback. Host apps that need to control light/dark/system theme should pass `onThemeChange` to `<Excalidraw />` instead.
|
||||
- `MainMenu.DefaultItems.ToggleTheme` with system theme support now uses `allowSystemTheme` together with `theme={Theme | "system"}` only to render the selected value. For the regular light/dark item, pass `allowSystemTheme={false}`.
|
||||
- `CommandPalette.defaultItems.toggleTheme` was removed. The default theme command is now rendered by the command palette itself when `UIOptions.canvasActions.toggleTheme` enables the action (see below).
|
||||
- `UIOptions.canvasActions.toggleTheme` still controls default theme UI availability. When it is `null`, it defaults to `true` if `props.theme` is omitted or `props.onThemeChange` is supplied, and otherwise defaults to disabled.
|
||||
|
||||
- Renamed the `excalidrawAPI` prop to `onExcalidrawAPI`.
|
||||
- `onExcalidrawAPI` is now called on mount (instead of during constructor), and later on unmount (with `null` value). The API may be removed altogether in the future (you can use `onMount` & `onUmount` to manage the `ExcalidrawAPI` object (e.g. to cache it to a global state), already).
|
||||
|
||||
### Features
|
||||
|
||||
- Added `ExcalidrawAPI.isDestroyed` flag. Set to `true` once the editor unmounts. Calling any `get*` method, `onStateChange`, or `onEvent` on a destroyed API instance will throw in development and `console.error` in production. The `ExcalidrawAPI` will be reset to `null` on umount, but to be extra safe, you should check `ExcalidrawAPI.isDestroyed` before calling these methods to guard against subtle race conditions in your code.
|
||||
|
||||
- Added `onMount`, `onInitialize`, and `onUnmount` props. `onMount` receives `{ excalidrawAPI, container }` once the editor root is mounted. `onInitialize` fires once the initial scene has loaded. `onUnmount` fires just before unmounting.
|
||||
|
||||
- Same events are also accessible imperatively through `api.onEvent(...)`.
|
||||
|
||||
```tsx
|
||||
<Excalidraw
|
||||
onExcalidrawAPI={(api) => {
|
||||
api.onEvent("editor:mount", ({ excalidrawAPI, container }) => {
|
||||
console.log(container);
|
||||
});
|
||||
|
||||
api.onEvent("editor:initialize").then((readyApi) => {
|
||||
readyApi.scrollToContent();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
Note that in future releases, most, if not all, `excalidrawAPI.on*` subscriptions will be removed in favor of `excalidrawAPI.onEvent(name)`.
|
||||
|
||||
- Also added `"editor:unmount"` lifecycle event, only accessible via `api.onEvent("editor:unmount")`.
|
||||
|
||||
- Exported `<ExcalidrawAPIProvider/>`, `useExcalidrawAPI()`, `useAppStateValue(prop | props | selectorFunction)`, and `useOnExcalidrawStateChange(prop | props | selectorFunction, callback)` from the package. The imperative API also now exposes `onStateChange(prop | props | selectorFunction, callback?)`, and `onEvent(name, callback)`.
|
||||
|
||||
```tsx
|
||||
<ExcalidrawAPIProvider>
|
||||
<Excalidraw />
|
||||
<Logger />
|
||||
</ExcalidrawAPIProvider>;
|
||||
|
||||
function Logger() {
|
||||
// initially null before the ExcalidrawAPIProvider initializes ater
|
||||
// <Excalidraw/> renders
|
||||
// When <Excalidraw/> unmounts, is reset back to null
|
||||
const api = useExcalidrawAPI();
|
||||
|
||||
useAppStateValue("viewModeEnabled", (viewModeEnabled) => {
|
||||
console.log("view mode changed:", viewModeEnabled);
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (api) {
|
||||
console.log("editor instance id:", api.id);
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
- Added `onExport` so host apps can delay JSON export until async work completes. The handler receives the export data plus an `AbortSignal`, and may return a `Promise` or an async generator that yields progress updates for the built-in toast UI.
|
||||
|
||||
```tsx
|
||||
<Excalidraw
|
||||
onExport={async function* (_type, { files }, { signal }) {
|
||||
yield { type: "progress", message: "Waiting for images..." };
|
||||
|
||||
await waitForImagesToLoad(files, signal);
|
||||
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
yield { type: "progress", message: "Export ready", progress: 1 };
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Excalidraw Library
|
||||
|
||||
## 0.18.0 (2025-03-11)
|
||||
|
||||
+111
-14
@@ -1,10 +1,10 @@
|
||||
# Excalidraw
|
||||
|
||||
**Excalidraw** is exported as a component to be directly embedded in your project.
|
||||
**Excalidraw** is exported as a React component that you can embed directly in your app.
|
||||
|
||||
## Installation
|
||||
|
||||
Use `npm` or `yarn` to install the package.
|
||||
Install the package together with its React peer dependencies.
|
||||
|
||||
```bash
|
||||
npm install react react-dom @excalidraw/excalidraw
|
||||
@@ -12,34 +12,131 @@ npm install react react-dom @excalidraw/excalidraw
|
||||
yarn add react react-dom @excalidraw/excalidraw
|
||||
```
|
||||
|
||||
> **Note**: If you don't want to wait for the next stable release and try out the unreleased changes, use `@excalidraw/excalidraw@next`.
|
||||
> **Note**: If you want to try unreleased changes, use `@excalidraw/excalidraw@next`.
|
||||
|
||||
#### Self-hosting fonts
|
||||
## Quick start
|
||||
|
||||
By default, Excalidraw will try to download all the used fonts from the [CDN](https://esm.run/@excalidraw/excalidraw/dist/prod).
|
||||
The minimum working setup has two easy-to-miss requirements:
|
||||
|
||||
For self-hosting purposes, you'll have to copy the content of the folder `node_modules/@excalidraw/excalidraw/dist/prod/fonts` to the path where your assets should be served from (i.e. `public/` directory in your project). In that case, you should also set `window.EXCALIDRAW_ASSET_PATH` to the very same path, i.e. `/` in case it's in the root:
|
||||
1. Import the package CSS:
|
||||
|
||||
```js
|
||||
<script>window.EXCALIDRAW_ASSET_PATH = "/";</script>
|
||||
```ts
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
```
|
||||
|
||||
### Dimensions of Excalidraw
|
||||
2. Render Excalidraw inside a container with a non-zero height.
|
||||
|
||||
Excalidraw takes _100%_ of `width` and `height` of the containing block so make sure the container in which you render Excalidraw has non zero dimensions.
|
||||
```tsx
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div style={{ height: "100vh" }}>
|
||||
<Excalidraw />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Excalidraw fills `100%` of the width and height of its parent. If the parent has no height, the canvas will not be visible.
|
||||
|
||||
## Next.js / SSR frameworks
|
||||
|
||||
Excalidraw should be rendered on the client. In SSR frameworks such as Next.js, use a client component and load it dynamically with SSR disabled.
|
||||
|
||||
```tsx
|
||||
// app/components/ExcalidrawClient.tsx
|
||||
"use client";
|
||||
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
|
||||
export default function ExcalidrawClient() {
|
||||
return (
|
||||
<div style={{ height: "100vh" }}>
|
||||
<Excalidraw />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// app/page.tsx
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const ExcalidrawClient = dynamic(
|
||||
() => import("./components/ExcalidrawClient"),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
export default function Page() {
|
||||
return <ExcalidrawClient />;
|
||||
}
|
||||
```
|
||||
|
||||
See the local examples for complete setups:
|
||||
|
||||
- [examples/with-nextjs](https://github.com/excalidraw/excalidraw/tree/master/examples/with-nextjs)
|
||||
- [examples/with-script-in-browser](https://github.com/excalidraw/excalidraw/tree/master/examples/with-script-in-browser)
|
||||
|
||||
## LLM / agent tips
|
||||
|
||||
If an LLM or coding agent is setting up Excalidraw, these shortcuts usually save more time than re-prompting:
|
||||
|
||||
- Start with a plain `<Excalidraw />` in a `100vh` container. Add refs, `initialData`, persistence, or custom UI only after the base embed works.
|
||||
- If the canvas is blank, check the CSS import and parent height first. Those are the two most common integration failures.
|
||||
- In Next.js or other SSR frameworks, assume client-only rendering first. Use `"use client"` and `dynamic(..., { ssr: false })` before debugging hydration or `window is not defined` errors.
|
||||
- If imports or entrypoints are unclear, inspect `node_modules/@excalidraw/excalidraw/package.json`. The installed package exports are the source of truth.
|
||||
- Do not set `window.EXCALIDRAW_ASSET_PATH` unless you are intentionally self-hosting fonts/assets.
|
||||
- When docs and generated code drift, copy the nearest working example from this repo, especially `examples/with-nextjs` or `examples/with-script-in-browser`.
|
||||
|
||||
## Migrating to `@excalidraw/excalidraw@0.18.x`
|
||||
|
||||
Version `0.18.x` removes the old `types/`-prefixed deep import paths. If you were importing types from `@excalidraw/excalidraw/types/...`, switch to the new type-only subpaths below.
|
||||
|
||||
| Old path | New path |
|
||||
| --- | --- |
|
||||
| `@excalidraw/excalidraw/types/data/transform.js` | `@excalidraw/excalidraw/element/transform` |
|
||||
| `@excalidraw/excalidraw/types/data/types.js` | `@excalidraw/excalidraw/data/types` |
|
||||
| `@excalidraw/excalidraw/types/element/types.js` | `@excalidraw/excalidraw/element/types` |
|
||||
| `@excalidraw/excalidraw/types/utility-types.js` | `@excalidraw/excalidraw/common/utility-types` |
|
||||
| `@excalidraw/excalidraw/types/types.js` | `@excalidraw/excalidraw/types` |
|
||||
|
||||
Drop the `.js` extension. The new package `exports` map resolves these paths without it.
|
||||
|
||||
These deep subpaths are for `import type` only. Runtime imports should come from the package root, plus `@excalidraw/excalidraw/index.css` for styles.
|
||||
|
||||
For example:
|
||||
|
||||
```ts
|
||||
import { exportToSvg } from "@excalidraw/excalidraw";
|
||||
```
|
||||
|
||||
## Self-hosting fonts
|
||||
|
||||
By default, Excalidraw downloads the fonts it needs from the [CDN](https://esm.run/@excalidraw/excalidraw/dist/prod).
|
||||
|
||||
For self-hosting, copy the contents of `node_modules/@excalidraw/excalidraw/dist/prod/fonts` into the path where your app serves static assets, for example `public/`. Then set `window.EXCALIDRAW_ASSET_PATH` to that same path:
|
||||
|
||||
```html
|
||||
<script>
|
||||
window.EXCALIDRAW_ASSET_PATH = "/";
|
||||
</script>
|
||||
```
|
||||
|
||||
## Demo
|
||||
|
||||
Go to [CodeSandbox](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser) example.
|
||||
Try the [CodeSandbox example](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser).
|
||||
|
||||
## Integration
|
||||
|
||||
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration).
|
||||
Read the [integration docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration).
|
||||
|
||||
## API
|
||||
|
||||
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api).
|
||||
Read the [API docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api).
|
||||
|
||||
## Contributing
|
||||
|
||||
Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing).
|
||||
Read the [contributing docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing).
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
VERTICAL_ALIGN,
|
||||
arrayToMap,
|
||||
getFontString,
|
||||
getStrokeWidthByKey,
|
||||
} from "@excalidraw/common";
|
||||
import {
|
||||
getOriginalContainerHeightFromCache,
|
||||
@@ -249,7 +250,10 @@ export const actionWrapTextInContainer = register({
|
||||
fillStyle: appState.currentItemFillStyle,
|
||||
strokeColor: appState.currentItemStrokeColor,
|
||||
roughness: appState.currentItemRoughness,
|
||||
strokeWidth: appState.currentItemStrokeWidth,
|
||||
strokeWidth: getStrokeWidthByKey(
|
||||
"rectangle",
|
||||
appState.currentItemStrokeWidthKey,
|
||||
),
|
||||
strokeStyle: appState.currentItemStrokeStyle,
|
||||
roundness:
|
||||
appState.currentItemRoundness === "round"
|
||||
|
||||
@@ -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"
|
||||
? {
|
||||
@@ -478,17 +477,28 @@ export const actionToggleTheme = register<AppState["theme"]>({
|
||||
appState.theme === THEME.LIGHT ? MoonIcon : SunIcon,
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_, appState, value) => {
|
||||
perform: (_, appState, value, app) => {
|
||||
const nextTheme =
|
||||
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT);
|
||||
|
||||
if (app.props.onThemeChange) {
|
||||
app.props.onThemeChange(nextTheme);
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
theme:
|
||||
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
|
||||
theme: nextTheme,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] &&
|
||||
event.altKey &&
|
||||
event.shiftKey &&
|
||||
event.code === CODES.D,
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return !!app.props.UIOptions.canvasActions.toggleTheme;
|
||||
},
|
||||
|
||||
@@ -30,7 +30,7 @@ import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { TrashIcon } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
|
||||
import { useStylesPanelMode } from "..";
|
||||
import { useStylesPanelMode } from "../components/App";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
getElementsInGroup,
|
||||
isSomeElementSelected,
|
||||
makeNextSelectedElementIds,
|
||||
selectGroupsForSelectedElements,
|
||||
} from "@excalidraw/element";
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { KEYS, isWritableElement, updateActiveTool } from "@excalidraw/common";
|
||||
|
||||
import type { GroupId } from "@excalidraw/element/types";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
|
||||
const getNextActiveTool = (
|
||||
appState: Readonly<AppState>,
|
||||
app: AppClassProperties,
|
||||
) => {
|
||||
if (appState.activeTool.type === "eraser") {
|
||||
return updateActiveTool(appState, {
|
||||
...(appState.activeTool.lastActiveTool || {
|
||||
type: app.state.preferredSelectionTool.type,
|
||||
}),
|
||||
lastActiveToolBeforeEraser: null,
|
||||
});
|
||||
}
|
||||
|
||||
return updateActiveTool(appState, {
|
||||
type: app.state.preferredSelectionTool.type,
|
||||
});
|
||||
};
|
||||
|
||||
const getParentEditingGroupId = (
|
||||
appState: Readonly<AppState>,
|
||||
app: AppClassProperties,
|
||||
selectedElementIds: AppState["selectedElementIds"],
|
||||
): GroupId | null => {
|
||||
if (!appState.editingGroupId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nonDeletedElements = app.scene.getNonDeletedElements();
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds,
|
||||
elements: nonDeletedElements,
|
||||
});
|
||||
const candidateElements = selectedElements.length
|
||||
? selectedElements
|
||||
: getElementsInGroup(nonDeletedElements, appState.editingGroupId);
|
||||
|
||||
for (const element of candidateElements) {
|
||||
const editingGroupIndex = element.groupIds.indexOf(appState.editingGroupId);
|
||||
if (editingGroupIndex !== -1 && element.groupIds[editingGroupIndex + 1]) {
|
||||
return element.groupIds[editingGroupIndex + 1] as GroupId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const actionDeselect = register({
|
||||
name: "deselect",
|
||||
label: "",
|
||||
trackEvent: false,
|
||||
perform: (_elements, appState, _, app) => {
|
||||
const activeTool = getNextActiveTool(appState, app);
|
||||
|
||||
if (appState.editingGroupId) {
|
||||
const nonDeletedElements = app.scene.getNonDeletedElements();
|
||||
const selectedElementIds =
|
||||
Object.keys(appState.selectedElementIds).length > 0
|
||||
? appState.selectedElementIds
|
||||
: getElementsInGroup(
|
||||
nonDeletedElements,
|
||||
appState.editingGroupId,
|
||||
).reduce((acc, element) => {
|
||||
acc[element.id] = true;
|
||||
return acc;
|
||||
}, {} as Record<string, true>);
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
...selectGroupsForSelectedElements(
|
||||
{
|
||||
editingGroupId: getParentEditingGroupId(
|
||||
appState,
|
||||
app,
|
||||
selectedElementIds,
|
||||
),
|
||||
selectedElementIds,
|
||||
},
|
||||
nonDeletedElements,
|
||||
appState,
|
||||
app,
|
||||
),
|
||||
activeEmbeddable: null,
|
||||
activeTool,
|
||||
selectedLinearElement: null,
|
||||
selectionElement: null,
|
||||
showHyperlinkPopup: false,
|
||||
suggestedBinding: null,
|
||||
frameToHighlight: null,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
activeEmbeddable: null,
|
||||
activeTool,
|
||||
editingGroupId: null,
|
||||
selectedElementIds: makeNextSelectedElementIds({}, appState),
|
||||
selectedGroupIds: {},
|
||||
selectedLinearElement: null,
|
||||
selectionElement: null,
|
||||
showHyperlinkPopup: false,
|
||||
suggestedBinding: null,
|
||||
frameToHighlight: null,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
keyTest: (event, appState, _, app) => {
|
||||
if (event.key !== KEYS.ESCAPE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isWritableElement(event.target)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
!appState.newElement &&
|
||||
appState.multiElement === null &&
|
||||
!appState.selectedLinearElement?.isEditing &&
|
||||
(appState.activeEmbeddable !== null ||
|
||||
appState.activeTool.type !== app.state.preferredSelectionTool.type ||
|
||||
!!appState.editingGroupId ||
|
||||
!!appState.selectedLinearElement ||
|
||||
isSomeElementSelected(app.scene.getNonDeletedElements(), appState))
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -27,7 +27,7 @@ import { t } from "../i18n";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { getShortcutKey } from "../shortcut";
|
||||
|
||||
import { useStylesPanelMode } from "..";
|
||||
import { useStylesPanelMode } from "../components/App";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
||||
@@ -9,18 +9,20 @@ import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { Theme } from "@excalidraw/element/types";
|
||||
import type { ExcalidrawElement, Theme } from "@excalidraw/element/types";
|
||||
|
||||
import { useEditorInterface } from "../components/App";
|
||||
import { CheckboxItem } from "../components/CheckboxItem";
|
||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||
import { ProjectName } from "../components/ProjectName";
|
||||
import { Toast } from "../components/Toast";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { ExportIcon, questionCircle, saveAs } from "../components/icons";
|
||||
import { loadFromJSON, saveAsJSON } from "../data";
|
||||
import { isImageFileHandle } from "../data/blob";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
|
||||
import { resaveAsImageWithScene } from "../data/resave";
|
||||
|
||||
import { t } from "../i18n";
|
||||
@@ -31,7 +33,15 @@ import "../components/ToolIcon.scss";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppState } from "../types";
|
||||
import type { JSONExportData } from "../data/json";
|
||||
|
||||
import type {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
BinaryFiles,
|
||||
ExcalidrawProps,
|
||||
OnExportProgress,
|
||||
} from "../types";
|
||||
|
||||
export const actionChangeProjectName = register<AppState["name"]>({
|
||||
name: "changeProjectName",
|
||||
@@ -150,6 +160,143 @@ export const actionChangeExportEmbedScene = register<
|
||||
),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// onExport interception helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let onExportInProgress = false;
|
||||
|
||||
const onProgressToast = (
|
||||
app: AppClassProperties,
|
||||
progress: {
|
||||
message?: OnExportProgress["message"];
|
||||
progress?: number | null;
|
||||
},
|
||||
) => {
|
||||
const message = progress.message ?? t("progressDialog.defaultMessage");
|
||||
app.setAppState({
|
||||
toast: {
|
||||
message:
|
||||
progress.progress != null ? (
|
||||
<>
|
||||
{message}
|
||||
<Toast.ProgressBar progress={progress.progress} />
|
||||
</>
|
||||
) : (
|
||||
message
|
||||
),
|
||||
duration: Infinity,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** awaits host app's onExport result, and renders progress to the UI */
|
||||
async function handleOnExportResult(
|
||||
onExportResult: ReturnType<NonNullable<ExcalidrawProps["onExport"]>>,
|
||||
opts: {
|
||||
signal: AbortSignal;
|
||||
app: AppClassProperties;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (opts.app.state.isLoading) {
|
||||
onProgressToast(opts.app, { progress: null });
|
||||
await opts.app.onStateChange({ predicate: (state) => !state.isLoading });
|
||||
}
|
||||
|
||||
if (
|
||||
onExportResult != null &&
|
||||
typeof onExportResult === "object" &&
|
||||
Symbol.asyncIterator in onExportResult
|
||||
) {
|
||||
for await (const value of onExportResult) {
|
||||
if (opts.signal.aborted) {
|
||||
onExportResult.return();
|
||||
return;
|
||||
}
|
||||
if (value.type === "progress") {
|
||||
onProgressToast(opts.app, {
|
||||
message: value.message,
|
||||
progress: value.progress ?? null,
|
||||
});
|
||||
} else if (value.type === "done") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Generator completed without explicit "done" message
|
||||
return;
|
||||
}
|
||||
|
||||
if (onExportResult instanceof Promise) {
|
||||
onProgressToast(opts.app, { progress: null });
|
||||
await onExportResult;
|
||||
}
|
||||
}
|
||||
|
||||
function prepareDataForJSONExport(
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
app: AppClassProperties,
|
||||
): { abortController: AbortController; data: Promise<JSONExportData> } {
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
const dataPromise = new Promise<JSONExportData>(async (resolve) => {
|
||||
try {
|
||||
if (app.props.onExport) {
|
||||
await handleOnExportResult(
|
||||
app.props.onExport(
|
||||
"json",
|
||||
{
|
||||
elements,
|
||||
appState,
|
||||
files,
|
||||
},
|
||||
{
|
||||
signal,
|
||||
},
|
||||
),
|
||||
{
|
||||
app,
|
||||
signal,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.name === "AbortError") {
|
||||
// if abort error, assume it's a reaction on the signal being aborted
|
||||
console.warn(
|
||||
`onExport() aborted by host app (signal aborted: ${signal.aborted})`,
|
||||
);
|
||||
} else {
|
||||
// non-abort error
|
||||
//
|
||||
console.error("Error during props.onExport() handling", error);
|
||||
}
|
||||
|
||||
// either way, we currently don't allow host apps to cancel save actions
|
||||
// so we resolve to orig data
|
||||
}
|
||||
|
||||
resolve({
|
||||
elements,
|
||||
appState,
|
||||
// return latest files in case they finished loading during onExport
|
||||
files: app.files,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
abortController,
|
||||
data: dataPromise,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Save actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const actionSaveToActiveFile = register({
|
||||
name: "saveToActiveFile",
|
||||
label: "buttons.save",
|
||||
@@ -163,42 +310,62 @@ export const actionSaveToActiveFile = register({
|
||||
);
|
||||
},
|
||||
perform: async (elements, appState, value, app) => {
|
||||
const fileHandleExists = !!appState.fileHandle;
|
||||
if (onExportInProgress) {
|
||||
return false;
|
||||
}
|
||||
onExportInProgress = true;
|
||||
|
||||
const previousFileHandle = appState.fileHandle;
|
||||
const filename = app.getName();
|
||||
|
||||
const { abortController, data: exportedDataPromise } =
|
||||
prepareDataForJSONExport(elements, appState, app.files, app);
|
||||
|
||||
try {
|
||||
const { fileHandle } = isImageFileHandle(appState.fileHandle)
|
||||
const { fileHandle } = isImageFileHandle(previousFileHandle)
|
||||
? await resaveAsImageWithScene(
|
||||
elements,
|
||||
appState,
|
||||
app.files,
|
||||
app.getName(),
|
||||
exportedDataPromise,
|
||||
previousFileHandle,
|
||||
filename,
|
||||
)
|
||||
: await saveAsJSON(elements, appState, app.files, app.getName());
|
||||
: await saveAsJSON({
|
||||
data: exportedDataPromise,
|
||||
filename,
|
||||
fileHandle: previousFileHandle,
|
||||
});
|
||||
|
||||
return {
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
appState: {
|
||||
...appState,
|
||||
fileHandle,
|
||||
toast: fileHandleExists
|
||||
? {
|
||||
message: fileHandle?.name
|
||||
? t("toast.fileSavedToFilename").replace(
|
||||
"{filename}",
|
||||
`"${fileHandle.name}"`,
|
||||
)
|
||||
: t("toast.fileSaved"),
|
||||
}
|
||||
: null,
|
||||
toast: {
|
||||
message:
|
||||
previousFileHandle && fileHandle?.name
|
||||
? t("toast.fileSavedToFilename").replace(
|
||||
"{filename}",
|
||||
`"${fileHandle.name}"`,
|
||||
)
|
||||
: t("toast.fileSaved"),
|
||||
duration: 1500,
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
abortController.abort();
|
||||
|
||||
if (error?.name !== "AbortError") {
|
||||
console.error(error);
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
|
||||
return {
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
appState: {
|
||||
toast: null,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
onExportInProgress = false;
|
||||
}
|
||||
},
|
||||
keyTest: (event) =>
|
||||
@@ -212,36 +379,50 @@ export const actionSaveFileToDisk = register({
|
||||
viewMode: true,
|
||||
trackEvent: { category: "export" },
|
||||
perform: async (elements, appState, value, app) => {
|
||||
if (onExportInProgress) {
|
||||
return false;
|
||||
}
|
||||
onExportInProgress = true;
|
||||
|
||||
const { abortController, data: exportedDataPromise } =
|
||||
prepareDataForJSONExport(elements, appState, app.files, app);
|
||||
|
||||
try {
|
||||
const { fileHandle } = await saveAsJSON(
|
||||
elements,
|
||||
{
|
||||
...appState,
|
||||
fileHandle: null,
|
||||
},
|
||||
app.files,
|
||||
app.getName(),
|
||||
);
|
||||
const { fileHandle: savedFileHandle } = await saveAsJSON({
|
||||
data: exportedDataPromise,
|
||||
filename: app.getName(),
|
||||
fileHandle: null,
|
||||
});
|
||||
|
||||
return {
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
appState: {
|
||||
...appState,
|
||||
openDialog: null,
|
||||
fileHandle,
|
||||
toast: { message: t("toast.fileSaved") },
|
||||
fileHandle: savedFileHandle,
|
||||
toast: { message: t("toast.fileSaved"), duration: 3000 },
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
abortController.abort();
|
||||
if (error?.name !== "AbortError") {
|
||||
console.error(error);
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
|
||||
return {
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
appState: {
|
||||
toast: null,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
onExportInProgress = false;
|
||||
}
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event.key === KEYS.S && event.shiftKey && event[KEYS.CTRL_OR_CMD],
|
||||
event.key.toLowerCase() === KEYS.S &&
|
||||
event.shiftKey &&
|
||||
event[KEYS.CTRL_OR_CMD],
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
@@ -300,7 +481,8 @@ export const actionExportWithDarkMode = register<
|
||||
name: "exportWithDarkMode",
|
||||
label: "imageExportDialog.label.darkMode",
|
||||
trackEvent: { category: "export", action: "toggleTheme" },
|
||||
perform: (_elements, appState, value) => {
|
||||
perform: (_elements, appState, value, app) => {
|
||||
app.sessionExportThemeOverride = value ? THEME.DARK : THEME.LIGHT;
|
||||
return {
|
||||
appState: { ...appState, exportWithDarkMode: value },
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
|
||||
@@ -54,6 +54,7 @@ export const actionFinalize = register<FormData>({
|
||||
label: "",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, data, app) => {
|
||||
let shouldCommit = true;
|
||||
let newElements = elements;
|
||||
const { interactiveCanvas, focusContainer, scene } = app;
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
@@ -222,9 +223,44 @@ export const actionFinalize = register<FormData>({
|
||||
!lastCommittedPoint ||
|
||||
points[points.length - 1] !== lastCommittedPoint
|
||||
) {
|
||||
shouldCommit = false;
|
||||
scene.mutateElement(element, {
|
||||
points: element.points.slice(0, -1),
|
||||
});
|
||||
if (
|
||||
isBindingElement(element) &&
|
||||
element.endBinding &&
|
||||
// after slicing the trailing point a <2-point arrow may be left
|
||||
element.points.length > 1
|
||||
) {
|
||||
const newArrow = !!appState.newElement;
|
||||
const draggedPoints: PointsPositionUpdates = new Map([
|
||||
[
|
||||
element.points.length - 1,
|
||||
{
|
||||
point: element.points[element.points.length - 1],
|
||||
isDragging: false,
|
||||
},
|
||||
],
|
||||
]);
|
||||
const globalPoint =
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
element,
|
||||
-1,
|
||||
elementsMap,
|
||||
);
|
||||
bindOrUnbindBindingElement(
|
||||
element,
|
||||
draggedPoints,
|
||||
globalPoint[0],
|
||||
globalPoint[1],
|
||||
scene,
|
||||
appState,
|
||||
{
|
||||
newArrow,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,8 +365,8 @@ export const actionFinalize = register<FormData>({
|
||||
selectionElement: null,
|
||||
multiElement: null,
|
||||
editingTextElement: null,
|
||||
startBoundElement: null,
|
||||
suggestedBinding: null,
|
||||
frameToHighlight: null,
|
||||
selectedElementIds:
|
||||
element &&
|
||||
!appState.activeTool.locked &&
|
||||
@@ -344,13 +380,13 @@ export const actionFinalize = register<FormData>({
|
||||
selectedLinearElement,
|
||||
},
|
||||
// TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
captureUpdate: shouldCommit
|
||||
? CaptureUpdateAction.IMMEDIATELY
|
||||
: CaptureUpdateAction.NEVER,
|
||||
};
|
||||
},
|
||||
keyTest: (event, appState) =>
|
||||
(event.key === KEYS.ESCAPE &&
|
||||
(appState.selectedLinearElement?.isEditing ||
|
||||
(!appState.newElement && appState.multiElement === null))) ||
|
||||
(event.key === KEYS.ESCAPE && appState.selectedLinearElement?.isEditing) ||
|
||||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
||||
appState.multiElement !== null),
|
||||
PanelComponent: ({ appState, updateData, data }) => (
|
||||
|
||||
@@ -205,7 +205,6 @@ export const actionWrapSelectionInFrame = register({
|
||||
[...app.scene.getElementsIncludingDeleted(), frame],
|
||||
selectedElements,
|
||||
frame,
|
||||
appState,
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -277,7 +277,6 @@ export const actionUngroup = register({
|
||||
elementsMap,
|
||||
),
|
||||
frame,
|
||||
app,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
isWindows,
|
||||
KEYS,
|
||||
matchKey,
|
||||
arrayToMap,
|
||||
@@ -18,7 +17,7 @@ import { HistoryChangedEvent } from "../history";
|
||||
import { useEmitter } from "../hooks/useEmitter";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { useStylesPanelMode } from "..";
|
||||
import { useStylesPanelMode } from "../components/App";
|
||||
|
||||
import type { History } from "../history";
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
@@ -114,7 +113,7 @@ export const createRedoAction: ActionCreator = (history) => ({
|
||||
),
|
||||
keyTest: (event) =>
|
||||
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
|
||||
(isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)),
|
||||
(event[KEYS.CTRL_OR_CMD] && !event.shiftKey && matchKey(event, KEYS.Y)),
|
||||
PanelComponent: ({ appState, updateData, data, app }) => {
|
||||
const { isRedoStackEmpty } = useEmitter(
|
||||
history.onHistoryChangedEmitter,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { queryByTestId } from "@testing-library/react";
|
||||
import { fireEvent, queryByTestId } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
||||
FREEDRAW_STROKE_WIDTH,
|
||||
FONT_FAMILY,
|
||||
STROKE_WIDTH,
|
||||
} from "@excalidraw/common";
|
||||
@@ -128,6 +129,62 @@ describe("element locking", () => {
|
||||
expect(thinStrokeWidthButton).toBeChecked();
|
||||
});
|
||||
|
||||
it("should highlight common stroke width key across freedraw and non-freedraw elements", () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
strokeWidth: STROKE_WIDTH.medium,
|
||||
});
|
||||
const freedraw = API.createElement({
|
||||
type: "freedraw",
|
||||
strokeWidth: FREEDRAW_STROKE_WIDTH.medium,
|
||||
});
|
||||
API.setElements([rect, freedraw]);
|
||||
API.setSelectedElements([rect, freedraw]);
|
||||
|
||||
expect(queryByTestId(document.body, `strokeWidth-medium`)).toBeChecked();
|
||||
});
|
||||
|
||||
it("should apply stroke width by element type", () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
strokeWidth: STROKE_WIDTH.thin,
|
||||
});
|
||||
const freedraw = API.createElement({
|
||||
type: "freedraw",
|
||||
strokeWidth: FREEDRAW_STROKE_WIDTH.thin,
|
||||
});
|
||||
API.setElements([rect, freedraw]);
|
||||
API.setSelectedElements([rect, freedraw]);
|
||||
|
||||
const boldStrokeWidthButton = queryByTestId(
|
||||
document.body,
|
||||
`strokeWidth-bold`,
|
||||
);
|
||||
expect(boldStrokeWidthButton).not.toBe(null);
|
||||
fireEvent.click(boldStrokeWidthButton!);
|
||||
|
||||
const selectedElements = API.getSelectedElements();
|
||||
const selectedRect = selectedElements.find(
|
||||
(element) => element.type === "rectangle",
|
||||
);
|
||||
const selectedFreedraw = selectedElements.find(
|
||||
(element) => element.type === "freedraw",
|
||||
);
|
||||
|
||||
expect(selectedRect?.strokeWidth).toBe(STROKE_WIDTH.bold);
|
||||
expect(selectedFreedraw?.strokeWidth).toBe(FREEDRAW_STROKE_WIDTH.bold);
|
||||
});
|
||||
|
||||
it("should create new elements with stroke width by element type", () => {
|
||||
API.setAppState({ currentItemStrokeWidthKey: "bold" });
|
||||
|
||||
const rect = API.createElement({ type: "rectangle" });
|
||||
const freedraw = API.createElement({ type: "freedraw" });
|
||||
|
||||
expect(rect.strokeWidth).toBe(STROKE_WIDTH.bold);
|
||||
expect(freedraw.strokeWidth).toBe(FREEDRAW_STROKE_WIDTH.bold);
|
||||
});
|
||||
|
||||
it("should not highlight any stroke width button if no common style", () => {
|
||||
const rect1 = API.createElement({
|
||||
type: "rectangle",
|
||||
@@ -135,7 +192,7 @@ describe("element locking", () => {
|
||||
});
|
||||
const rect2 = API.createElement({
|
||||
type: "rectangle",
|
||||
strokeWidth: STROKE_WIDTH.bold,
|
||||
strokeWidth: STROKE_WIDTH.medium,
|
||||
});
|
||||
API.setElements([rect1, rect2]);
|
||||
API.setSelectedElements([rect1, rect2]);
|
||||
@@ -145,17 +202,17 @@ describe("element locking", () => {
|
||||
queryByTestId(document.body, `strokeWidth-thin`),
|
||||
).not.toBeChecked();
|
||||
expect(
|
||||
queryByTestId(document.body, `strokeWidth-bold`),
|
||||
queryByTestId(document.body, `strokeWidth-medium`),
|
||||
).not.toBeChecked();
|
||||
expect(
|
||||
queryByTestId(document.body, `strokeWidth-extraBold`),
|
||||
queryByTestId(document.body, `strokeWidth-bold`),
|
||||
).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("should show properties of different element types when selected", () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
strokeWidth: STROKE_WIDTH.bold,
|
||||
strokeWidth: STROKE_WIDTH.medium,
|
||||
});
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
@@ -164,7 +221,7 @@ describe("element locking", () => {
|
||||
API.setElements([rect, text]);
|
||||
API.setSelectedElements([rect, text]);
|
||||
|
||||
expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
|
||||
expect(queryByTestId(document.body, `strokeWidth-medium`)).toBeChecked();
|
||||
expect(queryByTestId(document.body, `font-family-code`)).toHaveClass(
|
||||
"active",
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
DEFAULT_FONT_SIZE,
|
||||
FONT_FAMILY,
|
||||
ROUNDNESS,
|
||||
STROKE_WIDTH,
|
||||
STROKE_WIDTH_KEYS,
|
||||
VERTICAL_ALIGN,
|
||||
KEYS,
|
||||
randomInteger,
|
||||
@@ -20,9 +20,11 @@ import {
|
||||
getFontFamilyString,
|
||||
getLineHeight,
|
||||
isTransparent,
|
||||
getStrokeWidthByKey,
|
||||
reduceToCommonValue,
|
||||
invariant,
|
||||
FONT_SIZES,
|
||||
type StrokeWidthKey,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element";
|
||||
@@ -36,6 +38,7 @@ import {
|
||||
import { LinearElementEditor } from "@excalidraw/element";
|
||||
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { getArrowheadForPicker } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
getBoundTextElement,
|
||||
@@ -69,9 +72,11 @@ import type {
|
||||
ElementsMap,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextElement,
|
||||
FontFamilyValues,
|
||||
StrokeVariability,
|
||||
TextAlign,
|
||||
VerticalAlign,
|
||||
} from "@excalidraw/element/types";
|
||||
@@ -82,6 +87,7 @@ import type { CaptureUpdateActionType } from "@excalidraw/element";
|
||||
|
||||
import { trackEvent } from "../analytics";
|
||||
import { RadioSelection } from "../components/RadioSelection";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
||||
import { FontPicker } from "../components/FontPicker/FontPicker";
|
||||
import { IconPicker } from "../components/IconPicker";
|
||||
@@ -124,9 +130,14 @@ import {
|
||||
sharpArrowIcon,
|
||||
roundArrowIcon,
|
||||
elbowArrowIcon,
|
||||
ArrowheadCrowfootIcon,
|
||||
ArrowheadCrowfootOneIcon,
|
||||
ArrowheadCrowfootOneOrManyIcon,
|
||||
ArrowheadCardinalityExactlyOneIcon,
|
||||
ArrowheadCardinalityManyIcon,
|
||||
ArrowheadCardinalityOneIcon,
|
||||
ArrowheadCardinalityOneOrManyIcon,
|
||||
ArrowheadCardinalityZeroOrManyIcon,
|
||||
ArrowheadCardinalityZeroOrOneIcon,
|
||||
strokeVariabilityConstantIcon,
|
||||
strokeVariabilityVariableIcon,
|
||||
} from "../components/icons";
|
||||
|
||||
import { Fonts } from "../fonts";
|
||||
@@ -186,8 +197,12 @@ export const changeProperty = (
|
||||
export const getFormValue = function <T extends Primitive>(
|
||||
elements: readonly ExcalidrawElement[],
|
||||
app: AppClassProperties,
|
||||
getAttribute: (element: ExcalidrawElement) => T,
|
||||
isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
|
||||
/**
|
||||
* input value (usually the element attribute value,
|
||||
* but depends on what the action's PanelComponent input expects)
|
||||
*/
|
||||
getValue: (element: ExcalidrawElement) => T,
|
||||
elementPredicate: true | ((element: ExcalidrawElement) => boolean),
|
||||
defaultValue: T | ((isSomeElementSelected: boolean) => T),
|
||||
): T {
|
||||
const editingTextElement = app.state.editingTextElement;
|
||||
@@ -196,7 +211,7 @@ export const getFormValue = function <T extends Primitive>(
|
||||
let ret: T | null = null;
|
||||
|
||||
if (editingTextElement) {
|
||||
ret = getAttribute(editingTextElement);
|
||||
ret = getValue(editingTextElement);
|
||||
}
|
||||
|
||||
if (!ret) {
|
||||
@@ -205,12 +220,12 @@ export const getFormValue = function <T extends Primitive>(
|
||||
if (hasSelection) {
|
||||
const selectedElements = app.scene.getSelectedElements(app.state);
|
||||
const targetElements =
|
||||
isRelevantElement === true
|
||||
elementPredicate === true
|
||||
? selectedElements
|
||||
: selectedElements.filter((el) => isRelevantElement(el));
|
||||
: selectedElements.filter((el) => elementPredicate(el));
|
||||
|
||||
ret =
|
||||
reduceToCommonValue(targetElements, getAttribute) ??
|
||||
reduceToCommonValue(targetElements, getValue) ??
|
||||
(typeof defaultValue === "function"
|
||||
? defaultValue(true)
|
||||
: defaultValue);
|
||||
@@ -540,20 +555,37 @@ export const actionChangeFillStyle = register<ExcalidrawElement["fillStyle"]>({
|
||||
},
|
||||
});
|
||||
|
||||
export const actionChangeStrokeWidth = register<
|
||||
ExcalidrawElement["strokeWidth"]
|
||||
>({
|
||||
const getStrokeWidthKeyForElement = (
|
||||
element: ExcalidrawElement,
|
||||
): StrokeWidthKey | null => {
|
||||
return (
|
||||
STROKE_WIDTH_KEYS.find(
|
||||
(key) => getStrokeWidthByKey(element.type, key) === element.strokeWidth,
|
||||
) ?? null
|
||||
);
|
||||
};
|
||||
|
||||
const getStrokeWidthForElement = (
|
||||
element: ExcalidrawElement,
|
||||
strokeWidthKey: StrokeWidthKey,
|
||||
): ExcalidrawElement["strokeWidth"] => {
|
||||
return getStrokeWidthByKey(element.type, strokeWidthKey);
|
||||
};
|
||||
|
||||
export const actionChangeStrokeWidth = register<StrokeWidthKey>({
|
||||
name: "changeStrokeWidth",
|
||||
label: "labels.strokeWidth",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
invariant(value, "actionChangeStrokeWidth: value must be defined");
|
||||
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
strokeWidth: value,
|
||||
strokeWidth: getStrokeWidthForElement(el, value),
|
||||
}),
|
||||
),
|
||||
appState: { ...appState, currentItemStrokeWidth: value },
|
||||
appState: { ...appState, currentItemStrokeWidthKey: value },
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
@@ -561,35 +593,35 @@ export const actionChangeStrokeWidth = register<
|
||||
<fieldset>
|
||||
<legend>{t("labels.strokeWidth")}</legend>
|
||||
<div className="buttonList">
|
||||
<RadioSelection
|
||||
<RadioSelection<StrokeWidthKey>
|
||||
group="stroke-width"
|
||||
options={[
|
||||
{
|
||||
value: STROKE_WIDTH.thin,
|
||||
value: "thin",
|
||||
text: t("labels.thin"),
|
||||
icon: StrokeWidthBaseIcon,
|
||||
testId: "strokeWidth-thin",
|
||||
},
|
||||
{
|
||||
value: STROKE_WIDTH.bold,
|
||||
text: t("labels.bold"),
|
||||
value: "medium",
|
||||
text: t("labels.medium"),
|
||||
icon: StrokeWidthBoldIcon,
|
||||
testId: "strokeWidth-bold",
|
||||
testId: "strokeWidth-medium",
|
||||
},
|
||||
{
|
||||
value: STROKE_WIDTH.extraBold,
|
||||
text: t("labels.extraBold"),
|
||||
value: "bold",
|
||||
text: t("labels.bold"),
|
||||
icon: StrokeWidthExtraBoldIcon,
|
||||
testId: "strokeWidth-extraBold",
|
||||
testId: "strokeWidth-bold",
|
||||
},
|
||||
]}
|
||||
value={getFormValue(
|
||||
elements,
|
||||
app,
|
||||
(element) => element.strokeWidth,
|
||||
getStrokeWidthKeyForElement,
|
||||
(element) => element.hasOwnProperty("strokeWidth"),
|
||||
(hasSelection) =>
|
||||
hasSelection ? null : appState.currentItemStrokeWidth,
|
||||
hasSelection ? null : appState.currentItemStrokeWidthKey,
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
@@ -652,6 +684,87 @@ export const actionChangeSloppiness = register<ExcalidrawElement["roughness"]>({
|
||||
),
|
||||
});
|
||||
|
||||
export const actionChangeFreedrawMode = register<StrokeVariability>({
|
||||
name: "changeFreedrawMode",
|
||||
label: "labels.pressure",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
const variability = value || "constant";
|
||||
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) => {
|
||||
if (el.type !== "freedraw") {
|
||||
return el;
|
||||
}
|
||||
return newElementWith(el, {
|
||||
strokeOptions: {
|
||||
...el.strokeOptions,
|
||||
variability,
|
||||
},
|
||||
}) as ExcalidrawElement;
|
||||
}),
|
||||
appState: { ...appState, currentItemStrokeVariability: variability },
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app, data }) => {
|
||||
const strokeVariability =
|
||||
getFormValue(
|
||||
elements,
|
||||
app,
|
||||
(element) =>
|
||||
(element as ExcalidrawFreeDrawElement).strokeOptions?.variability,
|
||||
(element) => element.type === "freedraw",
|
||||
(hasSelection) =>
|
||||
hasSelection ? null : appState.currentItemStrokeVariability,
|
||||
) ?? appState.currentItemStrokeVariability;
|
||||
|
||||
// in the compact UI the pressure setting is rendered as a single button
|
||||
// that cycles between the two variability modes on click
|
||||
if (data?.cycle) {
|
||||
const isVariable = strokeVariability === "variable";
|
||||
return (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={
|
||||
isVariable
|
||||
? strokeVariabilityVariableIcon
|
||||
: strokeVariabilityConstantIcon
|
||||
}
|
||||
title={t("labels.pressure")}
|
||||
aria-label={t("labels.pressure")}
|
||||
onClick={() => updateData(isVariable ? "constant" : "variable")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>{t("labels.pressure")}</legend>
|
||||
<div className="buttonList">
|
||||
<RadioSelection<StrokeVariability>
|
||||
group="strokeOptions.variability"
|
||||
options={[
|
||||
{
|
||||
value: "constant",
|
||||
text: t("labels.pressure_constant"),
|
||||
icon: strokeVariabilityConstantIcon,
|
||||
},
|
||||
{
|
||||
value: "variable",
|
||||
text: t("labels.pressure_variable"),
|
||||
icon: strokeVariabilityVariableIcon,
|
||||
},
|
||||
]}
|
||||
value={strokeVariability}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const actionChangeStrokeStyle = register<
|
||||
ExcalidrawElement["strokeStyle"]
|
||||
>({
|
||||
@@ -726,9 +839,28 @@ export const actionChangeOpacity = register<ExcalidrawElement["opacity"]>({
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ app, updateData }) => (
|
||||
<Range updateData={updateData} app={app} testId="opacity" />
|
||||
),
|
||||
PanelComponent: ({ elements, appState, app, updateData }) => {
|
||||
const opacity = getFormValue(
|
||||
elements,
|
||||
app,
|
||||
(element) => element.opacity,
|
||||
true,
|
||||
(hasSelection) => (hasSelection ? null : appState.currentItemOpacity),
|
||||
);
|
||||
|
||||
return (
|
||||
<Range
|
||||
label={t("labels.opacity")}
|
||||
value={opacity ?? appState.currentItemOpacity}
|
||||
hasCommonValue={opacity !== null}
|
||||
onChange={updateData}
|
||||
min={0}
|
||||
max={100}
|
||||
step={10}
|
||||
testId="opacity"
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const actionChangeFontSize = register<ExcalidrawTextElement["fontSize"]>(
|
||||
@@ -1550,80 +1682,117 @@ export const actionChangeRoundness = register<"sharp" | "round">({
|
||||
});
|
||||
|
||||
const getArrowheadOptions = (flip: boolean) => {
|
||||
return [
|
||||
{
|
||||
value: null,
|
||||
text: t("labels.arrowhead_none"),
|
||||
keyBinding: "q",
|
||||
icon: <ArrowheadNoneIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "arrow",
|
||||
text: t("labels.arrowhead_arrow"),
|
||||
keyBinding: "w",
|
||||
icon: <ArrowheadArrowIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "triangle",
|
||||
text: t("labels.arrowhead_triangle"),
|
||||
icon: <ArrowheadTriangleIcon flip={flip} />,
|
||||
keyBinding: "e",
|
||||
},
|
||||
{
|
||||
value: "triangle_outline",
|
||||
text: t("labels.arrowhead_triangle_outline"),
|
||||
icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
|
||||
keyBinding: "r",
|
||||
},
|
||||
{
|
||||
value: "circle",
|
||||
text: t("labels.arrowhead_circle"),
|
||||
keyBinding: "a",
|
||||
icon: <ArrowheadCircleIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "circle_outline",
|
||||
text: t("labels.arrowhead_circle_outline"),
|
||||
keyBinding: "s",
|
||||
icon: <ArrowheadCircleOutlineIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "diamond",
|
||||
text: t("labels.arrowhead_diamond"),
|
||||
icon: <ArrowheadDiamondIcon flip={flip} />,
|
||||
keyBinding: "d",
|
||||
},
|
||||
{
|
||||
value: "diamond_outline",
|
||||
text: t("labels.arrowhead_diamond_outline"),
|
||||
icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
|
||||
keyBinding: "f",
|
||||
},
|
||||
{
|
||||
value: "bar",
|
||||
text: t("labels.arrowhead_bar"),
|
||||
keyBinding: "z",
|
||||
icon: <ArrowheadBarIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "crowfoot_one",
|
||||
text: t("labels.arrowhead_crowfoot_one"),
|
||||
icon: <ArrowheadCrowfootOneIcon flip={flip} />,
|
||||
keyBinding: "x",
|
||||
},
|
||||
{
|
||||
value: "crowfoot_many",
|
||||
text: t("labels.arrowhead_crowfoot_many"),
|
||||
icon: <ArrowheadCrowfootIcon flip={flip} />,
|
||||
keyBinding: "c",
|
||||
},
|
||||
{
|
||||
value: "crowfoot_one_or_many",
|
||||
text: t("labels.arrowhead_crowfoot_one_or_many"),
|
||||
icon: <ArrowheadCrowfootOneOrManyIcon flip={flip} />,
|
||||
keyBinding: "v",
|
||||
},
|
||||
] as const;
|
||||
return {
|
||||
visibleSections: [
|
||||
{
|
||||
name: "default",
|
||||
options: [
|
||||
{
|
||||
value: null,
|
||||
text: t("labels.arrowhead_none"),
|
||||
keyBinding: "q",
|
||||
icon: <ArrowheadNoneIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "arrow",
|
||||
text: t("labels.arrowhead_arrow"),
|
||||
keyBinding: "w",
|
||||
icon: <ArrowheadArrowIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "triangle",
|
||||
text: t("labels.arrowhead_triangle"),
|
||||
icon: <ArrowheadTriangleIcon flip={flip} />,
|
||||
keyBinding: "e",
|
||||
},
|
||||
{
|
||||
value: "triangle_outline",
|
||||
text: t("labels.arrowhead_triangle_outline"),
|
||||
icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
|
||||
keyBinding: "r",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
hiddenSections: [
|
||||
{
|
||||
name: "default",
|
||||
options: [
|
||||
{
|
||||
value: "circle",
|
||||
text: t("labels.arrowhead_circle"),
|
||||
keyBinding: "a",
|
||||
icon: <ArrowheadCircleIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "circle_outline",
|
||||
text: t("labels.arrowhead_circle_outline"),
|
||||
keyBinding: "s",
|
||||
icon: <ArrowheadCircleOutlineIcon flip={flip} />,
|
||||
},
|
||||
{
|
||||
value: "diamond",
|
||||
text: t("labels.arrowhead_diamond"),
|
||||
icon: <ArrowheadDiamondIcon flip={flip} />,
|
||||
keyBinding: "d",
|
||||
},
|
||||
{
|
||||
value: "diamond_outline",
|
||||
text: t("labels.arrowhead_diamond_outline"),
|
||||
icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
|
||||
keyBinding: "f",
|
||||
},
|
||||
{
|
||||
value: "bar",
|
||||
text: t("labels.arrowhead_bar"),
|
||||
keyBinding: "z",
|
||||
icon: <ArrowheadBarIcon flip={flip} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t("labels.cardinality"),
|
||||
options: [
|
||||
{
|
||||
value: "cardinality_one",
|
||||
text: t("labels.arrowhead_cardinality_one"),
|
||||
icon: <ArrowheadCardinalityOneIcon flip={flip} />,
|
||||
keyBinding: "x",
|
||||
},
|
||||
{
|
||||
value: "cardinality_many",
|
||||
text: t("labels.arrowhead_cardinality_many"),
|
||||
icon: <ArrowheadCardinalityManyIcon flip={flip} />,
|
||||
keyBinding: "c",
|
||||
},
|
||||
{
|
||||
value: "cardinality_one_or_many",
|
||||
text: t("labels.arrowhead_cardinality_one_or_many"),
|
||||
icon: <ArrowheadCardinalityOneOrManyIcon flip={flip} />,
|
||||
keyBinding: "v",
|
||||
},
|
||||
{
|
||||
value: "cardinality_exactly_one",
|
||||
text: t("labels.arrowhead_cardinality_exactly_one"),
|
||||
icon: <ArrowheadCardinalityExactlyOneIcon flip={flip} />,
|
||||
keyBinding: null,
|
||||
},
|
||||
{
|
||||
value: "cardinality_zero_or_one",
|
||||
text: t("labels.arrowhead_cardinality_zero_or_one"),
|
||||
icon: <ArrowheadCardinalityZeroOrOneIcon flip={flip} />,
|
||||
keyBinding: null,
|
||||
},
|
||||
{
|
||||
value: "cardinality_zero_or_many",
|
||||
text: t("labels.arrowhead_cardinality_zero_or_many"),
|
||||
icon: <ArrowheadCardinalityZeroOrManyIcon flip={flip} />,
|
||||
keyBinding: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
};
|
||||
|
||||
export const actionChangeArrowhead = register<{
|
||||
@@ -1667,45 +1836,52 @@ export const actionChangeArrowhead = register<{
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
const isRTL = getLanguage().rtl;
|
||||
const startArrowheadOptions = useMemo(
|
||||
() => getArrowheadOptions(!isRTL),
|
||||
[isRTL],
|
||||
);
|
||||
const endArrowheadOptions = useMemo(
|
||||
() => getArrowheadOptions(!!isRTL),
|
||||
[isRTL],
|
||||
);
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>{t("labels.arrowheads")}</legend>
|
||||
<div className="iconSelectList buttonList">
|
||||
<IconPicker
|
||||
visibleSections={startArrowheadOptions.visibleSections}
|
||||
hiddenSections={startArrowheadOptions.hiddenSections}
|
||||
label="arrowhead_start"
|
||||
options={getArrowheadOptions(!isRTL)}
|
||||
value={getFormValue<Arrowhead | null>(
|
||||
elements,
|
||||
app,
|
||||
(element) =>
|
||||
isLinearElement(element) && canHaveArrowheads(element.type)
|
||||
? element.startArrowhead
|
||||
? getArrowheadForPicker(element.startArrowhead)
|
||||
: appState.currentItemStartArrowhead,
|
||||
true,
|
||||
(hasSelection) =>
|
||||
hasSelection ? null : appState.currentItemStartArrowhead,
|
||||
)}
|
||||
onChange={(value) => updateData({ position: "start", type: value })}
|
||||
numberOfOptionsToAlwaysShow={4}
|
||||
/>
|
||||
<IconPicker
|
||||
visibleSections={endArrowheadOptions.visibleSections}
|
||||
hiddenSections={endArrowheadOptions.hiddenSections}
|
||||
label="arrowhead_end"
|
||||
group="arrowheads"
|
||||
options={getArrowheadOptions(!!isRTL)}
|
||||
value={getFormValue<Arrowhead | null>(
|
||||
elements,
|
||||
app,
|
||||
(element) =>
|
||||
isLinearElement(element) && canHaveArrowheads(element.type)
|
||||
? element.endArrowhead
|
||||
? getArrowheadForPicker(element.endArrowhead)
|
||||
: appState.currentItemEndArrowhead,
|
||||
true,
|
||||
(hasSelection) =>
|
||||
hasSelection ? null : appState.currentItemEndArrowhead,
|
||||
)}
|
||||
onChange={(value) => updateData({ position: "end", type: value })}
|
||||
numberOfOptionsToAlwaysShow={4}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -1830,6 +2006,7 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
|
||||
startElement,
|
||||
"start",
|
||||
elementsMap,
|
||||
appState.isBindingEnabled,
|
||||
),
|
||||
}
|
||||
: null;
|
||||
@@ -1843,6 +2020,7 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
|
||||
endElement,
|
||||
"end",
|
||||
elementsMap,
|
||||
appState.isBindingEnabled,
|
||||
),
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { getFontString } from "@excalidraw/common";
|
||||
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { isExcalidrawElement, newElementWith } from "@excalidraw/element";
|
||||
import { measureText } from "@excalidraw/element";
|
||||
|
||||
import { isTextElement } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { getSelectedElements } from "../scene";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppClassProperties } from "../types";
|
||||
|
||||
export const actionTextAutoResize = register({
|
||||
name: "autoResize",
|
||||
label: "labels.autoResize",
|
||||
icon: null,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState, _: unknown, app: AppClassProperties) => {
|
||||
predicate: (elements, appState, _: unknown) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
return (
|
||||
selectedElements.length === 1 &&
|
||||
@@ -26,13 +26,18 @@ export const actionTextAutoResize = register({
|
||||
!selectedElements[0].autoResize
|
||||
);
|
||||
},
|
||||
perform: (elements, appState, _, app) => {
|
||||
perform: (elements, appState, targetElement) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
|
||||
const targetTextElement =
|
||||
isExcalidrawElement(targetElement) && isTextElement(targetElement)
|
||||
? targetElement
|
||||
: (selectedElements[0] as ExcalidrawElement | undefined);
|
||||
|
||||
return {
|
||||
appState,
|
||||
elements: elements.map((element) => {
|
||||
if (element.id === selectedElements[0].id && isTextElement(element)) {
|
||||
if (element.id === targetTextElement?.id && isTextElement(element)) {
|
||||
const metrics = measureText(
|
||||
element.originalText,
|
||||
getFontString(element),
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleArrowBinding = register({
|
||||
name: "arrowBinding",
|
||||
label: "labels.arrowBinding",
|
||||
viewMode: false,
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => appState.bindingPreference === "disabled",
|
||||
},
|
||||
perform(elements, appState) {
|
||||
const newPreference =
|
||||
appState.bindingPreference === "enabled" ? "disabled" : "enabled";
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
bindingPreference: newPreference,
|
||||
isBindingEnabled: newPreference === "enabled",
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.bindingPreference === "enabled",
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleMidpointSnapping = register({
|
||||
name: "midpointSnapping",
|
||||
label: "labels.midpointSnapping",
|
||||
viewMode: false,
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => !appState.isMidpointSnappingEnabled,
|
||||
},
|
||||
perform(elements, appState) {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
isMidpointSnappingEnabled: !this.checked!(appState),
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.isMidpointSnappingEnabled,
|
||||
});
|
||||
@@ -13,6 +13,7 @@ export {
|
||||
actionChangeStrokeWidth,
|
||||
actionChangeFillStyle,
|
||||
actionChangeSloppiness,
|
||||
actionChangeFreedrawMode,
|
||||
actionChangeOpacity,
|
||||
actionChangeFontSize,
|
||||
actionChangeFontFamily,
|
||||
@@ -34,6 +35,7 @@ export {
|
||||
export { actionSetEmbeddableAsActiveTool } from "./actionEmbeddable";
|
||||
|
||||
export { actionFinalize } from "./actionFinalize";
|
||||
export { actionDeselect } from "./actionDeselect";
|
||||
|
||||
export {
|
||||
actionChangeProjectName,
|
||||
@@ -79,6 +81,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";
|
||||
|
||||
@@ -59,6 +59,8 @@ export type ActionName =
|
||||
| "gridMode"
|
||||
| "zenMode"
|
||||
| "objectsSnapMode"
|
||||
| "arrowBinding"
|
||||
| "midpointSnapping"
|
||||
| "stats"
|
||||
| "changeStrokeColor"
|
||||
| "changeBackgroundColor"
|
||||
@@ -66,6 +68,7 @@ export type ActionName =
|
||||
| "changeStrokeWidth"
|
||||
| "changeStrokeShape"
|
||||
| "changeSloppiness"
|
||||
| "changeFreedrawMode"
|
||||
| "changeStrokeStyle"
|
||||
| "changeArrowhead"
|
||||
| "changeArrowType"
|
||||
@@ -112,6 +115,7 @@ export type ActionName =
|
||||
| "distributeVertically"
|
||||
| "flipHorizontal"
|
||||
| "flipVertical"
|
||||
| "deselect"
|
||||
| "viewMode"
|
||||
| "exportWithDarkMode"
|
||||
| "toggleTheme"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { LaserPointer } from "@excalidraw/laser-pointer";
|
||||
|
||||
import {
|
||||
SVG_NS,
|
||||
getSvgPathFromStroke,
|
||||
@@ -8,7 +7,8 @@ import {
|
||||
|
||||
import type { LaserPointerOptions } from "@excalidraw/laser-pointer";
|
||||
|
||||
import type { AnimationFrameHandler } from "./animation-frame-handler";
|
||||
import { AnimationController } from "./renderer/animation";
|
||||
|
||||
import type App from "./components/App";
|
||||
import type { AppState } from "./types";
|
||||
|
||||
@@ -34,15 +34,16 @@ export class AnimatedTrail implements Trail {
|
||||
private container?: SVGSVGElement;
|
||||
private trailElement: SVGPathElement;
|
||||
private trailAnimation?: SVGAnimateElement;
|
||||
private key: string;
|
||||
|
||||
private static counter = 0;
|
||||
|
||||
constructor(
|
||||
private animationFrameHandler: AnimationFrameHandler,
|
||||
protected app: App,
|
||||
private options: Partial<LaserPointerOptions> &
|
||||
Partial<AnimatedTrailOptions>,
|
||||
) {
|
||||
this.animationFrameHandler.register(this, this.onFrame.bind(this));
|
||||
|
||||
this.key = `animated-trail-${AnimatedTrail.counter++}`;
|
||||
this.trailElement = document.createElementNS(SVG_NS, "path");
|
||||
if (this.options.animateTrail) {
|
||||
this.trailAnimation = document.createElementNS(SVG_NS, "animate");
|
||||
@@ -73,6 +74,15 @@ export class AnimatedTrail implements Trail {
|
||||
return false;
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
this.pastTrails = [];
|
||||
this.currentTrail = undefined;
|
||||
|
||||
if (this.trailElement.parentNode === this.container) {
|
||||
this.container?.removeChild(this.trailElement);
|
||||
}
|
||||
}
|
||||
|
||||
start(container?: SVGSVGElement) {
|
||||
if (container) {
|
||||
this.container = container;
|
||||
@@ -82,15 +92,23 @@ export class AnimatedTrail implements Trail {
|
||||
this.container.appendChild(this.trailElement);
|
||||
}
|
||||
|
||||
this.animationFrameHandler.start(this);
|
||||
if (!AnimationController.running(this.key)) {
|
||||
AnimationController.start(this.key, () => {
|
||||
const needsNext = this.onFrame();
|
||||
if (needsNext) {
|
||||
return { keep: true };
|
||||
}
|
||||
|
||||
this.cleanup();
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.animationFrameHandler.stop(this);
|
||||
|
||||
if (this.trailElement.parentNode === this.container) {
|
||||
this.container?.removeChild(this.trailElement);
|
||||
}
|
||||
AnimationController.cancel(this.key);
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
startPath(x: number, y: number) {
|
||||
@@ -145,21 +163,25 @@ export class AnimatedTrail implements Trail {
|
||||
|
||||
if (this.currentTrail) {
|
||||
const currentPath = this.drawTrail(this.currentTrail, this.app.state);
|
||||
|
||||
paths.push(currentPath);
|
||||
}
|
||||
|
||||
this.pastTrails = this.pastTrails.filter((trail) => {
|
||||
return trail.getStrokeOutline().length !== 0;
|
||||
});
|
||||
this.pastTrails = this.pastTrails.filter(
|
||||
(t) =>
|
||||
t.getStrokeOutline(t.options.size / this.app.state.zoom.value)
|
||||
.length !== 0,
|
||||
);
|
||||
|
||||
if (paths.length === 0) {
|
||||
this.stop();
|
||||
// Clean up the SVG path if there are no trails to render
|
||||
this.trailElement.setAttribute("d", "");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const svgPaths = paths.join(" ").trim();
|
||||
|
||||
this.trailElement.setAttribute("d", svgPaths);
|
||||
|
||||
if (this.trailAnimation) {
|
||||
this.trailElement.setAttribute(
|
||||
"fill",
|
||||
@@ -175,6 +197,8 @@ export class AnimatedTrail implements Trail {
|
||||
(this.options.fill ?? (() => "black"))(this),
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private drawTrail(trail: LaserPointer, state: AppState): string {
|
||||
@@ -1,79 +0,0 @@
|
||||
export type AnimationCallback = (timestamp: number) => void | boolean;
|
||||
|
||||
export type AnimationTarget = {
|
||||
callback: AnimationCallback;
|
||||
stopped: boolean;
|
||||
};
|
||||
|
||||
export class AnimationFrameHandler {
|
||||
private targets = new WeakMap<object, AnimationTarget>();
|
||||
private rafIds = new WeakMap<object, number>();
|
||||
|
||||
register(key: object, callback: AnimationCallback) {
|
||||
this.targets.set(key, { callback, stopped: true });
|
||||
}
|
||||
|
||||
start(key: object) {
|
||||
const target = this.targets.get(key);
|
||||
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.rafIds.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.targets.set(key, { ...target, stopped: false });
|
||||
this.scheduleFrame(key);
|
||||
}
|
||||
|
||||
stop(key: object) {
|
||||
const target = this.targets.get(key);
|
||||
if (target && !target.stopped) {
|
||||
this.targets.set(key, { ...target, stopped: true });
|
||||
}
|
||||
|
||||
this.cancelFrame(key);
|
||||
}
|
||||
|
||||
private constructFrame(key: object): FrameRequestCallback {
|
||||
return (timestamp: number) => {
|
||||
const target = this.targets.get(key);
|
||||
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldAbort = this.onFrame(target, timestamp);
|
||||
|
||||
if (!target.stopped && !shouldAbort) {
|
||||
this.scheduleFrame(key);
|
||||
} else {
|
||||
this.cancelFrame(key);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private scheduleFrame(key: object) {
|
||||
const rafId = requestAnimationFrame(this.constructFrame(key));
|
||||
|
||||
this.rafIds.set(key, rafId);
|
||||
}
|
||||
|
||||
private cancelFrame(key: object) {
|
||||
if (this.rafIds.has(key)) {
|
||||
const rafId = this.rafIds.get(key)!;
|
||||
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
|
||||
this.rafIds.delete(key);
|
||||
}
|
||||
|
||||
private onFrame(target: AnimationTarget, timestamp: number): boolean {
|
||||
const shouldAbort = target.callback(timestamp);
|
||||
|
||||
return shouldAbort ?? false;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DEFAULT_ELEMENT_PROPS,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_ELEMENT_STROKE_WIDTH_KEY,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
DEFAULT_GRID_SIZE,
|
||||
EXPORT_SCALES,
|
||||
@@ -27,7 +28,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,
|
||||
@@ -35,12 +35,13 @@ export const getDefaultAppState = (): Omit<
|
||||
currentItemFontSize: DEFAULT_FONT_SIZE,
|
||||
currentItemOpacity: DEFAULT_ELEMENT_PROPS.opacity,
|
||||
currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness,
|
||||
currentItemStrokeVariability: "constant",
|
||||
currentItemStartArrowhead: null,
|
||||
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
|
||||
currentItemRoundness: isTestEnv() ? "sharp" : "round",
|
||||
currentItemArrowType: ARROW_TYPE.round,
|
||||
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
|
||||
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
|
||||
currentItemStrokeWidthKey: DEFAULT_ELEMENT_STROKE_WIDTH_KEY,
|
||||
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
||||
currentHoveredFontFamily: null,
|
||||
cursorButton: "up",
|
||||
@@ -71,6 +72,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 +86,6 @@ export const getDefaultAppState = (): Omit<
|
||||
openPopup: null,
|
||||
openSidebar: null,
|
||||
openDialog: null,
|
||||
pasteDialog: { shown: false, data: null },
|
||||
previousSelectedElementIds: {},
|
||||
resizingElement: null,
|
||||
scrolledOutside: false,
|
||||
@@ -99,7 +101,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 +129,7 @@ export const getDefaultAppState = (): Omit<
|
||||
lockedMultiSelections: {},
|
||||
activeLockedId: null,
|
||||
bindMode: "orbit",
|
||||
boxSelectionMode: "contain",
|
||||
};
|
||||
};
|
||||
|
||||
@@ -150,7 +152,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 },
|
||||
@@ -168,10 +169,15 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
},
|
||||
currentItemOpacity: { browser: true, export: false, server: false },
|
||||
currentItemRoughness: { browser: true, export: false, server: false },
|
||||
currentItemStrokeVariability: {
|
||||
browser: true,
|
||||
export: false,
|
||||
server: false,
|
||||
},
|
||||
currentItemStartArrowhead: { browser: true, export: false, server: false },
|
||||
currentItemStrokeColor: { browser: true, export: false, server: false },
|
||||
currentItemStrokeStyle: { browser: true, export: false, server: false },
|
||||
currentItemStrokeWidth: { browser: true, export: false, server: false },
|
||||
currentItemStrokeWidthKey: { browser: true, export: false, server: false },
|
||||
currentItemTextAlign: { browser: true, export: false, server: false },
|
||||
currentHoveredFontFamily: { browser: false, export: false, server: false },
|
||||
cursorButton: { browser: true, export: false, server: false },
|
||||
@@ -193,7 +199,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 +221,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 +237,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 },
|
||||
|
||||
+1063
-16
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user