mirror of
https://github.com/excalidraw/excalidraw.git
synced 2026-10-20 00:16:54 +00:00
Compare commits
139
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc30ca5720 | ||
|
|
1919728724 | ||
|
|
35e854ecf7 | ||
|
|
5a406e5187 | ||
|
|
e283f724c8 | ||
|
|
3947883063 | ||
|
|
3d056dbc8a | ||
|
|
2d3707b816 | ||
|
|
834e5af631 | ||
|
|
438d89861f | ||
|
|
02fc9f3505 | ||
|
|
74423812ec | ||
|
|
84e3f5a40c | ||
|
|
974f054baf | ||
|
|
afed9e6e27 | ||
|
|
d29d864884 | ||
|
|
f3d99c42e9 | ||
|
|
c10499eebb | ||
|
|
c12a8cd037 | ||
|
|
5feb2172d5 | ||
|
|
5db42c3ddb | ||
|
|
e40d1cba95 | ||
|
|
1118751f3e | ||
|
|
dc2c16d9e2 | ||
|
|
4850bf336f | ||
|
|
2b9da96100 | ||
|
|
31df3e6ef2 | ||
|
|
97c68dd371 | ||
|
|
14e1c614d4 | ||
|
|
c0ad61c674 | ||
|
|
a918648012 | ||
|
|
4e758db17e | ||
|
|
afa3a653fc | ||
|
|
6574de60b3 | ||
|
|
854d00c31b | ||
|
|
0361bdd9cb | ||
|
|
2b1c2a9fbf | ||
|
|
f1a79b73df | ||
|
|
214cd6e6e8 | ||
|
|
e1bb9ff8f8 | ||
|
|
4a6f4e7a32 | ||
|
|
e160ff7ba0 | ||
|
|
884225b1c7 | ||
|
|
abeeaeba21 | ||
|
|
c5a50d223f | ||
|
|
cf212b2f33 | ||
|
|
4872083c04 | ||
|
|
ea74787447 | ||
|
|
219571a718 | ||
|
|
e4ab626739 | ||
|
|
1e22618ab2 | ||
|
|
74789584be | ||
|
|
e33a1c0e85 | ||
|
|
5e375b4331 | ||
|
|
85270fcc8d | ||
|
|
46c42a6cb0 | ||
|
|
c9c96f619c | ||
|
|
1da120e914 | ||
|
|
ab0255f21e | ||
|
|
792ea3a06c | ||
|
|
f1aa8f86a9 | ||
|
|
39103bd33a | ||
|
|
786ab266ff | ||
|
|
69d4c346cc | ||
|
|
1acf66edab | ||
|
|
7c0b329576 | ||
|
|
b2e81e38a6 | ||
|
|
f179f7ffd2 | ||
|
|
e6ae6bf057 | ||
|
|
91b78903ab | ||
|
|
a3b90897b5 | ||
|
|
53732f08f4 | ||
|
|
5cf547650b | ||
|
|
a1d9b16b0d | ||
|
|
65aa577e39 | ||
|
|
93dd51060a | ||
|
|
344878b0b5 | ||
|
|
374716304a | ||
|
|
5ca083436d | ||
|
|
acb48c3f45 | ||
|
|
2423819ee0 | ||
|
|
5776e1cc66 | ||
|
|
e9c856d262 | ||
|
|
082b2eeb1e | ||
|
|
d331bb49fc | ||
|
|
339d3c3733 | ||
|
|
ed7c0c1d7d | ||
|
|
3b9e1c07f5 | ||
|
|
adf9631997 | ||
|
|
f790bf7abd | ||
|
|
063e0256f5 | ||
|
|
fbff832424 | ||
|
|
8462bb81fb | ||
|
|
ba0387d186 | ||
|
|
dd8296af18 | ||
|
|
b2a729c400 | ||
|
|
aaa14e9df6 | ||
|
|
9357f98a9b | ||
|
|
5b0f5a4c2d | ||
|
|
51ca8abde4 | ||
|
|
cce5001aaa | ||
|
|
4ce70b815e | ||
|
|
c070c8ffa6 | ||
|
|
e4c70cb6c6 | ||
|
|
20f694d110 | ||
|
|
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 |
@@ -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
|
||||
@@ -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 .
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -43,5 +43,5 @@ jobs:
|
||||
uses: kt3k/update-pr-description@1b35a6dcd84d81aa0bc1889610efdcde7f37b0c0 # v1.0.1
|
||||
with:
|
||||
pr_body: ${{ steps.getCommentBody.outputs.body }}
|
||||
pr_title: "chore: Update translations from Crowdin"
|
||||
pr_title: "chore(editor): Update translations from Crowdin"
|
||||
github_token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2
|
||||
with:
|
||||
|
||||
@@ -6,11 +6,109 @@ on:
|
||||
- opened
|
||||
- edited
|
||||
- synchronize
|
||||
- labeled
|
||||
- unlabeled
|
||||
|
||||
jobs:
|
||||
semantic:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5
|
||||
with:
|
||||
types: |
|
||||
feat
|
||||
fix
|
||||
docs
|
||||
style
|
||||
refactor
|
||||
perf
|
||||
test
|
||||
build
|
||||
ci
|
||||
chore
|
||||
revert
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Guidelines
|
||||
|
||||
- For new DOM/browser API usage, use `app.ownerDocument` and `app.ownerWindow` instead of globals; without `app`, derive them from the mounted node's `ownerDocument` and its `defaultView`.
|
||||
- When overriding properties of an existing type, prefer `Merge<Base, Overrides>` from `@excalidraw/common/utility-types` over `Omit<Base, keyof Overrides> & Overrides`.
|
||||
+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
|
||||
|
||||
|
||||
@@ -274,6 +274,35 @@ convertToExcalidrawElements([
|
||||
|
||||

|
||||
|
||||
### Sticky Notes
|
||||
|
||||
A sticky note is an always-filled note whose label auto-fits: the font shrinks from the label's `fontSize` (the note's font ceiling) down to a minimum, and only then does the note grow in height. Only `type`, `x` and `y` are required; `width`/`height` default to a 250×250 square and `label` is optional. The note's `strokeColor` is its ink — its text and creation-date footer are always one color; a label that sets its own `strokeColor` gives it to the note. A footer band below the label shows the note's creation date (`created`); pass `created: null` for a note without one (the band stays reserved, but empty).
|
||||
|
||||
```js
|
||||
convertToExcalidrawElements([
|
||||
{
|
||||
type: "stickynote",
|
||||
x: 100,
|
||||
y: 100,
|
||||
label: {
|
||||
text: "Remember to ship it",
|
||||
fontSize: 28,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "stickynote",
|
||||
x: 400,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 200,
|
||||
backgroundColor: "#fcc2d7",
|
||||
strokeColor: "#c2255c",
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
Notes keep their invariants regardless of the properties you pass: the background is never transparent (it falls back to the default yellow), the fill is solid, and the size is at least 75×75. The height you pass is kept as the note's `baseHeight`; if a label needs more room at the minimum font size, the created note is taller.
|
||||
|
||||
### Labelled Arrows
|
||||
|
||||
Similar to Text Containers, you can create labelled arrows as well.
|
||||
|
||||
@@ -62,6 +62,18 @@ When saving an Excalidraw scene locally to a file, the JSON file (`.excalidraw`)
|
||||
}
|
||||
```
|
||||
|
||||
### Element creation time
|
||||
|
||||
Every normalized element has a `created: number | null` field. A number is the client's wall-clock time in epoch milliseconds when this element instance was created. `null` means the creation time is unknown; restoring an older file does not substitute its load time or last-update time.
|
||||
|
||||
| Operation | `created` |
|
||||
| --- | --- |
|
||||
| Construct a new element | Current time, unless explicitly supplied (including `null`) |
|
||||
| Duplicate, paste, or insert from a library | New time for the new instance |
|
||||
| Convert element skeletons with ID regeneration | New time for each resulting instance |
|
||||
| Restore/open/import an existing scene | Preserve the carried value; missing values become `null` |
|
||||
| Edit, change type while retaining the ID, capture, undo, or redo | Preserve the value |
|
||||
|
||||
## Excalidraw clipboard format
|
||||
|
||||
When copying selected excalidraw elements to clipboard, the JSON schema is similar to `.excalidraw` format, except it differs in attributes.
|
||||
|
||||
@@ -9,8 +9,6 @@ In case you want to pick up something from the roadmap, comment on that issue an
|
||||
|
||||
## Setup
|
||||
|
||||
### Option 1 - Manual
|
||||
|
||||
1. Fork and clone the repo
|
||||
1. Run `yarn` to install dependencies
|
||||
1. Create a branch for your PR with `git checkout -b your-branch-name`
|
||||
@@ -23,15 +21,6 @@ In case you want to pick up something from the roadmap, comment on that issue an
|
||||
> git branch --set-upstream-to=upstream/master master
|
||||
> ```
|
||||
|
||||
### Option 2 - CodeSandbox
|
||||
|
||||
1. Go to https://codesandbox.io/p/github/excalidraw/excalidraw
|
||||
1. Connect your GitHub account
|
||||
1. Go to Git tab on left side
|
||||
1. Tap on `Fork Sandbox`
|
||||
1. Write your code
|
||||
1. Commit and PR automatically
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
Don't worry if you get any of the below wrong, or if you don't know how. We'll gladly help out.
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# Development
|
||||
|
||||
## Code Sandbox
|
||||
|
||||
- Go to https://codesandbox.io/p/github/excalidraw/excalidraw
|
||||
- You may need to sign in with GitHub and reload the page
|
||||
- You can start coding instantly, and even send PRs from there!
|
||||
|
||||
## Local Installation
|
||||
|
||||
These instructions will get you a copy of the project up and running on your local machine for development and testing purposes.
|
||||
|
||||
@@ -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,10 @@
|
||||
{
|
||||
"git": {
|
||||
"deploymentEnabled": {
|
||||
"master": true,
|
||||
"**": false
|
||||
}
|
||||
},
|
||||
"outputDirectory": "build",
|
||||
"installCommand": "yarn install"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
{
|
||||
"git": {
|
||||
"deploymentEnabled": {
|
||||
"master": true,
|
||||
"**": false
|
||||
}
|
||||
},
|
||||
"outputDirectory": "build"
|
||||
}
|
||||
|
||||
@@ -102,6 +102,12 @@ export default function ExampleApp({
|
||||
} = excalidrawLib;
|
||||
const appRef = useRef<any>(null);
|
||||
const [viewModeEnabled, setViewModeEnabled] = useState(false);
|
||||
const [interactive, setInteractive] = useState(true);
|
||||
const [interactiveLinks, setInteractiveLinks] = useState(false);
|
||||
const [interactiveEmbeds, setInteractiveEmbeds] = useState(false);
|
||||
const [interactiveNavigation, setInteractiveNavigation] = useState(false);
|
||||
const [interactiveBrowserZoom, setInteractiveBrowserZoom] = useState(false);
|
||||
const [ui, setUi] = useState(true);
|
||||
const [zenModeEnabled, setZenModeEnabled] = useState(false);
|
||||
const [gridModeEnabled, setGridModeEnabled] = useState(false);
|
||||
const [renderScrollbars, setRenderScrollbars] = useState(false);
|
||||
@@ -192,6 +198,17 @@ export default function ExampleApp({
|
||||
pointersMap: Gesture["pointers"];
|
||||
}) => setPointerData(payload),
|
||||
viewModeEnabled,
|
||||
interaction: interactive
|
||||
? true
|
||||
: {
|
||||
enabled: {
|
||||
links: interactiveLinks,
|
||||
embeds: interactiveEmbeds,
|
||||
navigation: interactiveNavigation,
|
||||
browserZoom: interactiveBrowserZoom,
|
||||
},
|
||||
},
|
||||
ui,
|
||||
zenModeEnabled,
|
||||
renderScrollbars,
|
||||
gridModeEnabled,
|
||||
@@ -696,6 +713,52 @@ export default function ExampleApp({
|
||||
/>
|
||||
View mode
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!interactive}
|
||||
onChange={() => setInteractive(!interactive)}
|
||||
/>
|
||||
Non-interactive
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={interactiveLinks}
|
||||
onChange={() => setInteractiveLinks(!interactiveLinks)}
|
||||
/>
|
||||
Links when non-interactive
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={interactiveEmbeds}
|
||||
onChange={() => setInteractiveEmbeds(!interactiveEmbeds)}
|
||||
/>
|
||||
Embeds when non-interactive
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={interactiveNavigation}
|
||||
onChange={() => setInteractiveNavigation(!interactiveNavigation)}
|
||||
/>
|
||||
Navigation when non-interactive
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={interactiveBrowserZoom}
|
||||
onChange={() =>
|
||||
setInteractiveBrowserZoom(!interactiveBrowserZoom)
|
||||
}
|
||||
/>
|
||||
Browser zoom when non-interactive
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" checked={!ui} onChange={() => setUi(!ui)} />
|
||||
Hide UI
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -917,8 +980,9 @@ export default function ExampleApp({
|
||||
}
|
||||
|
||||
const elements = excalidrawAPI.getSceneElements();
|
||||
excalidrawAPI.scrollToContent(elements[0], {
|
||||
fitToViewport: true,
|
||||
excalidrawAPI.setViewport({
|
||||
target: elements[0],
|
||||
fit: "contain",
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -932,34 +996,14 @@ export default function ExampleApp({
|
||||
}
|
||||
|
||||
const elements = excalidrawAPI.getSceneElements();
|
||||
excalidrawAPI.scrollToContent(elements[0], {
|
||||
fitToContent: true,
|
||||
});
|
||||
|
||||
excalidrawAPI.scrollToContent(elements[0], {
|
||||
fitToContent: true,
|
||||
excalidrawAPI.setViewport({
|
||||
target: elements[0],
|
||||
fit: "scale-down",
|
||||
});
|
||||
}}
|
||||
>
|
||||
Fit to content, first element
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!excalidrawAPI) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elements = excalidrawAPI.getSceneElements();
|
||||
excalidrawAPI.scrollToContent(elements[0], {
|
||||
fitToContent: true,
|
||||
});
|
||||
|
||||
excalidrawAPI.scrollToContent(elements[0]);
|
||||
}}
|
||||
>
|
||||
Scroll to first element, no fitToContent, no fitToViewport
|
||||
</button>
|
||||
<div className="export export-canvas">
|
||||
<img src={canvasUrl} alt="" />
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
{
|
||||
"git": {
|
||||
"deploymentEnabled": {
|
||||
"master": true,
|
||||
"**": false
|
||||
}
|
||||
},
|
||||
"outputDirectory": "dist",
|
||||
"installCommand": "yarn install",
|
||||
"buildCommand": "yarn build:packages && yarn build"
|
||||
|
||||
+47
-12
@@ -22,7 +22,6 @@ import Trans from "@excalidraw/excalidraw/components/Trans";
|
||||
import {
|
||||
APP_NAME,
|
||||
EVENT,
|
||||
THEME,
|
||||
VERSION_TIMEOUT,
|
||||
debounce,
|
||||
getVersion,
|
||||
@@ -34,7 +33,7 @@ import {
|
||||
isDevEnv,
|
||||
} from "@excalidraw/common";
|
||||
import polyfill from "@excalidraw/excalidraw/polyfill";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { loadFromBlob } from "@excalidraw/excalidraw/data/blob";
|
||||
import { t } from "@excalidraw/excalidraw/i18n";
|
||||
|
||||
@@ -98,6 +97,7 @@ import Collab, {
|
||||
collabAPIAtom,
|
||||
isCollaboratingAtom,
|
||||
isOfflineAtom,
|
||||
userToFollowAtom,
|
||||
} from "./collab/Collab";
|
||||
import { AppFooter } from "./components/AppFooter";
|
||||
import { AppMainMenu } from "./components/AppMainMenu";
|
||||
@@ -140,6 +140,7 @@ import DebugCanvas, {
|
||||
isVisualDebuggerEnabled,
|
||||
loadSavedDebugState,
|
||||
} from "./components/DebugCanvas";
|
||||
import { useSimulatedCollaborators } from "./debugCollaborators";
|
||||
import { AIComponents } from "./components/AI";
|
||||
import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
|
||||
|
||||
@@ -410,6 +411,37 @@ const ExcalidrawWrapper = () => {
|
||||
return isCollaborationLink(window.location.href);
|
||||
});
|
||||
const collabError = useAtomValue(collabErrorIndicatorAtom);
|
||||
const userToFollow = useAtomValue(userToFollowAtom);
|
||||
|
||||
const viewportStatusFrame = useMemo(
|
||||
() =>
|
||||
userToFollow
|
||||
? {
|
||||
border: "var(--color-primary-hover)",
|
||||
label: {
|
||||
label: (
|
||||
<>
|
||||
Following{" "}
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
maxWidth: 100,
|
||||
}}
|
||||
title={userToFollow.username}
|
||||
>
|
||||
{userToFollow.username}
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
onClose: () => collabAPI?.setUserToFollow(null),
|
||||
},
|
||||
}
|
||||
: null,
|
||||
[userToFollow, collabAPI],
|
||||
);
|
||||
|
||||
useHandleLibrary({
|
||||
excalidrawAPI,
|
||||
@@ -435,6 +467,11 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
}, [excalidrawAPI]);
|
||||
|
||||
// ?collaborators=<N> — populate the canvas with N static fake
|
||||
// collaborators for exercising avatar/UserList UI without a real
|
||||
// collab room
|
||||
useSimulatedCollaborators(excalidrawAPI);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoisted loadImages
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -909,6 +946,8 @@ const ExcalidrawWrapper = () => {
|
||||
})}
|
||||
>
|
||||
<Excalidraw
|
||||
viewportStatusFrame={viewportStatusFrame}
|
||||
userToFollow={userToFollow}
|
||||
onChange={onChange}
|
||||
onExport={onExport}
|
||||
initialData={initialStatePromiseRef.current.promise}
|
||||
@@ -952,6 +991,7 @@ const ExcalidrawWrapper = () => {
|
||||
handleKeyboardGlobally={true}
|
||||
autoFocus={true}
|
||||
theme={editorTheme}
|
||||
onThemeChange={setAppTheme}
|
||||
renderTopRightUI={(isMobile) => {
|
||||
if (isMobile || !collabAPI || isCollabDisabled) {
|
||||
return null;
|
||||
@@ -979,7 +1019,11 @@ const ExcalidrawWrapper = () => {
|
||||
onLinkOpen={(element, event) => {
|
||||
if (element.link && isElementLink(element.link)) {
|
||||
event.preventDefault();
|
||||
excalidrawAPI?.scrollToContent(element.link, { animate: true });
|
||||
excalidrawAPI?.setViewport({
|
||||
target: element.link,
|
||||
fit: "scale-down",
|
||||
animation: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -988,7 +1032,6 @@ const ExcalidrawWrapper = () => {
|
||||
isCollaborating={isCollaborating}
|
||||
isCollabEnabled={!isCollabDisabled}
|
||||
theme={appTheme}
|
||||
setTheme={(theme) => setAppTheme(theme)}
|
||||
refresh={() => forceRefresh((prev) => !prev)}
|
||||
/>
|
||||
<AppWelcomeScreen
|
||||
@@ -1229,14 +1272,6 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
...CommandPalette.defaultItems.toggleTheme,
|
||||
perform: () => {
|
||||
setAppTheme(
|
||||
editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("labels.installPWA"),
|
||||
category: DEFAULT_CATEGORIES.app,
|
||||
|
||||
@@ -48,6 +48,7 @@ import type {
|
||||
SocketId,
|
||||
Collaborator,
|
||||
Gesture,
|
||||
UserToFollow,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
|
||||
|
||||
@@ -108,6 +109,7 @@ interface CollabState {
|
||||
}
|
||||
|
||||
export const activeRoomLinkAtom = atom<string | null>(null);
|
||||
export const userToFollowAtom = atom<UserToFollow | null>(null);
|
||||
|
||||
type CollabInstance = InstanceType<typeof Collab>;
|
||||
|
||||
@@ -123,6 +125,7 @@ export interface CollabAPI {
|
||||
getUsername: CollabInstance["getUsername"];
|
||||
getActiveRoomLink: CollabInstance["getActiveRoomLink"];
|
||||
setCollabError: CollabInstance["setErrorDialog"];
|
||||
setUserToFollow: CollabInstance["setUserToFollow"];
|
||||
}
|
||||
|
||||
interface CollabProps {
|
||||
@@ -139,6 +142,8 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
private socketInitializationTimer?: number;
|
||||
private lastBroadcastedOrReceivedSceneVersion: number = -1;
|
||||
private collaborators = new Map<SocketId, Collaborator>();
|
||||
/** the socket ids of the users following the current user */
|
||||
private followedBy = new Set<SocketId>();
|
||||
|
||||
constructor(props: CollabProps) {
|
||||
super(props);
|
||||
@@ -212,7 +217,9 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
window.addEventListener(EVENT.UNLOAD, this.onUnload);
|
||||
|
||||
const unsubOnUserFollow = this.excalidrawAPI.onUserFollow((payload) => {
|
||||
this.portal.socket && this.portal.broadcastUserFollowed(payload);
|
||||
this.setUserToFollow(
|
||||
payload.action === "FOLLOW" ? payload.userToFollow : null,
|
||||
);
|
||||
});
|
||||
const throttledRelayUserViewportBounds = throttleRAF(
|
||||
this.relayVisibleSceneBounds,
|
||||
@@ -238,6 +245,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
getUsername: this.getUsername,
|
||||
getActiveRoomLink: this.getActiveRoomLink,
|
||||
setCollabError: this.setErrorDialog,
|
||||
setUserToFollow: this.setUserToFollow,
|
||||
};
|
||||
|
||||
appJotaiStore.set(collabAPIAtom, collabAPI);
|
||||
@@ -406,9 +414,11 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
this.lastBroadcastedOrReceivedSceneVersion = -1;
|
||||
this.portal.close();
|
||||
this.fileManager.reset();
|
||||
this.followedBy = new Set();
|
||||
if (!opts?.isUnload) {
|
||||
this.setIsCollaborating(false);
|
||||
this.setActiveRoomLink(null);
|
||||
appJotaiStore.set(userToFollowAtom, null);
|
||||
this.collaborators = new Map();
|
||||
this.excalidrawAPI.updateScene({
|
||||
collaborators: this.collaborators,
|
||||
@@ -629,11 +639,11 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
case WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS: {
|
||||
const { sceneBounds, socketId } = decryptedData.payload;
|
||||
|
||||
const appState = this.excalidrawAPI.getAppState();
|
||||
const userToFollow = appJotaiStore.get(userToFollowAtom);
|
||||
|
||||
// we're not following the user
|
||||
// (shouldn't happen, but could be late message or bug upstream)
|
||||
if (appState.userToFollow?.socketId !== socketId) {
|
||||
if (userToFollow?.socketId !== socketId) {
|
||||
console.warn(
|
||||
`receiving remote client's (from ${socketId}) viewport bounds even though we're not subscribed to it!`,
|
||||
);
|
||||
@@ -641,19 +651,17 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
}
|
||||
|
||||
// cross-follow case, ignore updates in this case
|
||||
if (
|
||||
appState.userToFollow &&
|
||||
appState.followedBy.has(appState.userToFollow.socketId)
|
||||
) {
|
||||
if (this.followedBy.has(userToFollow.socketId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const appState = this.excalidrawAPI.getAppState();
|
||||
|
||||
this.excalidrawAPI.updateScene({
|
||||
appState: zoomToFitBounds({
|
||||
appState,
|
||||
bounds: sceneBounds,
|
||||
fitToViewport: true,
|
||||
viewportZoomFactor: 1,
|
||||
fit: "contain",
|
||||
}).appState,
|
||||
});
|
||||
|
||||
@@ -690,9 +698,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
this.portal.socket.on(
|
||||
WS_EVENTS.USER_FOLLOW_ROOM_CHANGE,
|
||||
(followedBy: SocketId[]) => {
|
||||
this.excalidrawAPI.updateScene({
|
||||
appState: { followedBy: new Set(followedBy) },
|
||||
});
|
||||
this.followedBy = new Set(followedBy);
|
||||
|
||||
this.relayVisibleSceneBounds({ force: true });
|
||||
},
|
||||
@@ -870,26 +876,38 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
const collaborators: InstanceType<typeof Collab>["collaborators"] =
|
||||
new Map();
|
||||
for (const socketId of sockets) {
|
||||
const isCurrentUser = socketId === this.portal.socket?.id;
|
||||
collaborators.set(
|
||||
socketId,
|
||||
Object.assign({}, this.collaborators.get(socketId), {
|
||||
isCurrentUser: socketId === this.portal.socket?.id,
|
||||
}),
|
||||
Object.assign(
|
||||
// we never receive our own broadcasts, so we need to seed
|
||||
// our own collaborator entry with the local username
|
||||
isCurrentUser ? { username: this.state.username } : {},
|
||||
this.collaborators.get(socketId),
|
||||
{ isCurrentUser },
|
||||
),
|
||||
);
|
||||
}
|
||||
this.collaborators = collaborators;
|
||||
this.excalidrawAPI.updateScene({ collaborators });
|
||||
|
||||
// unfollow if the followed user left the room
|
||||
const userToFollow = appJotaiStore.get(userToFollowAtom);
|
||||
if (userToFollow && !collaborators.has(userToFollow.socketId)) {
|
||||
this.setUserToFollow(null);
|
||||
}
|
||||
}
|
||||
|
||||
updateCollaborator = (socketId: SocketId, updates: Partial<Collaborator>) => {
|
||||
const isCurrentUser = socketId === this.portal.socket?.id;
|
||||
const collaborators = new Map(this.collaborators);
|
||||
const user: Mutable<Collaborator> = Object.assign(
|
||||
{},
|
||||
// we never receive our own broadcasts, so we need to seed
|
||||
// our own collaborator entry with the local username
|
||||
isCurrentUser ? { username: this.state.username } : {},
|
||||
collaborators.get(socketId),
|
||||
updates,
|
||||
{
|
||||
isCurrentUser: socketId === this.portal.socket?.id,
|
||||
},
|
||||
{ isCurrentUser },
|
||||
);
|
||||
collaborators.set(socketId, user);
|
||||
this.collaborators = collaborators;
|
||||
@@ -925,12 +943,10 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
);
|
||||
|
||||
relayVisibleSceneBounds = (props?: { force: boolean }) => {
|
||||
const appState = this.excalidrawAPI.getAppState();
|
||||
|
||||
if (this.portal.socket && (appState.followedBy.size > 0 || props?.force)) {
|
||||
if (this.portal.socket && (this.followedBy.size > 0 || props?.force)) {
|
||||
this.portal.broadcastVisibleSceneBounds(
|
||||
{
|
||||
sceneBounds: getVisibleSceneBounds(appState),
|
||||
sceneBounds: getVisibleSceneBounds(this.excalidrawAPI.getAppState()),
|
||||
},
|
||||
`follow@${this.portal.socket.id}`,
|
||||
);
|
||||
@@ -985,9 +1001,37 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
{ leading: false },
|
||||
);
|
||||
|
||||
setUserToFollow = (userToFollow: UserToFollow | null) => {
|
||||
const prev = appJotaiStore.get(userToFollowAtom) ?? null;
|
||||
|
||||
if (prev?.socketId !== userToFollow?.socketId && this.portal.socket) {
|
||||
// leave the previous user's follow room before joining the next one
|
||||
if (prev) {
|
||||
this.portal.broadcastUserFollowed({
|
||||
userToFollow: prev,
|
||||
action: "UNFOLLOW",
|
||||
});
|
||||
}
|
||||
if (userToFollow) {
|
||||
this.portal.broadcastUserFollowed({
|
||||
userToFollow,
|
||||
action: "FOLLOW",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
appJotaiStore.set(userToFollowAtom, userToFollow);
|
||||
};
|
||||
|
||||
setUsername = (username: string) => {
|
||||
this.setState({ username });
|
||||
saveUsernameToLocalStorage(username);
|
||||
|
||||
// keep our own collaborator entry in sync
|
||||
const socketId = this.portal.socket?.id as SocketId | undefined;
|
||||
if (socketId && this.collaborators.has(socketId)) {
|
||||
this.updateCollaborator(socketId, { username });
|
||||
}
|
||||
};
|
||||
|
||||
getUsername = () => this.state.username;
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import {
|
||||
DiagramToCodePlugin,
|
||||
exportToBlob,
|
||||
getNonDeletedElements,
|
||||
getTextFromElements,
|
||||
MIME_TYPES,
|
||||
parseSSEStream,
|
||||
TTDDialog,
|
||||
TTDStreamFetch,
|
||||
} from "@excalidraw/excalidraw";
|
||||
import { getDataURL } from "@excalidraw/excalidraw/data/blob";
|
||||
import { safelyParseJSON } from "@excalidraw/common";
|
||||
|
||||
import type { StreamChunk } from "@excalidraw/excalidraw";
|
||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { TTDIndexedDBAdapter } from "../data/TTDStorage";
|
||||
@@ -21,11 +24,18 @@ export const AIComponents = ({
|
||||
return (
|
||||
<>
|
||||
<DiagramToCodePlugin
|
||||
generate={async ({ frame, children }) => {
|
||||
generate={async ({ frame, children, onPartial }) => {
|
||||
const appState = excalidrawAPI.getAppState();
|
||||
|
||||
// SAFETY: This should never happen, but log it just in case
|
||||
if (children.some((el) => el.isDeleted)) {
|
||||
console.error(
|
||||
"[NONDELETED][INVARIANT] Generated children elements should not be `isDeleted: true`",
|
||||
);
|
||||
}
|
||||
|
||||
const blob = await exportToBlob({
|
||||
elements: children,
|
||||
elements: getNonDeletedElements(children),
|
||||
appState: {
|
||||
...appState,
|
||||
exportBackground: true,
|
||||
@@ -43,11 +53,11 @@ export const AIComponents = ({
|
||||
const response = await fetch(
|
||||
`${
|
||||
import.meta.env.VITE_APP_AI_BACKEND
|
||||
}/v1/ai/diagram-to-code/generate`,
|
||||
}/v1/ai/diagram-to-code/generate-streaming`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Accept: "text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
@@ -86,18 +96,57 @@ export const AIComponents = ({
|
||||
throw new Error(errorJSON.message || text);
|
||||
}
|
||||
|
||||
try {
|
||||
const { html } = await response.json();
|
||||
const reader = response.body?.getReader();
|
||||
|
||||
if (!html) {
|
||||
throw new Error("Generation failed (invalid response)");
|
||||
}
|
||||
return {
|
||||
html,
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (!reader) {
|
||||
throw new Error("Generation failed (invalid response)");
|
||||
}
|
||||
|
||||
let html = "";
|
||||
let streamError: Error | null = null;
|
||||
|
||||
for await (const data of parseSSEStream(reader)) {
|
||||
if (data === "[DONE]") {
|
||||
break;
|
||||
}
|
||||
|
||||
const chunk = safelyParseJSON(data) as StreamChunk | null;
|
||||
|
||||
if (!chunk) {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (chunk.type) {
|
||||
case "content": {
|
||||
if (chunk.delta) {
|
||||
html += chunk.delta;
|
||||
onPartial?.(html);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "error": {
|
||||
streamError = new Error(
|
||||
chunk.error.message || "Generation failed",
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "done": {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (streamError) {
|
||||
throw streamError;
|
||||
}
|
||||
|
||||
if (!html.trim()) {
|
||||
throw new Error("Generation failed (invalid response)");
|
||||
}
|
||||
|
||||
return {
|
||||
html,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ExcalLogo,
|
||||
eyeIcon,
|
||||
} from "@excalidraw/excalidraw/components/icons";
|
||||
import { useI18n } from "@excalidraw/excalidraw/i18n";
|
||||
import { MainMenu } from "@excalidraw/excalidraw/index";
|
||||
import React from "react";
|
||||
|
||||
@@ -20,9 +21,9 @@ export const AppMainMenu: React.FC<{
|
||||
isCollaborating: boolean;
|
||||
isCollabEnabled: boolean;
|
||||
theme: Theme | "system";
|
||||
setTheme: (theme: Theme | "system") => void;
|
||||
refresh: () => void;
|
||||
}> = React.memo((props) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<MainMenu>
|
||||
<MainMenu.DefaultItems.LoadScene />
|
||||
@@ -57,7 +58,7 @@ export const AppMainMenu: React.FC<{
|
||||
}?utm_source=signin&utm_medium=app&utm_content=hamburger`}
|
||||
className="highlighted"
|
||||
>
|
||||
{isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
|
||||
{isExcalidrawPlusSignedUser ? t("labels.signIn") : t("labels.signUp")}
|
||||
</MainMenu.ItemLink>
|
||||
{isDevEnv() && (
|
||||
<MainMenu.Item
|
||||
@@ -78,11 +79,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>
|
||||
|
||||
@@ -4,21 +4,32 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 1rem;
|
||||
gap: 1.5rem;
|
||||
flex: 1 1 auto;
|
||||
|
||||
.link-button {
|
||||
margin: 0 auto !important;
|
||||
width: 100%;
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.app-sidebar-promo-image {
|
||||
margin: 1rem 0;
|
||||
|
||||
height: 16.25rem;
|
||||
height: 295px;
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: calc(
|
||||
var(--right-sidebar-width, 100%) - var(--space-factor) * 2
|
||||
);
|
||||
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
transparent 60%,
|
||||
transparent 0%,
|
||||
var(--sidebar-bg-color) 100%
|
||||
),
|
||||
var(--image-source);
|
||||
@@ -33,4 +44,49 @@
|
||||
.link-button {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.app-sidebar-promo-text {
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.app-sidebar-promo-illustration {
|
||||
position: relative;
|
||||
min-height: 7rem;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 0.125rem;
|
||||
|
||||
.app-sidebar-promo-heart {
|
||||
width: 46px;
|
||||
transform: translateX(-14px);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.app-sidebar-promo-trial-note {
|
||||
position: absolute;
|
||||
top: 2.75rem;
|
||||
left: 50%;
|
||||
transform: translateX(-56%) rotate(-7deg);
|
||||
font-size: 1rem;
|
||||
line-height: 1.15;
|
||||
color: var(--color-primary);
|
||||
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-sidebar-promo-trial-arrow {
|
||||
position: absolute;
|
||||
top: 3.35rem;
|
||||
left: 55%;
|
||||
width: 4.5rem;
|
||||
height: 3rem;
|
||||
transform: translateX(1.75rem) rotate(10deg);
|
||||
color: var(--color-primary);
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,63 @@ import { useUIAppState } from "@excalidraw/excalidraw/context/ui-appState";
|
||||
|
||||
import "./AppSidebar.scss";
|
||||
|
||||
type SidebarPromoCopyProps = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
const SidebarPromoCopy = (props: SidebarPromoCopyProps) => {
|
||||
return (
|
||||
<div className="app-sidebar-promo-copy">
|
||||
<div className="app-sidebar-promo-illustration" aria-hidden="true">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 300 250"
|
||||
className="app-sidebar-promo-heart"
|
||||
>
|
||||
<path
|
||||
d="M 145 75
|
||||
C 110 35, 60 55, 65 120
|
||||
C 70 180, 140 190, 215 200
|
||||
C 225 180, 260 110, 235 55
|
||||
C 210 -5, 140 20, 160 105"
|
||||
fill="none"
|
||||
stroke="#D06B64"
|
||||
strokeWidth="16"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div className="app-sidebar-promo-trial-note excalifont">
|
||||
14 days of
|
||||
<br />
|
||||
free trial
|
||||
</div>
|
||||
<svg
|
||||
className="app-sidebar-promo-trial-arrow"
|
||||
viewBox="0 0 72 48"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M5 6C23 1 50 8 48 32"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M42 26L48 32L54 26"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="app-sidebar-promo-text">{props.text}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AppSidebar = () => {
|
||||
const { theme, openSidebar } = useUIAppState();
|
||||
|
||||
@@ -32,15 +89,13 @@ export const AppSidebar = () => {
|
||||
<div
|
||||
className="app-sidebar-promo-image"
|
||||
style={{
|
||||
["--image-source" as any]: `url(/oss_promo_comments_${
|
||||
["--image-source" as any]: `url(/sidebar-comments-promo-${
|
||||
theme === THEME.DARK ? "dark" : "light"
|
||||
}.jpg)`,
|
||||
opacity: 0.7,
|
||||
opacity: 0.9,
|
||||
}}
|
||||
/>
|
||||
<div className="app-sidebar-promo-text">
|
||||
Make comments with Excalidraw+
|
||||
</div>
|
||||
<SidebarPromoCopy text="Make comments with Excalidraw+" />
|
||||
<LinkButton
|
||||
href={`${
|
||||
import.meta.env.VITE_APP_PLUS_LP
|
||||
@@ -55,16 +110,13 @@ export const AppSidebar = () => {
|
||||
<div
|
||||
className="app-sidebar-promo-image"
|
||||
style={{
|
||||
["--image-source" as any]: `url(/oss_promo_presentations_${
|
||||
["--image-source" as any]: `url(/sidebar-presentation-promo-${
|
||||
theme === THEME.DARK ? "dark" : "light"
|
||||
}.svg)`,
|
||||
backgroundSize: "60%",
|
||||
opacity: 0.4,
|
||||
}.jpg)`,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
<div className="app-sidebar-promo-text">
|
||||
Create presentations with Excalidraw+
|
||||
</div>
|
||||
<SidebarPromoCopy text="Create presentation with Excalidraw+" />
|
||||
<LinkButton
|
||||
href={`${
|
||||
import.meta.env.VITE_APP_PLUS_LP
|
||||
|
||||
@@ -72,7 +72,7 @@ export const AppWelcomeScreen: React.FC<{
|
||||
shortcut={null}
|
||||
icon={loginIcon}
|
||||
>
|
||||
Sign up
|
||||
{t("labels.signUp")}
|
||||
</WelcomeScreen.Center.MenuItemLink>
|
||||
)}
|
||||
</WelcomeScreen.Center.Menu>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useI18n } from "@excalidraw/excalidraw/i18n";
|
||||
|
||||
export const ExcalidrawPlusPromoBanner = ({
|
||||
isSignedIn,
|
||||
}: {
|
||||
isSignedIn: boolean;
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<a
|
||||
href={
|
||||
@@ -16,7 +19,7 @@ export const ExcalidrawPlusPromoBanner = ({
|
||||
rel="noopener"
|
||||
className="plus-banner"
|
||||
>
|
||||
Excalidraw+
|
||||
{isSignedIn ? t("labels.signIn") : t("labels.upgrade")}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import { nanoid } from "nanoid";
|
||||
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
||||
import { Card } from "@excalidraw/excalidraw/components/Card";
|
||||
import { ExcalidrawLogo } from "@excalidraw/excalidraw/components/ExcalidrawLogo";
|
||||
import { ToolButton } from "@excalidraw/excalidraw/components/ToolButton";
|
||||
import { IconButton } from "@excalidraw/excalidraw/components/IconButton";
|
||||
import { MIME_TYPES, getFrame } from "@excalidraw/common";
|
||||
import {
|
||||
encryptData,
|
||||
@@ -111,7 +111,7 @@ export const ExportToExcalidrawPlus: React.FC<{
|
||||
<div className="Card-details">
|
||||
{t("exportDialog.excalidrawplus_description")}
|
||||
</div>
|
||||
<ToolButton
|
||||
<IconButton
|
||||
className="Card-button"
|
||||
type="button"
|
||||
title={t("exportDialog.excalidrawplus_button")}
|
||||
|
||||
@@ -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,6 +38,7 @@ 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";
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { isDevEnv, toBrandedType } from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
Collaborator,
|
||||
ExcalidrawImperativeAPI,
|
||||
SocketId,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
/**
|
||||
* Dev-only debug helper: `?collaborators=<N>` on excalidraw-app populates
|
||||
* the canvas with N static fake collaborators (random usernames, one of
|
||||
* them marked as the current user) so avatar/UserList UI can be exercised
|
||||
* without a real collab room or a second browser tab.
|
||||
*
|
||||
* Not meant to coexist with a real collab session — joining an actual room
|
||||
* will overwrite these with the real roster (Collab owns `collaborators`
|
||||
* from that point on).
|
||||
*/
|
||||
|
||||
const getSimulatedCollaboratorCountFromUrl = (): number | null => {
|
||||
const count = Number(
|
||||
new URLSearchParams(window.location.search).get("collaborators"),
|
||||
);
|
||||
return Number.isFinite(count) && count > 0 ? Math.floor(count) : null;
|
||||
};
|
||||
|
||||
const generateSimulatedCollaborators = async (
|
||||
count: number,
|
||||
): Promise<Map<SocketId, Collaborator>> => {
|
||||
const { getRandomUsername } = await import("@excalidraw/random-username");
|
||||
|
||||
const collaborators = new Map<SocketId, Collaborator>();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const socketId = toBrandedType<SocketId>(`debug-collaborator-${i}`);
|
||||
collaborators.set(socketId, {
|
||||
id: socketId,
|
||||
socketId,
|
||||
username: getRandomUsername(),
|
||||
isCurrentUser: i === 0,
|
||||
});
|
||||
}
|
||||
|
||||
return collaborators;
|
||||
};
|
||||
|
||||
export const useSimulatedCollaborators = (
|
||||
excalidrawAPI: ExcalidrawImperativeAPI | null,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
if (!isDevEnv() || !excalidrawAPI) {
|
||||
return;
|
||||
}
|
||||
|
||||
const count = getSimulatedCollaboratorCountFromUrl();
|
||||
if (!count) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
generateSimulatedCollaborators(count).then((collaborators) => {
|
||||
if (!cancelled) {
|
||||
excalidrawAPI.updateScene({ collaborators });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [excalidrawAPI]);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getFeatureFlag } from "@excalidraw/common";
|
||||
import * as Sentry from "@sentry/browser";
|
||||
import callsites from "callsites";
|
||||
|
||||
@@ -34,7 +33,6 @@ Sentry.init({
|
||||
Sentry.captureConsoleIntegration({
|
||||
levels: ["error"],
|
||||
}),
|
||||
Sentry.featureFlagsIntegration(),
|
||||
],
|
||||
beforeSend(event) {
|
||||
if (event.request?.url) {
|
||||
@@ -81,14 +79,3 @@ Sentry.init({
|
||||
return event;
|
||||
},
|
||||
});
|
||||
|
||||
const flagsIntegration =
|
||||
Sentry.getClient()?.getIntegrationByName<Sentry.FeatureFlagsIntegration>(
|
||||
"FeatureFlags",
|
||||
);
|
||||
if (flagsIntegration) {
|
||||
flagsIntegration.addFeatureFlag(
|
||||
"COMPLEX_BINDINGS",
|
||||
getFeatureFlag("COMPLEX_BINDINGS"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defaultLang } from "@excalidraw/excalidraw/i18n";
|
||||
import { Excalidraw, MainMenu } from "@excalidraw/excalidraw";
|
||||
import { defaultLang, languages } from "@excalidraw/excalidraw/i18n";
|
||||
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
import {
|
||||
screen,
|
||||
@@ -7,11 +8,35 @@ import {
|
||||
render,
|
||||
} from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
import ExcalidrawApp from "../App";
|
||||
import { LanguageList } from "../app-language/LanguageList";
|
||||
import { useAppLangCode } from "../app-language/language-state";
|
||||
import { Provider } from "../app-jotai";
|
||||
|
||||
const TEST_LANG_CODE = "fr-FR";
|
||||
|
||||
const TestApp = () => {
|
||||
const [langCode] = useAppLangCode();
|
||||
|
||||
return (
|
||||
<Excalidraw langCode={langCode}>
|
||||
<MainMenu>
|
||||
<MainMenu.ItemCustom>
|
||||
<LanguageList />
|
||||
</MainMenu.ItemCustom>
|
||||
</MainMenu>
|
||||
</Excalidraw>
|
||||
);
|
||||
};
|
||||
|
||||
describe("Test LanguageList", () => {
|
||||
it("rerenders UI on language change", async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
expect(languages.some((lang) => lang.code === TEST_LANG_CODE)).toBe(true);
|
||||
|
||||
await render(
|
||||
<Provider>
|
||||
<TestApp />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
// select rectangle tool to show properties menu
|
||||
UI.clickTool("rectangle");
|
||||
@@ -20,9 +45,9 @@ describe("Test LanguageList", () => {
|
||||
fireEvent.click(document.querySelector(".dropdown-menu-button")!);
|
||||
|
||||
fireEvent.change(document.querySelector(".dropdown-select__language")!, {
|
||||
target: { value: "de-DE" },
|
||||
target: { value: TEST_LANG_CODE },
|
||||
});
|
||||
// switching to german, `thin` label should no longer exist
|
||||
// switching to French, `thin` label should no longer exist
|
||||
await waitFor(() => expect(screen.queryByTitle(/thin/i)).toBeNull());
|
||||
// reset language
|
||||
fireEvent.change(document.querySelector(".dropdown-select__language")!, {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Excalidraw, WelcomeScreen } from "@excalidraw/excalidraw";
|
||||
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
import {
|
||||
mockBoundingClientRect,
|
||||
@@ -5,8 +6,6 @@ import {
|
||||
restoreOriginalGetBoundingClientRect,
|
||||
} from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
import ExcalidrawApp from "../App";
|
||||
|
||||
describe("Test MobileMenu", () => {
|
||||
const { h } = window;
|
||||
const dimensions = { height: 400, width: 800 };
|
||||
@@ -16,7 +15,11 @@ describe("Test MobileMenu", () => {
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
await render(
|
||||
<Excalidraw>
|
||||
<WelcomeScreen />
|
||||
</Excalidraw>,
|
||||
);
|
||||
h.app.refreshEditorInterface();
|
||||
});
|
||||
|
||||
|
||||
@@ -50,11 +50,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
|
||||
<div
|
||||
class="welcome-screen-center__heading welcome-screen-decor excalifont"
|
||||
>
|
||||
Your drawings are saved in your browser's storage.
|
||||
<br />
|
||||
Browser storage can be cleared unexpectedly.
|
||||
<br />
|
||||
Save your work to a file regularly to avoid losing it.
|
||||
Diagrams. Made. Simple.
|
||||
</div>
|
||||
<div
|
||||
class="welcome-screen-menu"
|
||||
@@ -149,103 +145,6 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
|
||||
?
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="welcome-screen-menu-item "
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="welcome-screen-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
/>
|
||||
<circle
|
||||
cx="9"
|
||||
cy="7"
|
||||
r="4"
|
||||
/>
|
||||
<path
|
||||
d="M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2"
|
||||
/>
|
||||
<path
|
||||
d="M16 3.13a4 4 0 0 1 0 7.75"
|
||||
/>
|
||||
<path
|
||||
d="M21 21v-2a4 4 0 0 0 -3 -3.85"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="welcome-screen-menu-item__text"
|
||||
>
|
||||
Live collaboration...
|
||||
</div>
|
||||
</button>
|
||||
<a
|
||||
class="welcome-screen-menu-item "
|
||||
href="undefined/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<div
|
||||
class="welcome-screen-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
/>
|
||||
<path
|
||||
d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2"
|
||||
/>
|
||||
<path
|
||||
d="M21 12h-13l3 -3"
|
||||
/>
|
||||
<path
|
||||
d="M11 15l-3 -3"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="welcome-screen-menu-item__text"
|
||||
>
|
||||
Sign up
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
+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",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
applyDarkModeFilter,
|
||||
COLOR_PALETTE,
|
||||
removeDarkModeFilter,
|
||||
rgbToHex,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
@@ -215,6 +216,24 @@ describe("applyDarkModeFilter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeDarkModeFilter", () => {
|
||||
it("restores black and white", () => {
|
||||
expect(removeDarkModeFilter("#ededed")).toBe("#000000");
|
||||
expect(removeDarkModeFilter("#121212")).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it.each([
|
||||
COLOR_PALETTE.black,
|
||||
COLOR_PALETTE.white,
|
||||
COLOR_PALETTE.red[4],
|
||||
COLOR_PALETTE.green[4],
|
||||
COLOR_PALETTE.blue[4],
|
||||
])("preserves the rendered dark-mode color for %s", (color) => {
|
||||
const filtered = applyDarkModeFilter(color);
|
||||
expect(applyDarkModeFilter(removeDarkModeFilter(filtered))).toBe(filtered);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rgbToHex", () => {
|
||||
describe("basic RGB conversion", () => {
|
||||
it("converts black (0,0,0)", () => {
|
||||
|
||||
@@ -13,6 +13,9 @@ import type { Degrees } from "@excalidraw/math";
|
||||
const DARK_MODE_COLORS_CACHE: Map<string, string> | null =
|
||||
typeof window !== "undefined" ? new Map() : null;
|
||||
|
||||
const DARK_MODE_FILTER_INVERT_PERCENT = 93;
|
||||
const DARK_MODE_FILTER_HUE_ROTATE_DEGREES = 180 as Degrees;
|
||||
|
||||
function cssHueRotate(
|
||||
red: number,
|
||||
green: number,
|
||||
@@ -80,7 +83,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;
|
||||
@@ -92,12 +99,17 @@ export const applyDarkModeFilter = (color: string): string => {
|
||||
// order of operations matters
|
||||
// (corresponds to "filter: invert(invertPercent) hue-rotate(hueDegrees)" in css)
|
||||
const rgb = tc.toRgb();
|
||||
const inverted = cssInvert(rgb.r, rgb.g, rgb.b, 93);
|
||||
const inverted = cssInvert(
|
||||
rgb.r,
|
||||
rgb.g,
|
||||
rgb.b,
|
||||
DARK_MODE_FILTER_INVERT_PERCENT,
|
||||
);
|
||||
const rotated = cssHueRotate(
|
||||
inverted.r,
|
||||
inverted.g,
|
||||
inverted.b,
|
||||
180 as Degrees,
|
||||
DARK_MODE_FILTER_HUE_ROTATE_DEGREES,
|
||||
);
|
||||
|
||||
const result = rgbToHex(rotated.r, rotated.g, rotated.b, alpha);
|
||||
@@ -109,6 +121,44 @@ export const applyDarkModeFilter = (color: string): string => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const _reverseDarkModeInvert = (
|
||||
r: number,
|
||||
g: number,
|
||||
b: number,
|
||||
): { r: number; g: number; b: number } => {
|
||||
const p = DARK_MODE_FILTER_INVERT_PERCENT / 100;
|
||||
const denominator = 1 - 2 * p;
|
||||
const restore = (color: number) =>
|
||||
Math.round(clamp((color - 255 * p) / denominator, 0, 255));
|
||||
|
||||
return {
|
||||
r: restore(r),
|
||||
g: restore(g),
|
||||
b: restore(b),
|
||||
};
|
||||
};
|
||||
|
||||
export const removeDarkModeFilter = (color: string): string => {
|
||||
const tc = tinycolor(color);
|
||||
const alpha = tc.getAlpha();
|
||||
const rgb = tc.toRgb();
|
||||
|
||||
// 180deg hue rotation is its own inverse, so undo it before the inversion.
|
||||
const rotatedBack = cssHueRotate(
|
||||
rgb.r,
|
||||
rgb.g,
|
||||
rgb.b,
|
||||
DARK_MODE_FILTER_HUE_ROTATE_DEGREES,
|
||||
);
|
||||
const restored = _reverseDarkModeInvert(
|
||||
rotatedBack.r,
|
||||
rotatedBack.g,
|
||||
rotatedBack.b,
|
||||
);
|
||||
|
||||
return rgbToHex(restored.r, restored.g, restored.b, alpha);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color palette
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -180,6 +230,11 @@ const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
|
||||
// quick picks defaults
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/** number of slots in the color-picker top-picks strip — the strip layout is
|
||||
* sized for exactly this many swatches, and pick customization (replace /
|
||||
* reorder) preserves it */
|
||||
export const COLOR_TOP_PICKS_SLOTS = 5;
|
||||
|
||||
// ORDER matters for positioning in quick picker
|
||||
export const DEFAULT_ELEMENT_STROKE_PICKS = [
|
||||
COLOR_PALETTE.black,
|
||||
@@ -198,6 +253,33 @@ export const DEFAULT_ELEMENT_BACKGROUND_PICKS = [
|
||||
COLOR_PALETTE.yellow[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
|
||||
] as ColorTuple;
|
||||
|
||||
// ORDER matters for positioning in quick picker.
|
||||
// Same as element background picks, with `transparent` (a no-op fill for the
|
||||
// bucket tool) swapped for white.
|
||||
export const BUCKET_FILL_BACKGROUND_PICKS = [
|
||||
COLOR_PALETTE.white,
|
||||
COLOR_PALETTE.red[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
|
||||
COLOR_PALETTE.green[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
|
||||
COLOR_PALETTE.blue[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
|
||||
COLOR_PALETTE.yellow[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
|
||||
] as ColorTuple;
|
||||
|
||||
// sticky notes are their own color domain (own defaults, own picks)
|
||||
export const DEFAULT_STICKY_NOTE_BG = "#ffdf6b";
|
||||
|
||||
export const STICKY_NOTE_STROKE_PICKS: ColorTuple =
|
||||
DEFAULT_ELEMENT_STROKE_PICKS;
|
||||
|
||||
// ORDER matters for positioning in quick picker. Classic note colors; the
|
||||
// first one is `DEFAULT_STICKY_NOTE_BG`. Never transparent.
|
||||
export const STICKY_NOTE_BACKGROUND_PICKS = [
|
||||
DEFAULT_STICKY_NOTE_BG,
|
||||
COLOR_PALETTE.pink[1],
|
||||
COLOR_PALETTE.green[1],
|
||||
COLOR_PALETTE.blue[1],
|
||||
COLOR_PALETTE.orange[1],
|
||||
] as ColorTuple;
|
||||
|
||||
// ORDER matters for positioning in quick picker
|
||||
export const DEFAULT_CANVAS_BACKGROUND_PICKS = [
|
||||
COLOR_PALETTE.white,
|
||||
@@ -278,6 +360,19 @@ export const rgbToHex = (r: number, g: number, b: number, a?: number) => {
|
||||
return hex6;
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns the color with its alpha replaced by `alpha` (as #RRGGBBAA), or the
|
||||
* input unchanged if it's not a valid color
|
||||
*/
|
||||
export const setColorAlpha = (color: string, alpha: number): string => {
|
||||
const tc = tinycolor(color);
|
||||
if (!tc.isValid()) {
|
||||
return color;
|
||||
}
|
||||
const { r, g, b } = tc.toRgb();
|
||||
return rgbToHex(r, g, b, alpha);
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns #RRGGBB or #RRGGBBAA based on color containing non-opaque alpha,
|
||||
* null if not valid color
|
||||
@@ -295,6 +390,17 @@ export const isTransparent = (color: string) => {
|
||||
return tinycolor(color).getAlpha() === 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the color is fully opaque (alpha exactly 1).
|
||||
*
|
||||
* NOT the negation of `isTransparent`, which is true only at alpha 0 — a
|
||||
* partial-alpha color (`#RRGGBBAA`, `rgba(…, 0.5)`) is neither transparent
|
||||
* (it renders) nor opaque (what's beneath shows through it).
|
||||
*/
|
||||
export const isOpaqueColor = (color: string) => {
|
||||
return tinycolor(color).getAlpha() === 1;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// color contract helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -2,10 +2,16 @@ import type {
|
||||
ExcalidrawElement,
|
||||
FontFamilyValues,
|
||||
} from "@excalidraw/element/types";
|
||||
import type { AppProps, AppState } from "@excalidraw/excalidraw/types";
|
||||
import type {
|
||||
AppProps,
|
||||
AppState,
|
||||
NormalizedZoomValue,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { COLOR_PALETTE } from "./colors";
|
||||
|
||||
export { DEFAULT_STICKY_NOTE_BG } from "./colors";
|
||||
|
||||
export const supportsResizeObserver =
|
||||
typeof window !== "undefined" && "ResizeObserver" in window;
|
||||
|
||||
@@ -16,6 +22,12 @@ export const APP_NAME = "Excalidraw";
|
||||
// don't unintentionally create text elements that are wrapped to a few chars
|
||||
// (happens a lot with fast clicks with the text tool)
|
||||
export const TEXT_AUTOWRAP_THRESHOLD = 36; // px
|
||||
// room left at each side of the visible canvas when a text stops growing at
|
||||
// its width, or is brought into view once it wraps
|
||||
export const TEXT_VIEWPORT_PADDING = 20; // px
|
||||
// the widest a typed or pasted text gets before it wraps (in scene units, so
|
||||
// at any zoom), however wide the view
|
||||
export const TEXT_MAX_WRAP_WIDTH = 800; // px
|
||||
export const DRAGGING_THRESHOLD = 10; // px
|
||||
export const MINIMUM_ARROW_SIZE = 20; // px
|
||||
export const LINE_CONFIRM_THRESHOLD = 8; // px
|
||||
@@ -69,6 +81,7 @@ export enum EVENT {
|
||||
POINTER_MOVE = "pointermove",
|
||||
POINTER_DOWN = "pointerdown",
|
||||
POINTER_UP = "pointerup",
|
||||
POINTER_CANCEL = "pointercancel",
|
||||
STATE_CHANGE = "statechange",
|
||||
WHEEL = "wheel",
|
||||
TOUCH_START = "touchstart",
|
||||
@@ -211,7 +224,56 @@ export const FRAME_STYLE = {
|
||||
|
||||
export const MIN_FONT_SIZE = 1;
|
||||
export const DEFAULT_FONT_SIZE = 20;
|
||||
export const STICKY_NOTE_MIN_FONT_SIZE = 16;
|
||||
export const STICKY_NOTE_MAX_FONT_SIZE = 512;
|
||||
export const STICKY_NOTE_FALLBACK_FONT_SIZE = 28;
|
||||
export const STICKY_NOTE_FONT_STEP = 2;
|
||||
export const STICKY_NOTE_PADDING = 16;
|
||||
/**
|
||||
* The creation-date footer: a 20px text row under the label body plus a 12px
|
||||
* gap above it, inside the note's bottom padding. Reserved for every note —
|
||||
* also when `created` is unknown — so geometry never depends on data
|
||||
* availability. The label is chosen by width bucket, never measured, so
|
||||
* painting stays measurement-free (the server has no text measurer):
|
||||
* `minBodyWidthForYear` is the worst case ("30 May 2026") in the system sans
|
||||
* stack at 12px, with margin for wider fallbacks such as DejaVu Sans.
|
||||
*/
|
||||
/**
|
||||
* The creation-date footer of a sticky note. The label body ends `height`
|
||||
* above the note's bottom padding, and the date's baseline sits
|
||||
* `baselineFromBottom` above the note's bottom edge — so the 12px glyphs
|
||||
* (~9px above the baseline, ~3px below) end up roughly 11px from the edge
|
||||
* with a ~13px gap to the label body above them.
|
||||
*/
|
||||
export const STICKY_NOTE_FOOTER = {
|
||||
height: 20,
|
||||
fontSize: 12,
|
||||
fontFamily: "Helvetica, Arial, sans-serif",
|
||||
baselineFromBottom: 14,
|
||||
opacity: 1,
|
||||
minBodyWidthForYear: 80,
|
||||
} as const;
|
||||
/**
|
||||
* outer height → label body height: top + bottom padding + footer. The body
|
||||
* is what the label is fitted into; a middle-aligned label is still centered
|
||||
* in the whole padded note when it fits (see `computeBoundTextPosition`).
|
||||
*/
|
||||
export const STICKY_NOTE_BODY_INSET_Y =
|
||||
STICKY_NOTE_PADDING * 2 + STICKY_NOTE_FOOTER.height;
|
||||
export const DEFAULT_STICKY_NOTE_SIZE = 250;
|
||||
// floor for a finalized note's width and base height; the UI floor is font-aware
|
||||
// on top of it (see `getStickyNoteMinSize`)
|
||||
export const STICKY_NOTE_MIN_SIZE = 75;
|
||||
export const STICKY_NOTE_SHADOW_OFFSET = 3;
|
||||
export const STICKY_NOTE_SHADOW_OPACITY = 0.16;
|
||||
export const STICKY_NOTE_EDGE_SHADOW_WIDTH = 0.5;
|
||||
export const STICKY_NOTE_EDGE_SHADOW_OPACITY = 0.08;
|
||||
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Excalifont;
|
||||
/** number of slots in the font-picker top-picks strip — pick customization
|
||||
* (replace / reorder) preserves it. Must equal `DEFAULT_FONTS.length` in
|
||||
* `packages/excalidraw/components/FontPicker/FontPicker.tsx` (enforced by
|
||||
* `fontTopPicks.test.ts`) */
|
||||
export const FONT_TOP_PICKS_SLOTS = 3;
|
||||
export const DEFAULT_TEXT_ALIGN = "left";
|
||||
export const DEFAULT_VERTICAL_ALIGN = "top";
|
||||
export const DEFAULT_VERSION = "{version}";
|
||||
@@ -299,9 +361,14 @@ export const TOUCH_CTX_MENU_TIMEOUT = 500;
|
||||
export const TITLE_TIMEOUT = 10000;
|
||||
export const VERSION_TIMEOUT = 30000;
|
||||
export const SCROLL_TIMEOUT = 100;
|
||||
export const ZEN_MODE_TRANSITION_DURATION = 250;
|
||||
export const ZOOM_STEP = 0.1;
|
||||
export const MIN_ZOOM = 0.1;
|
||||
export const MAX_ZOOM = 30;
|
||||
/** 100% zoom, for computations that have no editor zoom to go by */
|
||||
export const DEFAULT_ZOOM: AppState["zoom"] = Object.freeze({
|
||||
value: 1 as NormalizedZoomValue,
|
||||
});
|
||||
export const HYPERLINK_TOOLTIP_DELAY = 300;
|
||||
|
||||
// Report a user inactive after IDLE_THRESHOLD milliseconds
|
||||
@@ -337,9 +404,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"?>
|
||||
@@ -370,6 +438,7 @@ export const TEXT_ALIGN = {
|
||||
};
|
||||
|
||||
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
|
||||
export const ELEMENT_PENDING_DRAW_SHAPE_OPACITY = 70;
|
||||
|
||||
// Radius represented as 25% of element's largest side (width/height).
|
||||
// Used for LEGACY and PROPORTIONAL_RADIUS algorithms, or when the element is
|
||||
@@ -403,11 +472,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 +527,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,
|
||||
@@ -459,8 +564,11 @@ export const TOOL_TYPE = {
|
||||
hand: "hand",
|
||||
frame: "frame",
|
||||
magicframe: "magicframe",
|
||||
stickynote: "stickynote",
|
||||
embeddable: "embeddable",
|
||||
laser: "laser",
|
||||
autoshape: "autoshape",
|
||||
bucketfill: "bucketfill",
|
||||
} as const;
|
||||
|
||||
export const EDITOR_LS_KEYS = {
|
||||
@@ -507,9 +615,10 @@ export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
|
||||
|
||||
export const DOUBLE_TAP_POSITION_THRESHOLD = 35;
|
||||
|
||||
export const BIND_MODE_TIMEOUT = 700; // ms
|
||||
|
||||
// glass background for mobile action buttons
|
||||
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;
|
||||
|
||||
@@ -51,6 +51,7 @@ export const KEYS = {
|
||||
SLASH: "/",
|
||||
|
||||
A: "a",
|
||||
B: "b",
|
||||
C: "c",
|
||||
D: "d",
|
||||
E: "e",
|
||||
@@ -59,6 +60,7 @@ export const KEYS = {
|
||||
H: "h",
|
||||
I: "i",
|
||||
L: "l",
|
||||
N: "n",
|
||||
O: "o",
|
||||
P: "p",
|
||||
Q: "q",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
pointFrom,
|
||||
pointFromPair,
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
@@ -69,12 +70,12 @@ export const getGridPoint = (
|
||||
x: number,
|
||||
y: number,
|
||||
gridSize: NullableGridSize,
|
||||
): [number, number] => {
|
||||
): GlobalPoint => {
|
||||
if (gridSize) {
|
||||
return [
|
||||
return pointFrom<GlobalPoint>(
|
||||
Math.round(x / gridSize) * gridSize,
|
||||
Math.round(y / gridSize) * gridSize,
|
||||
];
|
||||
);
|
||||
}
|
||||
return [x, y];
|
||||
return pointFrom<GlobalPoint>(x, y);
|
||||
};
|
||||
|
||||
@@ -14,3 +14,20 @@ export const reseed = (seed: number) => {
|
||||
};
|
||||
|
||||
export const randomId = () => (isTestEnv() ? `id${testIdBase++}` : nanoid());
|
||||
|
||||
/**
|
||||
* Deterministic PRNG (mulberry32) for a given seed: same seed, same
|
||||
* sequence — used where a render must be stable across frames and clients
|
||||
* (e.g. the sticky note's corner jitter) and roughjs's `Random` is
|
||||
* unsuitable (it degenerates for seed 0). Returns numbers in [0, 1).
|
||||
*/
|
||||
export const seededRandom = (seed: number) => {
|
||||
let value = seed >>> 0;
|
||||
return () => {
|
||||
value += 0x6d2b79f5;
|
||||
let next = value;
|
||||
next = Math.imul(next ^ (next >>> 15), next | 1);
|
||||
next ^= next + Math.imul(next ^ (next >>> 7), next | 61);
|
||||
return ((next ^ (next >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import {
|
||||
getNearestScrollableContainer,
|
||||
isInputLike,
|
||||
isInteractive,
|
||||
isToolIcon,
|
||||
isTransparent,
|
||||
isWritableElement,
|
||||
mapFind,
|
||||
reduceToCommonValue,
|
||||
} from "@excalidraw/common";
|
||||
@@ -11,6 +16,34 @@ import { throttleRAF } from "./utils";
|
||||
type RafCallback = FrameRequestCallback;
|
||||
|
||||
describe("@excalidraw/common/utils", () => {
|
||||
describe("cross-document element guards", () => {
|
||||
it("uses constructors from the target element's window", () => {
|
||||
const iframe = document.createElement("iframe");
|
||||
document.body.append(iframe);
|
||||
|
||||
const iframeDocument = iframe.contentDocument!;
|
||||
const iframeWindow = iframe.contentWindow!;
|
||||
const textarea = iframeDocument.createElement("textarea");
|
||||
const button = iframeDocument.createElement("button");
|
||||
const toolIcon = iframeDocument.createElement("div");
|
||||
toolIcon.className = "ToolIcon__icon";
|
||||
|
||||
expect(textarea).not.toBeInstanceOf(HTMLTextAreaElement);
|
||||
expect(textarea).toBeInstanceOf(
|
||||
(iframeWindow as Window & typeof globalThis).HTMLTextAreaElement,
|
||||
);
|
||||
expect(isInputLike(textarea)).toBe(true);
|
||||
expect(isWritableElement(textarea)).toBe(true);
|
||||
expect(isInteractive(button)).toBe(true);
|
||||
expect(isToolIcon(toolIcon)).toBe(true);
|
||||
|
||||
iframeDocument.body.append(textarea);
|
||||
expect(getNearestScrollableContainer(textarea)).toBe(iframeDocument);
|
||||
|
||||
iframe.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTransparent()", () => {
|
||||
it("should return true when color is rgb transparent", () => {
|
||||
expect(isTransparent("#ff00")).toEqual(true);
|
||||
|
||||
+64
-199
@@ -48,10 +48,23 @@ export const getDateTime = () => {
|
||||
export const capitalizeString = (str: string) =>
|
||||
str.charAt(0).toUpperCase() + str.slice(1);
|
||||
|
||||
const getTargetWindow = (
|
||||
target: Element | EventTarget | null,
|
||||
): (Window & typeof globalThis) | null =>
|
||||
(target as (EventTarget & { ownerDocument?: Document | null }) | null)
|
||||
?.ownerDocument?.defaultView ??
|
||||
(typeof window === "undefined" ? null : window);
|
||||
|
||||
export const isToolIcon = (
|
||||
target: Element | EventTarget | null,
|
||||
): target is HTMLElement =>
|
||||
target instanceof HTMLElement && target.className.includes("ToolIcon");
|
||||
): target is HTMLElement => {
|
||||
const targetWindow = getTargetWindow(target);
|
||||
return (
|
||||
!!targetWindow &&
|
||||
target instanceof targetWindow.HTMLElement &&
|
||||
target.className.includes("ToolIcon")
|
||||
);
|
||||
};
|
||||
|
||||
export const isInputLike = (
|
||||
target: Element | EventTarget | null,
|
||||
@@ -60,17 +73,26 @@ export const isInputLike = (
|
||||
| HTMLTextAreaElement
|
||||
| HTMLSelectElement
|
||||
| HTMLBRElement
|
||||
| HTMLDivElement =>
|
||||
(target instanceof HTMLElement && target.dataset.type === "wysiwyg") ||
|
||||
target instanceof HTMLBRElement || // newline in wysiwyg
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement;
|
||||
| HTMLDivElement => {
|
||||
const targetWindow = getTargetWindow(target);
|
||||
return (
|
||||
!!targetWindow &&
|
||||
((target instanceof targetWindow.HTMLElement &&
|
||||
target.dataset.type === "wysiwyg") ||
|
||||
target instanceof targetWindow.HTMLBRElement || // newline in wysiwyg
|
||||
target instanceof targetWindow.HTMLInputElement ||
|
||||
target instanceof targetWindow.HTMLTextAreaElement ||
|
||||
target instanceof targetWindow.HTMLSelectElement)
|
||||
);
|
||||
};
|
||||
|
||||
export const isInteractive = (target: Element | EventTarget | null) => {
|
||||
const targetWindow = getTargetWindow(target);
|
||||
return (
|
||||
isInputLike(target) ||
|
||||
(target instanceof Element && !!target.closest("label, button"))
|
||||
(!!targetWindow &&
|
||||
target instanceof targetWindow.Element &&
|
||||
!!target.closest("label, button"))
|
||||
);
|
||||
};
|
||||
|
||||
@@ -80,16 +102,23 @@ export const isWritableElement = (
|
||||
| HTMLInputElement
|
||||
| HTMLTextAreaElement
|
||||
| HTMLBRElement
|
||||
| HTMLDivElement =>
|
||||
(target instanceof HTMLElement && target.dataset.type === "wysiwyg") ||
|
||||
target instanceof HTMLBRElement || // newline in wysiwyg
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
(target instanceof HTMLInputElement &&
|
||||
(target.type === "text" ||
|
||||
target.type === "number" ||
|
||||
target.type === "password" ||
|
||||
target.type === "search")) ||
|
||||
(target instanceof HTMLElement && target.closest(".cm-editor") !== null);
|
||||
| HTMLDivElement => {
|
||||
const targetWindow = getTargetWindow(target);
|
||||
return (
|
||||
!!targetWindow &&
|
||||
((target instanceof targetWindow.HTMLElement &&
|
||||
target.dataset.type === "wysiwyg") ||
|
||||
target instanceof targetWindow.HTMLBRElement || // newline in wysiwyg
|
||||
target instanceof targetWindow.HTMLTextAreaElement ||
|
||||
(target instanceof targetWindow.HTMLInputElement &&
|
||||
(target.type === "text" ||
|
||||
target.type === "number" ||
|
||||
target.type === "password" ||
|
||||
target.type === "search")) ||
|
||||
(target instanceof targetWindow.HTMLElement &&
|
||||
target.closest(".cm-editor") !== null))
|
||||
);
|
||||
};
|
||||
|
||||
export const getFontFamilyString = ({
|
||||
fontFamily,
|
||||
@@ -204,135 +233,6 @@ export const easeOut = (k: number) => {
|
||||
return 1 - Math.pow(1 - k, 4);
|
||||
};
|
||||
|
||||
const easeOutInterpolate = (from: number, to: number, progress: number) => {
|
||||
return (to - from) * easeOut(progress) + from;
|
||||
};
|
||||
|
||||
/**
|
||||
* Animates values from `fromValues` to `toValues` using the requestAnimationFrame API.
|
||||
* Executes the `onStep` callback on each step with the interpolated values.
|
||||
* Returns a function that can be called to cancel the animation.
|
||||
*
|
||||
* @example
|
||||
* // Example usage:
|
||||
* const fromValues = { x: 0, y: 0 };
|
||||
* const toValues = { x: 100, y: 200 };
|
||||
* const onStep = ({x, y}) => {
|
||||
* setState(x, y)
|
||||
* };
|
||||
* const onCancel = () => {
|
||||
* console.log("Animation canceled");
|
||||
* };
|
||||
*
|
||||
* const cancelAnimation = easeToValuesRAF({
|
||||
* fromValues,
|
||||
* toValues,
|
||||
* onStep,
|
||||
* onCancel,
|
||||
* });
|
||||
*
|
||||
* // To cancel the animation:
|
||||
* cancelAnimation();
|
||||
*/
|
||||
export const easeToValuesRAF = <
|
||||
T extends Record<keyof T, number>,
|
||||
K extends keyof T,
|
||||
>({
|
||||
fromValues,
|
||||
toValues,
|
||||
onStep,
|
||||
duration = 250,
|
||||
interpolateValue,
|
||||
onStart,
|
||||
onEnd,
|
||||
onCancel,
|
||||
}: {
|
||||
fromValues: T;
|
||||
toValues: T;
|
||||
/**
|
||||
* Interpolate a single value.
|
||||
* Return undefined to be handled by the default interpolator.
|
||||
*/
|
||||
interpolateValue?: (
|
||||
fromValue: number,
|
||||
toValue: number,
|
||||
/** no easing applied */
|
||||
progress: number,
|
||||
key: K,
|
||||
) => number | undefined;
|
||||
onStep: (values: T) => void;
|
||||
duration?: number;
|
||||
onStart?: () => void;
|
||||
onEnd?: () => void;
|
||||
onCancel?: () => void;
|
||||
}) => {
|
||||
let canceled = false;
|
||||
let frameId = 0;
|
||||
let startTime: number;
|
||||
|
||||
function step(timestamp: number) {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
if (startTime === undefined) {
|
||||
startTime = timestamp;
|
||||
onStart?.();
|
||||
}
|
||||
|
||||
const elapsed = Math.min(timestamp - startTime, duration);
|
||||
const factor = easeOut(elapsed / duration);
|
||||
|
||||
const newValues = {} as T;
|
||||
|
||||
Object.keys(fromValues).forEach((key) => {
|
||||
const _key = key as keyof T;
|
||||
const result = ((toValues[_key] - fromValues[_key]) * factor +
|
||||
fromValues[_key]) as T[keyof T];
|
||||
newValues[_key] = result;
|
||||
});
|
||||
|
||||
onStep(newValues);
|
||||
|
||||
if (elapsed < duration) {
|
||||
const progress = elapsed / duration;
|
||||
|
||||
const newValues = {} as T;
|
||||
|
||||
Object.keys(fromValues).forEach((key) => {
|
||||
const _key = key as K;
|
||||
const startValue = fromValues[_key];
|
||||
const endValue = toValues[_key];
|
||||
|
||||
let result;
|
||||
|
||||
result = interpolateValue
|
||||
? interpolateValue(startValue, endValue, progress, _key)
|
||||
: easeOutInterpolate(startValue, endValue, progress);
|
||||
|
||||
if (result == null) {
|
||||
result = easeOutInterpolate(startValue, endValue, progress);
|
||||
}
|
||||
|
||||
newValues[_key] = result as T[K];
|
||||
});
|
||||
onStep(newValues);
|
||||
|
||||
frameId = window.requestAnimationFrame(step);
|
||||
} else {
|
||||
onStep(toValues);
|
||||
onEnd?.();
|
||||
}
|
||||
}
|
||||
|
||||
frameId = window.requestAnimationFrame(step);
|
||||
|
||||
return () => {
|
||||
onCancel?.();
|
||||
canceled = true;
|
||||
window.cancelAnimationFrame(frameId);
|
||||
};
|
||||
};
|
||||
|
||||
// https://github.com/lodash/lodash/blob/es/chunk.js
|
||||
export const chunk = <T extends any>(
|
||||
array: readonly T[],
|
||||
@@ -381,7 +281,7 @@ export const updateActiveTool = (
|
||||
}
|
||||
| { type: "custom"; customType: string }
|
||||
) & { locked?: boolean; fromSelection?: boolean }) & {
|
||||
lastActiveToolBeforeEraser?: ActiveTool | null;
|
||||
lastActiveTool?: ActiveTool | null;
|
||||
},
|
||||
): AppState["activeTool"] => {
|
||||
if (data.type === "custom") {
|
||||
@@ -396,9 +296,9 @@ export const updateActiveTool = (
|
||||
return {
|
||||
...appState.activeTool,
|
||||
lastActiveTool:
|
||||
data.lastActiveToolBeforeEraser === undefined
|
||||
data.lastActiveTool === undefined
|
||||
? appState.activeTool.lastActiveTool
|
||||
: data.lastActiveToolBeforeEraser,
|
||||
: data.lastActiveTool,
|
||||
type: data.type,
|
||||
customType: null,
|
||||
locked: data.locked ?? appState.activeTool.locked,
|
||||
@@ -473,6 +373,13 @@ const RE_RTL_CHECK = new RegExp(`^[^${RS_LTR_CHARS}]*[${RS_RTL_CHARS}]`);
|
||||
*/
|
||||
export const isRTL = (text: string) => RE_RTL_CHECK.test(text);
|
||||
|
||||
const RE_RTL_CHARS = new RegExp(`[${RS_RTL_CHARS}]`);
|
||||
/**
|
||||
* Checks whether the text has any RTL character, i.e. whether it may be laid
|
||||
* out bidirectionally.
|
||||
*/
|
||||
export const hasRTLChars = (text: string) => RE_RTL_CHARS.test(text);
|
||||
|
||||
export const tupleToCoors = (
|
||||
xyTuple: readonly [number, number],
|
||||
): { x: number; y: number } => {
|
||||
@@ -604,12 +511,14 @@ export const supportsEmoji = () => {
|
||||
export const getNearestScrollableContainer = (
|
||||
element: HTMLElement,
|
||||
): HTMLElement | Document => {
|
||||
const ownerDocument = element.ownerDocument;
|
||||
const ownerWindow = ownerDocument.defaultView ?? window;
|
||||
let parent = element.parentElement;
|
||||
while (parent) {
|
||||
if (parent === document.body) {
|
||||
return document;
|
||||
if (parent === ownerDocument.body) {
|
||||
return ownerDocument;
|
||||
}
|
||||
const { overflowY } = window.getComputedStyle(parent);
|
||||
const { overflowY } = ownerWindow.getComputedStyle(parent);
|
||||
const hasScrollableContent = parent.scrollHeight > parent.clientHeight;
|
||||
if (
|
||||
hasScrollableContent &&
|
||||
@@ -621,7 +530,7 @@ export const getNearestScrollableContainer = (
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return document;
|
||||
return ownerDocument;
|
||||
};
|
||||
|
||||
export const focusNearestParent = (element: HTMLInputElement) => {
|
||||
@@ -1279,50 +1188,6 @@ export const reduceToCommonValue = <T, R = T>(
|
||||
return commonValue;
|
||||
};
|
||||
|
||||
type FEATURE_FLAGS = {
|
||||
COMPLEX_BINDINGS: boolean;
|
||||
};
|
||||
|
||||
const FEATURE_FLAGS_STORAGE_KEY = "excalidraw-feature-flags";
|
||||
const DEFAULT_FEATURE_FLAGS: FEATURE_FLAGS = {
|
||||
COMPLEX_BINDINGS: false,
|
||||
};
|
||||
let featureFlags: FEATURE_FLAGS | null = null;
|
||||
|
||||
export const getFeatureFlag = <F extends keyof FEATURE_FLAGS>(
|
||||
flag: F,
|
||||
): FEATURE_FLAGS[F] => {
|
||||
if (!featureFlags) {
|
||||
try {
|
||||
const serializedFlags = localStorage.getItem(FEATURE_FLAGS_STORAGE_KEY);
|
||||
if (serializedFlags) {
|
||||
const flags = JSON.parse(serializedFlags);
|
||||
featureFlags = flags ?? DEFAULT_FEATURE_FLAGS;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return (featureFlags || DEFAULT_FEATURE_FLAGS)[flag];
|
||||
};
|
||||
|
||||
export const setFeatureFlag = <F extends keyof FEATURE_FLAGS>(
|
||||
flag: F,
|
||||
value: FEATURE_FLAGS[F],
|
||||
) => {
|
||||
try {
|
||||
featureFlags = {
|
||||
...(featureFlags || DEFAULT_FEATURE_FLAGS),
|
||||
[flag]: value,
|
||||
};
|
||||
localStorage.setItem(
|
||||
FEATURE_FLAGS_STORAGE_KEY,
|
||||
JSON.stringify(featureFlags),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("unable to set feature flag", e);
|
||||
}
|
||||
};
|
||||
|
||||
export const oneOf = <N extends string | number | symbol | null, H extends N>(
|
||||
needle: N,
|
||||
haystack: readonly H[],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ const getNonDeletedElements = <T extends ExcalidrawElement>(
|
||||
allElements: readonly T[],
|
||||
) => {
|
||||
const elementsMap = new Map() as NonDeletedSceneElementsMap;
|
||||
const elements: T[] = [];
|
||||
const elements: NonDeleted<T>[] = [];
|
||||
for (const element of allElements) {
|
||||
if (!element.isDeleted) {
|
||||
elements.push(element as NonDeleted<T>);
|
||||
@@ -187,7 +187,7 @@ export class Scene {
|
||||
// selection-related options
|
||||
includeBoundTextElement?: boolean;
|
||||
includeElementsInFrames?: boolean;
|
||||
}): NonDeleted<ExcalidrawElement>[] {
|
||||
}): NonDeletedExcalidrawElement[] {
|
||||
const hash = hashSelectionOpts(opts);
|
||||
|
||||
const elements = opts?.elements || this.nonDeletedElements;
|
||||
@@ -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) {
|
||||
|
||||
@@ -14,6 +14,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -51,6 +52,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -83,6 +85,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -144,6 +147,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -210,6 +214,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -242,6 +247,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id50",
|
||||
@@ -249,6 +255,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
},
|
||||
],
|
||||
"containerId": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -259,6 +266,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"id": Any<String>,
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -288,6 +296,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id50",
|
||||
@@ -295,6 +304,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
},
|
||||
],
|
||||
"containerId": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -305,6 +315,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"id": Any<String>,
|
||||
"index": "a1",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -339,6 +350,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -400,8 +412,10 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id50",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -412,6 +426,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"id": Any<String>,
|
||||
"index": "a3",
|
||||
"isDeleted": false,
|
||||
"labelPosition": 0.5,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -446,6 +461,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -507,8 +523,10 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id39",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -519,6 +537,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"id": Any<String>,
|
||||
"index": "a1",
|
||||
"isDeleted": false,
|
||||
"labelPosition": 0.5,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -553,6 +572,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -590,6 +610,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -627,6 +648,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -688,8 +710,10 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id43",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -700,6 +724,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"id": Any<String>,
|
||||
"index": "a1",
|
||||
"isDeleted": false,
|
||||
"labelPosition": 0.5,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -729,6 +754,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id43",
|
||||
@@ -736,6 +762,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
},
|
||||
],
|
||||
"containerId": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -746,6 +773,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"id": Any<String>,
|
||||
"index": "a2",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -775,6 +803,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id43",
|
||||
@@ -782,6 +811,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
},
|
||||
],
|
||||
"containerId": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -792,6 +822,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"id": Any<String>,
|
||||
"index": "a3",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -821,6 +852,7 @@ exports[`Test Transform > should not allow duplicate ids 1`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -853,6 +885,7 @@ exports[`Test Transform > should transform linear elements 1`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -900,6 +933,7 @@ exports[`Test Transform > should transform linear elements 2`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "triangle",
|
||||
@@ -947,6 +981,7 @@ exports[`Test Transform > should transform linear elements 3`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"endArrowhead": null,
|
||||
"endBinding": null,
|
||||
@@ -994,6 +1029,7 @@ exports[`Test Transform > should transform linear elements 4`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"endArrowhead": null,
|
||||
"endBinding": null,
|
||||
@@ -1041,6 +1077,7 @@ exports[`Test Transform > should transform regular shapes 1`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -1073,6 +1110,7 @@ exports[`Test Transform > should transform regular shapes 2`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -1105,6 +1143,7 @@ exports[`Test Transform > should transform regular shapes 3`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -1137,6 +1176,7 @@ exports[`Test Transform > should transform regular shapes 4`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "#c0eb75",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -1169,6 +1209,7 @@ exports[`Test Transform > should transform regular shapes 5`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "#ffc9c9",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -1201,6 +1242,7 @@ exports[`Test Transform > should transform regular shapes 6`] = `
|
||||
"angle": 0,
|
||||
"backgroundColor": "#a5d8ff",
|
||||
"boundElements": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "cross-hatch",
|
||||
"frameId": null,
|
||||
@@ -1233,8 +1275,10 @@ exports[`Test Transform > should transform text element 1`] = `
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -1245,6 +1289,7 @@ exports[`Test Transform > should transform text element 1`] = `
|
||||
"id": Any<String>,
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -1274,8 +1319,10 @@ exports[`Test Transform > should transform text element 2`] = `
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": null,
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -1286,6 +1333,7 @@ exports[`Test Transform > should transform text element 2`] = `
|
||||
"id": Any<String>,
|
||||
"index": "a1",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -1324,6 +1372,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -1363,6 +1412,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -1406,6 +1456,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -1453,6 +1504,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -1492,6 +1544,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -1560,6 +1613,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -1619,8 +1673,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "B",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -1633,6 +1689,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"id": Any<String>,
|
||||
"index": "a6",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -1662,8 +1719,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "A",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -1676,6 +1735,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"id": Any<String>,
|
||||
"index": "a7",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -1705,8 +1765,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "Alice",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -1719,6 +1781,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"id": Any<String>,
|
||||
"index": "a8",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -1748,8 +1811,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "Bob",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -1762,6 +1827,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"id": Any<String>,
|
||||
"index": "a9",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -1791,8 +1857,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "Bob_Alice",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -1803,6 +1871,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"id": Any<String>,
|
||||
"index": "aA",
|
||||
"isDeleted": false,
|
||||
"labelPosition": 0.5,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -1832,8 +1901,10 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "Bob_B",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -1844,6 +1915,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"id": Any<String>,
|
||||
"index": "aB",
|
||||
"isDeleted": false,
|
||||
"labelPosition": 0.5,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -1859,12 +1931,12 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"textAlign": "center",
|
||||
"type": "text",
|
||||
"updated": 1,
|
||||
"version": 3,
|
||||
"version": 2,
|
||||
"versionNonce": Any<Number>,
|
||||
"verticalAlign": "middle",
|
||||
"width": 100,
|
||||
"x": 27.016999999999996,
|
||||
"y": 66.5,
|
||||
"x": -50,
|
||||
"y": -12.5,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1878,6 +1950,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -1930,6 +2003,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -1982,6 +2056,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -2034,6 +2109,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
@@ -2081,8 +2157,10 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id25",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -2093,6 +2171,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"id": Any<String>,
|
||||
"index": "a4",
|
||||
"isDeleted": false,
|
||||
"labelPosition": 0.5,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -2122,8 +2201,10 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id26",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -2134,6 +2215,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"id": Any<String>,
|
||||
"index": "a5",
|
||||
"isDeleted": false,
|
||||
"labelPosition": 0.5,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -2163,8 +2245,10 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id27",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -2175,6 +2259,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"id": Any<String>,
|
||||
"index": "a6",
|
||||
"isDeleted": false,
|
||||
"labelPosition": 0.5,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -2205,8 +2290,10 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id28",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -2217,6 +2304,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"id": Any<String>,
|
||||
"index": "a7",
|
||||
"isDeleted": false,
|
||||
"labelPosition": 0.5,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -2252,6 +2340,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -2289,6 +2378,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -2326,6 +2416,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -2363,6 +2454,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -2400,6 +2492,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -2437,6 +2530,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -2469,8 +2563,10 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id13",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -2481,6 +2577,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"id": Any<String>,
|
||||
"index": "a6",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -2510,8 +2607,10 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id14",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -2522,6 +2621,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"id": Any<String>,
|
||||
"index": "a7",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -2552,8 +2652,10 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id15",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -2564,6 +2666,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"id": Any<String>,
|
||||
"index": "a8",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -2596,8 +2699,10 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id16",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -2608,6 +2713,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"id": Any<String>,
|
||||
"index": "a9",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -2638,8 +2744,10 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id17",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -2650,6 +2758,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"id": Any<String>,
|
||||
"index": "aA",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -2681,8 +2790,10 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"baseFontSize": null,
|
||||
"boundElements": null,
|
||||
"containerId": "id18",
|
||||
"created": 1,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -2693,6 +2804,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
||||
"id": Any<String>,
|
||||
"index": "aB",
|
||||
"isDeleted": false,
|
||||
"labelPosition": null,
|
||||
"lineHeight": 1.25,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ROUNDNESS } from "@excalidraw/common";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { Scene } from "../Scene";
|
||||
import { addNewNodes } from "../flowchart";
|
||||
import { newStickyNoteElement } from "../newElement";
|
||||
import { isFlowchartNodeElement, isStickyNoteElement } from "../typeChecks";
|
||||
|
||||
describe("flowchart", () => {
|
||||
it("creates connected sticky notes", () => {
|
||||
const sticky = newStickyNoteElement({
|
||||
type: "stickynote",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 240,
|
||||
height: 260,
|
||||
baseHeight: 220,
|
||||
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
|
||||
roughness: 2,
|
||||
backgroundColor: "#ffec99",
|
||||
strokeColor: "#1e1e1e",
|
||||
strokeWidth: 2,
|
||||
});
|
||||
const scene = new Scene([sticky], { skipValidation: true });
|
||||
const {
|
||||
nodes: [nextNode, bindingArrow],
|
||||
} = addNewNodes(
|
||||
sticky,
|
||||
{
|
||||
currentItemEndArrowhead: "arrow",
|
||||
} as AppState,
|
||||
"right",
|
||||
scene,
|
||||
1,
|
||||
);
|
||||
|
||||
expect(isFlowchartNodeElement(sticky)).toBe(true);
|
||||
expect(isFlowchartNodeElement(nextNode)).toBe(true);
|
||||
expect(isStickyNoteElement(nextNode)).toBe(true);
|
||||
expect(nextNode).toMatchObject({
|
||||
type: "stickynote",
|
||||
x: sticky.x + sticky.width + 100,
|
||||
y: sticky.y,
|
||||
width: sticky.width,
|
||||
height: sticky.height,
|
||||
baseHeight: sticky.baseHeight,
|
||||
roundness: sticky.roundness,
|
||||
roughness: sticky.roughness,
|
||||
backgroundColor: sticky.backgroundColor,
|
||||
strokeColor: sticky.strokeColor,
|
||||
strokeWidth: sticky.strokeWidth,
|
||||
});
|
||||
expect(bindingArrow).toMatchObject({
|
||||
type: "arrow",
|
||||
startBinding: { elementId: sticky.id },
|
||||
endBinding: { elementId: nextNode.id },
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,30 @@
|
||||
import {
|
||||
DEFAULT_STICKY_NOTE_BG,
|
||||
DEFAULT_STICKY_NOTE_SIZE,
|
||||
STICKY_NOTE_MIN_FONT_SIZE,
|
||||
STICKY_NOTE_MIN_SIZE,
|
||||
getUpdatedTimestamp,
|
||||
} from "@excalidraw/common";
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import {
|
||||
newElement,
|
||||
newEmbeddableElement,
|
||||
newFreeDrawElement,
|
||||
newIframeElement,
|
||||
} from "../newElement";
|
||||
|
||||
import {
|
||||
convertToExcalidrawElements,
|
||||
type ExcalidrawElementSkeleton,
|
||||
} from "../transform";
|
||||
|
||||
import type { ExcalidrawArrowElement } from "../types";
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawStickyNoteElement,
|
||||
ExcalidrawTextElement,
|
||||
} from "../types";
|
||||
|
||||
const opts = { regenerateIds: false };
|
||||
|
||||
@@ -965,4 +983,208 @@ describe("Test Transform", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("creation timestamps", () => {
|
||||
const createElements = () => [
|
||||
{
|
||||
...newElement({ type: "rectangle", x: 0, y: 0 }),
|
||||
type: "rectangle" as const,
|
||||
},
|
||||
newFreeDrawElement({
|
||||
type: "freedraw",
|
||||
x: 0,
|
||||
y: 0,
|
||||
simulatePressure: false,
|
||||
}),
|
||||
newIframeElement({ type: "iframe", x: 0, y: 0 }),
|
||||
newEmbeddableElement({ type: "embeddable", x: 0, y: 0 }),
|
||||
];
|
||||
|
||||
it("assigns fresh timestamps when regenerating IDs without mutating the input", () => {
|
||||
const elements = createElements().map((element) => ({
|
||||
...element,
|
||||
created: 123,
|
||||
}));
|
||||
const converted = convertToExcalidrawElements(elements);
|
||||
|
||||
expect(converted).toHaveLength(elements.length);
|
||||
converted.forEach((element, index) => {
|
||||
expect(element.id).not.toBe(elements[index].id);
|
||||
expect(element.created).toBe(getUpdatedTimestamp());
|
||||
expect(elements[index].created).toBe(123);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([123, 0, null])(
|
||||
"preserves an explicit timestamp of %s when retaining IDs",
|
||||
(created) => {
|
||||
const elements = createElements().map((element) => ({
|
||||
...element,
|
||||
created,
|
||||
}));
|
||||
const converted = convertToExcalidrawElements(elements, opts);
|
||||
|
||||
converted.forEach((element, index) => {
|
||||
expect(element.id).toBe(elements[index].id);
|
||||
expect(element.created).toBe(created);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("defaults missing timestamps when retaining IDs, as every type goes through a constructor", () => {
|
||||
// models skeletons persisted before `created` existed; the declared
|
||||
// skeleton type is a complete element
|
||||
const elements = createElements().map(
|
||||
({ created, ...element }) => element,
|
||||
) as unknown as ExcalidrawElementSkeleton[];
|
||||
const converted = convertToExcalidrawElements(elements, opts);
|
||||
|
||||
converted.forEach((element, index) => {
|
||||
expect(element.id).toBe(elements[index].id);
|
||||
expect(element.created).toBe(getUpdatedTimestamp());
|
||||
expect(elements[index]).not.toHaveProperty("created");
|
||||
});
|
||||
});
|
||||
|
||||
it("assigns fresh timestamps to generated labels and binding endpoints", () => {
|
||||
const elements: ExcalidrawElementSkeleton[] = [
|
||||
{
|
||||
type: "arrow",
|
||||
x: 100,
|
||||
y: 100,
|
||||
created: 123,
|
||||
// fragments cannot carry a creation time; they are always new
|
||||
label: { text: "label" },
|
||||
start: { type: "rectangle" },
|
||||
end: { type: "text", text: "end" },
|
||||
},
|
||||
];
|
||||
const converted = convertToExcalidrawElements(elements);
|
||||
|
||||
expect(converted).toHaveLength(4);
|
||||
expect(converted.map((element) => element.created)).toEqual(
|
||||
Array(4).fill(getUpdatedTimestamp()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sticky notes", () => {
|
||||
const find = <
|
||||
T extends ExcalidrawStickyNoteElement | ExcalidrawTextElement,
|
||||
>(
|
||||
elements: readonly { type: string }[],
|
||||
type: T["type"],
|
||||
) => elements.find((element) => element.type === type) as T;
|
||||
|
||||
it("creates a finalized note with the sticky defaults", () => {
|
||||
const elements = convertToExcalidrawElements(
|
||||
[{ type: "stickynote", x: 100, y: 100 }],
|
||||
opts,
|
||||
);
|
||||
const note = find<ExcalidrawStickyNoteElement>(elements, "stickynote");
|
||||
|
||||
expect(elements).toHaveLength(1);
|
||||
expect(note.width).toBe(DEFAULT_STICKY_NOTE_SIZE);
|
||||
expect(note.height).toBe(DEFAULT_STICKY_NOTE_SIZE);
|
||||
expect(note.baseHeight).toBe(DEFAULT_STICKY_NOTE_SIZE);
|
||||
expect(note.backgroundColor).toBe(DEFAULT_STICKY_NOTE_BG);
|
||||
expect(note.fillStyle).toBe("solid");
|
||||
});
|
||||
|
||||
it("enforces the note invariants on the given properties", () => {
|
||||
const note = find<ExcalidrawStickyNoteElement>(
|
||||
convertToExcalidrawElements(
|
||||
[
|
||||
{
|
||||
type: "stickynote",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 300,
|
||||
backgroundColor: "transparent",
|
||||
strokeColor: "transparent",
|
||||
},
|
||||
],
|
||||
opts,
|
||||
),
|
||||
"stickynote",
|
||||
);
|
||||
|
||||
expect(note.width).toBe(STICKY_NOTE_MIN_SIZE);
|
||||
expect(note.height).toBe(300);
|
||||
expect(note.baseHeight).toBe(300);
|
||||
expect(note.backgroundColor).toBe(DEFAULT_STICKY_NOTE_BG);
|
||||
expect(note.strokeColor).not.toBe("transparent");
|
||||
});
|
||||
|
||||
it("gives the note the color of a label that sets its own", () => {
|
||||
const elements = convertToExcalidrawElements(
|
||||
[
|
||||
{
|
||||
type: "stickynote",
|
||||
x: 0,
|
||||
y: 0,
|
||||
strokeColor: "#1971c2",
|
||||
label: { text: "hello", strokeColor: "#e03131" },
|
||||
},
|
||||
],
|
||||
opts,
|
||||
);
|
||||
const note = find<ExcalidrawStickyNoteElement>(elements, "stickynote");
|
||||
const label = find<ExcalidrawTextElement>(elements, "text");
|
||||
|
||||
expect(label.strokeColor).toBe("#e03131");
|
||||
expect(note.strokeColor).toBe("#e03131");
|
||||
});
|
||||
|
||||
it("binds a label whose font size becomes the note's ceiling", () => {
|
||||
const elements = convertToExcalidrawElements(
|
||||
[
|
||||
{
|
||||
type: "stickynote",
|
||||
x: 100,
|
||||
y: 100,
|
||||
strokeColor: "#1971c2",
|
||||
label: { text: "hello", fontSize: 28 },
|
||||
},
|
||||
],
|
||||
opts,
|
||||
);
|
||||
const note = find<ExcalidrawStickyNoteElement>(elements, "stickynote");
|
||||
const label = find<ExcalidrawTextElement>(elements, "text");
|
||||
|
||||
expect(elements).toHaveLength(2);
|
||||
expect(label.containerId).toBe(note.id);
|
||||
expect(note.boundElements).toEqual([{ type: "text", id: label.id }]);
|
||||
expect(label.baseFontSize).toBe(28);
|
||||
expect(label.fontSize).toBe(28);
|
||||
// the label is the note's visible text: it takes the note's stroke
|
||||
expect(label.strokeColor).toBe("#1971c2");
|
||||
expect(note.height).toBe(DEFAULT_STICKY_NOTE_SIZE);
|
||||
});
|
||||
|
||||
it("grows the note for a label that overflows at the minimum font size", () => {
|
||||
const elements = convertToExcalidrawElements(
|
||||
[
|
||||
{
|
||||
type: "stickynote",
|
||||
x: 0,
|
||||
y: 0,
|
||||
label: {
|
||||
text: Array(40).fill("abcdefghijklmnopqrstuvwx").join("\n"),
|
||||
fontSize: 28,
|
||||
},
|
||||
},
|
||||
],
|
||||
opts,
|
||||
);
|
||||
const note = find<ExcalidrawStickyNoteElement>(elements, "stickynote");
|
||||
const label = find<ExcalidrawTextElement>(elements, "text");
|
||||
|
||||
expect(label.fontSize).toBe(STICKY_NOTE_MIN_FONT_SIZE);
|
||||
expect(label.baseFontSize).toBe(28);
|
||||
expect(note.baseHeight).toBe(DEFAULT_STICKY_NOTE_SIZE);
|
||||
expect(note.height).toBeGreaterThan(DEFAULT_STICKY_NOTE_SIZE);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,10 +4,12 @@ import { updateBoundElements } from "./binding";
|
||||
import { getCommonBoundingBox } from "./bounds";
|
||||
import { getSelectedElementsByGroup } from "./groups";
|
||||
|
||||
import { getNonDeletedElements } from ".";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
import type { BoundingBox } from "./bounds";
|
||||
import type { ExcalidrawElement } from "./types";
|
||||
import type { ExcalidrawElement, NonDeletedExcalidrawElement } from "./types";
|
||||
|
||||
export interface Alignment {
|
||||
position: "start" | "center" | "end";
|
||||
@@ -15,16 +17,16 @@ export interface Alignment {
|
||||
}
|
||||
|
||||
export const alignElements = (
|
||||
selectedElements: ExcalidrawElement[],
|
||||
selectedElements: NonDeletedExcalidrawElement[],
|
||||
alignment: Alignment,
|
||||
scene: Scene,
|
||||
appState: Readonly<AppState>,
|
||||
): ExcalidrawElement[] => {
|
||||
const groups: ExcalidrawElement[][] = getSelectedElementsByGroup(
|
||||
): NonDeletedExcalidrawElement[] => {
|
||||
const groups = getSelectedElementsByGroup(
|
||||
selectedElements,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
appState,
|
||||
);
|
||||
).map(getNonDeletedElements); // Nothing to align on deleted elements
|
||||
const selectionBoundingBox = getCommonBoundingBox(selectedElements);
|
||||
|
||||
return groups.flatMap((group) => {
|
||||
@@ -50,7 +52,7 @@ export const alignElements = (
|
||||
};
|
||||
|
||||
const calculateTranslation = (
|
||||
group: ExcalidrawElement[],
|
||||
group: readonly ExcalidrawElement[],
|
||||
selectionBoundingBox: BoundingBox,
|
||||
{ axis, position }: Alignment,
|
||||
): { x: number; y: number } => {
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Text bound to an arrow endpoint — creating a text element that reads as a
|
||||
* label for the tip of an arrow, placed and anchored so the arrow itself never
|
||||
* moves.
|
||||
*
|
||||
* The App-side interaction (hover affordance, hit gating, creation/drag entry
|
||||
* points) lives in `App.arrowText.ts`; the generic binding bookkeeping these
|
||||
* helpers rely on stays in `binding.ts`.
|
||||
*/
|
||||
import { TEXT_ALIGN, VERTICAL_ALIGN } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
pointDistance,
|
||||
pointFromVector,
|
||||
pointsEqual,
|
||||
vector,
|
||||
vectorDot,
|
||||
vectorFromPoint,
|
||||
vectorNormalize,
|
||||
vectorScale,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { GlobalPoint } from "@excalidraw/math";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getBindingGap, normalizeFixedPoint } from "./binding";
|
||||
import {
|
||||
HEADING_DOWN,
|
||||
HEADING_LEFT,
|
||||
HEADING_RIGHT,
|
||||
HEADING_UP,
|
||||
compareHeading,
|
||||
} from "./heading";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { getTextAnchorRatios } from "./newElement";
|
||||
import { isArrowElement, isElbowArrow } from "./typeChecks";
|
||||
|
||||
import type { Heading } from "./heading";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawTextElement,
|
||||
FixedPoint,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
TextAlign,
|
||||
VerticalAlign,
|
||||
} from "./types";
|
||||
|
||||
export type ArrowEndpoint = {
|
||||
arrow: NonDeleted<ExcalidrawArrowElement>;
|
||||
startOrEnd: "start" | "end";
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds a free (unbound) arrow endpoint under the cursor, front-most first.
|
||||
* Used by the text tool to offer creating a text label anchored to the tip of
|
||||
* an arrow.
|
||||
*/
|
||||
export const getUnboundArrowEndpointAtPoint = (
|
||||
scenePointer: GlobalPoint,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
zoom: AppState["zoom"],
|
||||
): ArrowEndpoint | null => {
|
||||
// front to back, so the top-most arrow wins when endpoints overlap
|
||||
for (let index = elements.length - 1; index >= 0; index--) {
|
||||
const element = elements[index];
|
||||
|
||||
if (
|
||||
!isArrowElement(element) ||
|
||||
element.locked ||
|
||||
element.points.length < 2
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// prefer the end (arrowhead) over the start when both are within range
|
||||
for (const startOrEnd of ["end", "start"] as const) {
|
||||
if (element[startOrEnd === "start" ? "startBinding" : "endBinding"]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// a zero-length tail has no direction to place a text along, so
|
||||
// `getTextBindingForArrowEndpoint` would refuse the endpoint — don't
|
||||
// offer it at all rather than highlight something a click won't honor
|
||||
if (
|
||||
pointsEqual(
|
||||
element.points[
|
||||
startOrEnd === "start" ? 0 : element.points.length - 1
|
||||
],
|
||||
element.points[
|
||||
startOrEnd === "start" ? 1 : element.points.length - 2
|
||||
],
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const point = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
element,
|
||||
startOrEnd === "start" ? 0 : -1,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
// same reach as grabbing a point handle in the linear editor
|
||||
// (`LinearElementEditor.getPointIndexUnderCursor`), which can't be
|
||||
// reused here: it reports whichever point is nearest, with no way to
|
||||
// skip an endpoint that is already bound and fall through to the other
|
||||
if (
|
||||
pointDistance(scenePointer, point) * zoom.value <
|
||||
LinearElementEditor.POINT_HANDLE_SIZE + 1
|
||||
) {
|
||||
return { arrow: element, startOrEnd };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes how a text element should be created so that it reads as a label
|
||||
* for the given arrow endpoint.
|
||||
*
|
||||
* The arrow is treated as fixed: rather than routing the arrow to the text, we
|
||||
* pick the side of the text the arrow should attach to (the one it already
|
||||
* points at), and place the text so that side's midpoint lands on the existing
|
||||
* endpoint. The alignment returned pins that same midpoint while the text is
|
||||
* typed, so the arrow doesn't swing around during editing.
|
||||
*/
|
||||
export const getTextBindingForArrowEndpoint = (
|
||||
arrow: NonDeleted<ExcalidrawArrowElement>,
|
||||
startOrEnd: "start" | "end",
|
||||
elementsMap: ElementsMap,
|
||||
/**
|
||||
* stroke width the text will be created with — the binding gap is derived
|
||||
* from the *bind target*, so using the arrow's would offset the anchor by
|
||||
* half the difference between the two
|
||||
*/
|
||||
targetStrokeWidth: number,
|
||||
): {
|
||||
/** the text-local ratio the arrow binds to (a side midpoint) */
|
||||
fixedPoint: FixedPoint;
|
||||
textAlign: TextAlign;
|
||||
verticalAlign: VerticalAlign;
|
||||
/** scene position the text's bound side midpoint should sit at */
|
||||
anchor: GlobalPoint;
|
||||
} | null => {
|
||||
const endpoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
arrow,
|
||||
startOrEnd === "start" ? 0 : -1,
|
||||
elementsMap,
|
||||
);
|
||||
const neighbor = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
arrow,
|
||||
startOrEnd === "start" ? 1 : -2,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
if (pointsEqual(endpoint, neighbor)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The direction the arrow travels as it reaches this endpoint, snapped to
|
||||
// the dominant axis — i.e. the direction the text should extend away from
|
||||
// the tip.
|
||||
//
|
||||
// Deliberately not `vectorToHeading`: its tie-break resolves an exact 45°
|
||||
// vector to a direction the arrow isn't travelling in at all (a perfectly
|
||||
// down-right vector yields UP), which would put the text on the wrong side.
|
||||
// Here a tie picks the horizontal axis, always with the correct sign.
|
||||
const direction = vectorFromPoint(endpoint, neighbor);
|
||||
const heading: Heading =
|
||||
Math.abs(direction[0]) >= Math.abs(direction[1])
|
||||
? direction[0] >= 0
|
||||
? HEADING_RIGHT
|
||||
: HEADING_LEFT
|
||||
: direction[1] >= 0
|
||||
? HEADING_DOWN
|
||||
: HEADING_UP;
|
||||
|
||||
// Keep the tip off the text's bounding box by the usual binding gap so the
|
||||
// arrowhead doesn't collide with the glyphs. Must match the gap
|
||||
// `updateBoundPoint` will resolve the binding with, or the first text update
|
||||
// shifts the arrow. Elbow arrows terminate on the fixed point itself rather
|
||||
// than gap-outside the outline, so offsetting there would just shift them.
|
||||
const gap = isElbowArrow(arrow)
|
||||
? 0
|
||||
: getBindingGap({ strokeWidth: targetStrokeWidth });
|
||||
|
||||
// How far back along the arrow the bound side has to sit for the tip to
|
||||
// stay exactly where it is.
|
||||
//
|
||||
// `updateBoundPoint` resolves an orbit binding by intersecting the arrow's
|
||||
// own line with the target's outline grown by `gap`; it does not step along
|
||||
// the bound side's normal. Offsetting the anchor perpendicular to the side
|
||||
// would therefore leave the tip `gap * tan(angle)` off to one side on a
|
||||
// diagonal arrow. Walking back along the arrow instead keeps the anchor both
|
||||
// `gap` clear of the tip and collinear with the arrow, so the intersection
|
||||
// lands back on the tip itself.
|
||||
//
|
||||
// `heading` is the dominant axis of the arrow direction, so it is never more
|
||||
// than 45° away from it and the divisor stays >= cos(45°).
|
||||
const awayFromTip = vectorNormalize(vectorFromPoint(neighbor, endpoint));
|
||||
const alongHeading = Math.abs(
|
||||
vectorDot(awayFromTip, vector(heading[0], heading[1])),
|
||||
);
|
||||
const anchor = pointFromVector(
|
||||
vectorScale(awayFromTip, -gap / alongHeading),
|
||||
endpoint,
|
||||
);
|
||||
|
||||
if (compareHeading(heading, HEADING_RIGHT)) {
|
||||
// text sits to the right of the tip -> bind its left side
|
||||
return {
|
||||
fixedPoint: normalizeFixedPoint([0, 0.5]),
|
||||
anchor,
|
||||
textAlign: TEXT_ALIGN.LEFT as TextAlign,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE as VerticalAlign,
|
||||
};
|
||||
}
|
||||
|
||||
if (compareHeading(heading, HEADING_LEFT)) {
|
||||
// text sits to the left of the tip -> bind its right side
|
||||
return {
|
||||
fixedPoint: normalizeFixedPoint([1, 0.5]),
|
||||
anchor,
|
||||
textAlign: TEXT_ALIGN.RIGHT as TextAlign,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE as VerticalAlign,
|
||||
};
|
||||
}
|
||||
|
||||
if (compareHeading(heading, HEADING_DOWN)) {
|
||||
// text sits below the tip -> bind its top side
|
||||
return {
|
||||
fixedPoint: normalizeFixedPoint([0.5, 0]),
|
||||
anchor,
|
||||
textAlign: TEXT_ALIGN.CENTER as TextAlign,
|
||||
verticalAlign: VERTICAL_ALIGN.TOP as VerticalAlign,
|
||||
};
|
||||
}
|
||||
|
||||
// text sits above the tip -> bind its bottom side
|
||||
return {
|
||||
fixedPoint: normalizeFixedPoint([0.5, 1]),
|
||||
anchor,
|
||||
textAlign: TEXT_ALIGN.CENTER as TextAlign,
|
||||
verticalAlign: VERTICAL_ALIGN.BOTTOM as VerticalAlign,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether an arrow endpoint is bound to this text — i.e. the text serves as an
|
||||
* arrow-endpoint label. Checked on the arrows' own bindings: the text's
|
||||
* `boundElements` only says an arrow relates to it, while the binding on the
|
||||
* arrow is the authoritative side of the relationship.
|
||||
*/
|
||||
export const isEndpointBoundText = (
|
||||
text: ExcalidrawTextElement,
|
||||
elementsMap: ElementsMap,
|
||||
): boolean =>
|
||||
!!text.boundElements?.some(({ id, type }) => {
|
||||
if (type !== "arrow") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const arrow = elementsMap.get(id);
|
||||
|
||||
return (
|
||||
isArrowElement(arrow) &&
|
||||
(arrow.startBinding?.elementId === text.id ||
|
||||
arrow.endBinding?.elementId === text.id)
|
||||
);
|
||||
});
|
||||
|
||||
/** the anchor a text bound to an arrow endpoint grows away from */
|
||||
export const getEndpointBoundTextDragAnchor = (
|
||||
newElement: ExcalidrawTextElement,
|
||||
) => {
|
||||
const anchorRatio = getTextAnchorRatios(newElement).x;
|
||||
|
||||
return {
|
||||
anchorRatio,
|
||||
anchorX: newElement.x + newElement.width * anchorRatio,
|
||||
};
|
||||
};
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
getBindingGap,
|
||||
getGlobalFixedPointForBindableElement,
|
||||
isBindingEnabled,
|
||||
maxBindingDistance_simple,
|
||||
unbindBindingElement,
|
||||
updateBoundPoint,
|
||||
} from "../binding";
|
||||
@@ -20,13 +19,15 @@ import {
|
||||
isElbowArrow,
|
||||
} from "../typeChecks";
|
||||
import { LinearElementEditor } from "../linearElementEditor";
|
||||
import { getHoveredElementForFocusPoint, hitElementItself } from "../collision";
|
||||
import { getHoveredElementForBinding, hitElementItself } from "../collision";
|
||||
import { moveArrowAboveBindable } from "../zindex";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawBindableElement,
|
||||
NonDeleted,
|
||||
FixedPointBinding,
|
||||
NonDeletedSceneElementsMap,
|
||||
PointsPositionUpdates,
|
||||
} from "../types";
|
||||
@@ -92,7 +93,7 @@ export const isFocusPointVisible = (
|
||||
element: bindableElement,
|
||||
elementsMap,
|
||||
point: focusPoint,
|
||||
threshold: getBindingGap(bindableElement, arrow),
|
||||
threshold: getBindingGap(bindableElement),
|
||||
overrideShouldTestInside: true,
|
||||
})
|
||||
);
|
||||
@@ -100,7 +101,7 @@ export const isFocusPointVisible = (
|
||||
|
||||
// Updates the arrow endpoints in "orbit" configuration
|
||||
const focusPointUpdate = (
|
||||
arrow: ExcalidrawArrowElement,
|
||||
arrow: NonDeleted<ExcalidrawArrowElement>,
|
||||
bindableElement: ExcalidrawBindableElement | null,
|
||||
isStartBinding: boolean,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
@@ -110,10 +111,17 @@ const focusPointUpdate = (
|
||||
) => {
|
||||
const pointUpdates = new Map();
|
||||
|
||||
const originalAdjacentBinding =
|
||||
appState.selectedLinearElement?.initialState
|
||||
.arrowOtherEndpointInitialBinding;
|
||||
const bindingField = isStartBinding ? "startBinding" : "endBinding";
|
||||
const adjacentBindingField = isStartBinding ? "endBinding" : "startBinding";
|
||||
let currentBinding = arrow[bindingField];
|
||||
let adjacentBinding = arrow[adjacentBindingField];
|
||||
let adjacentBinding =
|
||||
originalAdjacentBinding?.mode === "orbit" &&
|
||||
arrow[adjacentBindingField]?.mode === "inside"
|
||||
? originalAdjacentBinding
|
||||
: arrow[adjacentBindingField];
|
||||
|
||||
// Update the dragged focus point related end
|
||||
if (currentBinding && bindableElement) {
|
||||
@@ -163,7 +171,7 @@ const focusPointUpdate = (
|
||||
// Same shape bound on both ends
|
||||
const boundToSameElementAfterUpdate =
|
||||
bindableElement && adjacentBinding.elementId === bindableElement.id;
|
||||
if (switchToInsideBinding || boundToSameElementAfterUpdate) {
|
||||
if (boundToSameElementAfterUpdate) {
|
||||
adjacentBinding = {
|
||||
...adjacentBinding,
|
||||
mode: "inside",
|
||||
@@ -212,7 +220,7 @@ export const handleFocusPointDrag = (
|
||||
const arrow = LinearElementEditor.getElement(
|
||||
linearElementEditor.elementId,
|
||||
elementsMap,
|
||||
) as any;
|
||||
);
|
||||
|
||||
// Sanity checks
|
||||
if (
|
||||
@@ -234,12 +242,11 @@ export const handleFocusPointDrag = (
|
||||
pointerCoords.y - offsetY,
|
||||
);
|
||||
const bindingField = isStartBinding ? "startBinding" : "endBinding";
|
||||
const hit = getHoveredElementForFocusPoint(
|
||||
const hit = getHoveredElementForBinding(
|
||||
point,
|
||||
arrow,
|
||||
scene.getNonDeletedElements(),
|
||||
elementsMap,
|
||||
maxBindingDistance_simple(appState.zoom),
|
||||
appState.zoom,
|
||||
);
|
||||
|
||||
// Hovering a bindable element
|
||||
@@ -270,6 +277,7 @@ export const handleFocusPointDrag = (
|
||||
newMode || "orbit",
|
||||
linearElementEditor.draggedFocusPointBinding,
|
||||
scene,
|
||||
appState.zoom,
|
||||
point,
|
||||
);
|
||||
}
|
||||
@@ -339,6 +347,7 @@ export const handleFocusPointPointerDown = (
|
||||
): {
|
||||
hitFocusPoint: "start" | "end" | null;
|
||||
pointerOffset: { x: number; y: number };
|
||||
arrowOtherEndpointInitialBinding: FixedPointBinding | null;
|
||||
} => {
|
||||
const pointerPos = pointFrom(
|
||||
pointerDownState.origin.x,
|
||||
@@ -376,6 +385,7 @@ export const handleFocusPointPointerDown = (
|
||||
x: pointerPos[0] - focusPoint[0],
|
||||
y: pointerPos[1] - focusPoint[1],
|
||||
},
|
||||
arrowOtherEndpointInitialBinding: arrow.endBinding,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -411,6 +421,7 @@ export const handleFocusPointPointerDown = (
|
||||
x: pointerPos[0] - focusPoint[0],
|
||||
y: pointerPos[1] - focusPoint[1],
|
||||
},
|
||||
arrowOtherEndpointInitialBinding: arrow.startBinding,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -419,6 +430,7 @@ export const handleFocusPointPointerDown = (
|
||||
return {
|
||||
hitFocusPoint: null,
|
||||
pointerOffset: { x: 0, y: 0 },
|
||||
arrowOtherEndpointInitialBinding: null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -483,6 +495,10 @@ export const handleFocusPointPointerUp = (
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
arrowOtherEndpointInitialBinding: null,
|
||||
};
|
||||
};
|
||||
|
||||
export const handleFocusPointHover = (
|
||||
|
||||
+504
-688
File diff suppressed because it is too large
Load Diff
+294
-37
@@ -1,5 +1,4 @@
|
||||
import rough from "roughjs/bin/rough";
|
||||
|
||||
import {
|
||||
arrayToMap,
|
||||
type Bounds,
|
||||
@@ -7,18 +6,14 @@ import {
|
||||
rescalePoints,
|
||||
sizeOf,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
degreesToRadians,
|
||||
lineSegment,
|
||||
pointDistance,
|
||||
pointFrom,
|
||||
pointFromArray,
|
||||
pointRotateRads,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { getCurvePathOps } from "@excalidraw/utils/shape";
|
||||
|
||||
import { pointsOnBezierCurves } from "points-on-curve";
|
||||
|
||||
import type {
|
||||
@@ -29,9 +24,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 +34,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 +62,7 @@ import type {
|
||||
ExcalidrawRectanguloidElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
|
||||
export type RectangleBox = {
|
||||
@@ -424,6 +420,7 @@ const _isRectanguloidElement = (
|
||||
return (
|
||||
element != null &&
|
||||
(element.type === "rectangle" ||
|
||||
element.type === "stickynote" ||
|
||||
element.type === "image" ||
|
||||
element.type === "iframe" ||
|
||||
element.type === "embeddable" ||
|
||||
@@ -1032,7 +1029,7 @@ export const getCommonBounds = (
|
||||
};
|
||||
|
||||
export const getDraggedElementsBounds = (
|
||||
elements: ExcalidrawElement[],
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
dragOffset: { x: number; y: number },
|
||||
) => {
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||
@@ -1117,33 +1114,6 @@ export const getElementPointsCoords = (
|
||||
];
|
||||
};
|
||||
|
||||
export const getClosestElementBounds = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
from: { x: number; y: number },
|
||||
): Bounds => {
|
||||
if (!elements.length) {
|
||||
return [0, 0, 0, 0];
|
||||
}
|
||||
|
||||
let minDistance = Infinity;
|
||||
let closestElement = elements[0];
|
||||
const elementsMap = arrayToMap(elements);
|
||||
elements.forEach((element) => {
|
||||
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
|
||||
const distance = pointDistance(
|
||||
pointFrom((x1 + x2) / 2, (y1 + y2) / 2),
|
||||
pointFrom(from.x, from.y),
|
||||
);
|
||||
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
closestElement = element;
|
||||
}
|
||||
});
|
||||
|
||||
return getElementBounds(closestElement, elementsMap);
|
||||
};
|
||||
|
||||
export interface BoundingBox {
|
||||
minX: number;
|
||||
minY: number;
|
||||
@@ -1295,6 +1265,293 @@ export const boundsContainBounds = (outerBounds: Bounds, innerBounds: Bounds) =>
|
||||
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 = <T extends ExcalidrawElement>({
|
||||
elements,
|
||||
elementsMap,
|
||||
bounds,
|
||||
type,
|
||||
excludeElementsInFrames,
|
||||
shouldIgnoreElementFromSelection,
|
||||
}: {
|
||||
elements: readonly T[];
|
||||
elementsMap?: ElementsMap;
|
||||
bounds: Bounds | ExcalidrawElement;
|
||||
/**
|
||||
* - overlap: elements overlapping or inside bounds
|
||||
* - contain: elements inside bounds
|
||||
**/
|
||||
type: "contain" | "overlap";
|
||||
excludeElementsInFrames?: boolean;
|
||||
shouldIgnoreElementFromSelection?: (element: T) => 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<ExcalidrawElement["id"]>()
|
||||
: null;
|
||||
const groups: Record<string, T[]> = {};
|
||||
const elementsInSelection: Set<T> = 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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+229
-203
@@ -8,6 +8,7 @@ import {
|
||||
pointFromVector,
|
||||
pointRotateRads,
|
||||
pointsEqual,
|
||||
polygonIncludesPoint,
|
||||
vectorFromPoint,
|
||||
vectorNormalize,
|
||||
vectorScale,
|
||||
@@ -22,10 +23,11 @@ import type {
|
||||
Curve,
|
||||
GlobalPoint,
|
||||
LineSegment,
|
||||
LocalPoint,
|
||||
Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
|
||||
import type { AppState, FrameNameBounds } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { isPathALoop } from "./utils";
|
||||
import {
|
||||
@@ -33,13 +35,13 @@ import {
|
||||
elementCenterPoint,
|
||||
getCenterForBounds,
|
||||
getCubicBezierCurveBound,
|
||||
getDiamondPoints,
|
||||
getElementBounds,
|
||||
pointInsideBounds,
|
||||
} from "./bounds";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isBindableElement,
|
||||
isBoundToContainer,
|
||||
isFrameLikeElement,
|
||||
isFreeDrawElement,
|
||||
isIframeLikeElement,
|
||||
@@ -53,17 +55,21 @@ import {
|
||||
deconstructRectanguloidElement,
|
||||
} from "./utils";
|
||||
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getTextElementWithAccuratePosition,
|
||||
} from "./textElement";
|
||||
|
||||
import { distanceToElement } from "./distance";
|
||||
|
||||
import { getBindingGap } from "./binding";
|
||||
import { maxBindingDistance } from "./binding";
|
||||
|
||||
import { hasBackground } from "./comparisons";
|
||||
|
||||
import { getFreedrawFillPolygon, getFreedrawMaxStrokeRadius } from "./shape";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
@@ -83,7 +89,7 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
|
||||
}
|
||||
|
||||
const isDraggableFromInside =
|
||||
!isTransparent(element.backgroundColor) ||
|
||||
(hasBackground(element.type) && !isTransparent(element.backgroundColor)) ||
|
||||
hasBoundTextElement(element) ||
|
||||
isIframeLikeElement(element) ||
|
||||
isTextElement(element);
|
||||
@@ -113,6 +119,19 @@ let cachedElement: WeakRef<ExcalidrawElement> | null = null;
|
||||
let cachedThreshold: number = Infinity;
|
||||
let cachedHit: boolean = false;
|
||||
let cachedOverrideShouldTestInside = false;
|
||||
let cachedFrameNameBound: FrameNameBounds | null = null;
|
||||
|
||||
const frameNameBoundsEqual = (
|
||||
a: FrameNameBounds | null,
|
||||
b: FrameNameBounds | null,
|
||||
) =>
|
||||
a === b ||
|
||||
(!!a &&
|
||||
!!b &&
|
||||
a.x === b.x &&
|
||||
a.y === b.y &&
|
||||
a.width === b.width &&
|
||||
a.height === b.height);
|
||||
|
||||
export const hitElementItself = ({
|
||||
point,
|
||||
@@ -122,12 +141,19 @@ export const hitElementItself = ({
|
||||
frameNameBound = null,
|
||||
overrideShouldTestInside = false,
|
||||
}: HitTestArgs) => {
|
||||
// Return cached result if the same point and element version is tested again
|
||||
// Return cached result if the same point and element version is tested again.
|
||||
// A cached hit stays valid for any larger threshold, while a cached miss
|
||||
// stays valid only for a threshold no larger than the cached one (a larger
|
||||
// threshold could turn a miss into a hit).
|
||||
// Skipped for container-bound labels — their position can change without
|
||||
// their version being bumped (see ElementBounds.getBounds).
|
||||
if (
|
||||
!isBoundToContainer(element) &&
|
||||
cachedPoint &&
|
||||
pointsEqual(point, cachedPoint) &&
|
||||
cachedThreshold <= threshold &&
|
||||
overrideShouldTestInside === cachedOverrideShouldTestInside
|
||||
(cachedHit ? cachedThreshold <= threshold : cachedThreshold >= threshold) &&
|
||||
overrideShouldTestInside === cachedOverrideShouldTestInside &&
|
||||
frameNameBoundsEqual(frameNameBound, cachedFrameNameBound)
|
||||
) {
|
||||
const derefElement = cachedElement?.deref();
|
||||
if (
|
||||
@@ -158,7 +184,10 @@ export const hitElementItself = ({
|
||||
point,
|
||||
bounds,
|
||||
element.angle,
|
||||
threshold,
|
||||
// Freedraw bounds follow the centerline points, but the ink extends past them
|
||||
isFreeDrawElement(element)
|
||||
? threshold + getFreedrawMaxStrokeRadius(element)
|
||||
: threshold,
|
||||
);
|
||||
|
||||
// PERF: Bail out early if the point is not even in the
|
||||
@@ -167,6 +196,15 @@ export const hitElementItself = ({
|
||||
return false;
|
||||
}
|
||||
|
||||
// an arrow label's stored coords can be stale. The bounds test above
|
||||
// already accounts for it (getElementBounds derives the position
|
||||
// internally), but the precise test below reads the element coords
|
||||
// directly, so substitute them. Done after the early bail so the common
|
||||
// miss path stays allocation-free.
|
||||
if (isTextElement(element)) {
|
||||
element = getTextElementWithAccuratePosition(element, elementsMap);
|
||||
}
|
||||
|
||||
// Do the precise (and relatively costly) hit test
|
||||
const hitElement = (
|
||||
overrideShouldTestInside ? true : shouldTestInside(element)
|
||||
@@ -184,6 +222,7 @@ export const hitElementItself = ({
|
||||
cachedElement = new WeakRef(element);
|
||||
cachedThreshold = threshold;
|
||||
cachedOverrideShouldTestInside = overrideShouldTestInside;
|
||||
cachedFrameNameBound = frameNameBound;
|
||||
cachedHit = result;
|
||||
|
||||
return result;
|
||||
@@ -236,78 +275,102 @@ export const hitElementBoundText = (
|
||||
if (!boundTextElementCandidate) {
|
||||
return false;
|
||||
}
|
||||
const boundTextElement = isLinearElement(element)
|
||||
? {
|
||||
...boundTextElementCandidate,
|
||||
// arrow's bound text accurate position is not stored in the element's property
|
||||
// but rather calculated and returned from the following static method
|
||||
...LinearElementEditor.getBoundTextElementPosition(
|
||||
element,
|
||||
boundTextElementCandidate,
|
||||
elementsMap,
|
||||
),
|
||||
}
|
||||
: boundTextElementCandidate;
|
||||
const boundTextElement = getTextElementWithAccuratePosition(
|
||||
boundTextElementCandidate,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
return isPointInElement(point, boundTextElement, elementsMap);
|
||||
};
|
||||
|
||||
const bindingBorderTest = (
|
||||
element: NonDeleted<ExcalidrawBindableElement>,
|
||||
[x, y]: Readonly<GlobalPoint>,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
tolerance: number = 0,
|
||||
): boolean => {
|
||||
const p = pointFrom<GlobalPoint>(x, y);
|
||||
const shouldTestInside =
|
||||
// disable fullshape snapping for frame elements so we
|
||||
// can bind to frame children
|
||||
!isFrameLikeElement(element);
|
||||
// Frame children are clipped to their enclosing frame, so a point outside
|
||||
// the frame cannot hit them
|
||||
const isPointClippedByEnclosingFrame = (
|
||||
element: ExcalidrawElement,
|
||||
point: Readonly<GlobalPoint>,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
if (!element.frameId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const enclosingFrame = elementsMap.get(element.frameId);
|
||||
|
||||
return (
|
||||
!!enclosingFrame &&
|
||||
isFrameLikeElement(enclosingFrame) &&
|
||||
!pointInsideBounds(point, getElementBounds(enclosingFrame, elementsMap))
|
||||
);
|
||||
};
|
||||
|
||||
const bindableElementBorderDistanceIfClose = (
|
||||
element: NonDeleted<ExcalidrawBindableElement>,
|
||||
point: GlobalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
tolerance: number = 0,
|
||||
) => {
|
||||
// PERF: Run a cheap test to see if the binding element
|
||||
// is even close to the element
|
||||
const [x, y] = point;
|
||||
const t = Math.max(1, tolerance);
|
||||
const bounds = [x - t, y - t, x + t, y + t] as Bounds;
|
||||
const elementBounds = getElementBounds(element, elementsMap);
|
||||
if (!doBoundsIntersect(bounds, elementBounds)) {
|
||||
return false;
|
||||
return -Infinity;
|
||||
}
|
||||
|
||||
// If the element is inside a frame, we should clip the element
|
||||
if (element.frameId) {
|
||||
const enclosingFrame = elementsMap.get(element.frameId);
|
||||
if (enclosingFrame && isFrameLikeElement(enclosingFrame)) {
|
||||
const enclosingFrameBounds = getElementBounds(
|
||||
enclosingFrame,
|
||||
elementsMap,
|
||||
);
|
||||
if (!pointInsideBounds(p, enclosingFrameBounds)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (isPointClippedByEnclosingFrame(element, point, elementsMap)) {
|
||||
return -Infinity;
|
||||
}
|
||||
|
||||
// Do the intersection test against the element since it's close enough
|
||||
const intersections = intersectElementWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
lineSegment(elementCenterPoint(element, elementsMap), p),
|
||||
);
|
||||
const distance = distanceToElement(element, elementsMap, p);
|
||||
const isInside = isPointInElement(point, element, elementsMap);
|
||||
// frames are only bindable from the outside, so arrows ending inside
|
||||
// a frame can bind to its children (or stay unbound)
|
||||
if (isInside && isFrameLikeElement(element)) {
|
||||
return -Infinity;
|
||||
}
|
||||
|
||||
return shouldTestInside
|
||||
? intersections.length === 0 || distance <= tolerance
|
||||
: intersections.length > 0 && distance <= t;
|
||||
const distance = distanceToElement(element, elementsMap, point);
|
||||
if (isInside) {
|
||||
return distance;
|
||||
}
|
||||
|
||||
return distance > tolerance ? -Infinity : -distance;
|
||||
};
|
||||
|
||||
export const getAllHoveredElementAtPoint = (
|
||||
type BindingCandidate = {
|
||||
element: NonDeleted<ExcalidrawBindableElement>;
|
||||
/** distance to the outline: positive inside, negative outside */
|
||||
distance: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the element hides what is behind it from binding — an arrow's or a
|
||||
* text label's. Images count as opaque, and frames once they support a
|
||||
* background.
|
||||
*/
|
||||
export const isOpaqueForBinding = (element: ExcalidrawElement) =>
|
||||
isImageElement(element) ||
|
||||
(hasBackground(element.type) && !isTransparent(element.backgroundColor));
|
||||
|
||||
/**
|
||||
* Bindable elements within binding distance of the point, front to back.
|
||||
* Stops at the first opaque element containing the point, since it hides
|
||||
* everything behind it. Locked elements can't be bound to, but still hide
|
||||
* what is behind them.
|
||||
*/
|
||||
const getBindingCandidates = (
|
||||
point: Readonly<GlobalPoint>,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
tolerance?: number,
|
||||
): NonDeleted<ExcalidrawBindableElement>[] => {
|
||||
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
|
||||
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
||||
zoom: AppState["zoom"],
|
||||
): BindingCandidate[] => {
|
||||
const maxDistance = maxBindingDistance(zoom);
|
||||
const candidates: BindingCandidate[] = [];
|
||||
// A frame's children sit just below it in z-order, so a frame's background
|
||||
// can't end the search: it only hides the non-children behind it
|
||||
let occludingFrameId: ExcalidrawElement["id"] | null = null;
|
||||
// We need to do hit testing from front (end of the array) to back (beginning of the array)
|
||||
// because array is ordered from lower z-index to highest and we want element z-index
|
||||
// with higher z-index
|
||||
for (let index = elements.length - 1; index >= 0; --index) {
|
||||
@@ -315,101 +378,95 @@ export const getAllHoveredElementAtPoint = (
|
||||
|
||||
invariant(
|
||||
!element.isDeleted,
|
||||
"Elements in the function parameter for getAllElementsAtPositionForBinding() should not contain deleted elements",
|
||||
"Elements passed to binding hit tests should not contain deleted elements",
|
||||
);
|
||||
|
||||
if (
|
||||
isBindableElement(element, false) &&
|
||||
bindingBorderTest(element, point, elementsMap, tolerance)
|
||||
) {
|
||||
candidateElements.push(element);
|
||||
if (occludingFrameId && element.frameId !== occludingFrameId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isTransparent(element.backgroundColor)) {
|
||||
if (!isBindableElement(element)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isFrameLikeElement(element)) {
|
||||
if (
|
||||
isOpaqueForBinding(element) &&
|
||||
isPointInElement(point, element, elementsMap)
|
||||
) {
|
||||
occludingFrameId = element.id;
|
||||
}
|
||||
}
|
||||
|
||||
const distance = bindableElementBorderDistanceIfClose(
|
||||
element,
|
||||
point,
|
||||
elementsMap,
|
||||
maxDistance,
|
||||
);
|
||||
|
||||
if (distance > -maxDistance) {
|
||||
if (!element.locked) {
|
||||
candidates.push({ element, distance });
|
||||
}
|
||||
|
||||
if (distance >= 0 && isOpaqueForBinding(element)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidateElements;
|
||||
return candidates;
|
||||
};
|
||||
|
||||
export const getHoveredElementForBinding = (
|
||||
point: Readonly<GlobalPoint>,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
tolerance?: number,
|
||||
zoom: AppState["zoom"],
|
||||
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||
const candidateElements = getAllHoveredElementAtPoint(
|
||||
point,
|
||||
elements,
|
||||
elementsMap,
|
||||
tolerance,
|
||||
const candidates = getBindingCandidates(point, elements, elementsMap, zoom);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (candidates.length === 1) {
|
||||
return candidates[0].element;
|
||||
}
|
||||
|
||||
const closestElements = candidates.sort(
|
||||
(a, b) => Math.abs(a.distance) - Math.abs(b.distance),
|
||||
);
|
||||
|
||||
if (!candidateElements || candidateElements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const candidate = closestElements[0];
|
||||
const [cx1, cy1, cx2, cy2] = getElementBounds(candidate.element, elementsMap);
|
||||
const candidateArea = Math.max(
|
||||
0.00001,
|
||||
Math.abs(cx2 - cx1) * Math.abs(cy2 - cy1),
|
||||
);
|
||||
// A smaller element overlapping the closest one takes precedence, but only
|
||||
// when the point is inside it: otherwise the closest outline wins, e.g. an
|
||||
// arrow ending just inside a container's edge next to a nested element
|
||||
const overlaps = closestElements
|
||||
.filter((c) => c.element !== candidate.element && c.distance >= 0)
|
||||
.map((c) => {
|
||||
const [x1, y1, x2, y2] = getElementBounds(c.element, elementsMap);
|
||||
const overlapWidth = Math.max(0, Math.min(x2, cx2) - Math.max(x1, cx1));
|
||||
const overlapHeight = Math.max(0, Math.min(y2, cy2) - Math.max(y1, cy1));
|
||||
const area = Math.max(0.00001, Math.abs(x2 - x1) * Math.abs(y2 - y1));
|
||||
|
||||
if (candidateElements.length === 1) {
|
||||
return candidateElements[0];
|
||||
}
|
||||
return {
|
||||
...c,
|
||||
overlapPercent: (overlapHeight * overlapWidth) / area,
|
||||
relativeArea: area / candidateArea,
|
||||
};
|
||||
})
|
||||
.filter((c) => c.overlapPercent > 0.25 && c.relativeArea < 0.75);
|
||||
|
||||
// Prefer smaller shapes
|
||||
return candidateElements
|
||||
.sort(
|
||||
(a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2),
|
||||
)
|
||||
.pop() as NonDeleted<ExcalidrawBindableElement>;
|
||||
};
|
||||
|
||||
export const getHoveredElementForFocusPoint = (
|
||||
point: GlobalPoint,
|
||||
arrow: ExcalidrawArrowElement,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
tolerance?: number,
|
||||
): ExcalidrawBindableElement | null => {
|
||||
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
|
||||
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
||||
// because array is ordered from lower z-index to highest and we want element z-index
|
||||
// with higher z-index
|
||||
for (let index = elements.length - 1; index >= 0; --index) {
|
||||
const element = elements[index];
|
||||
|
||||
invariant(
|
||||
!element.isDeleted,
|
||||
"Elements in the function parameter for getAllElementsAtPositionForBinding() should not contain deleted elements",
|
||||
);
|
||||
|
||||
if (
|
||||
isBindableElement(element, false) &&
|
||||
bindingBorderTest(element, point, elementsMap, tolerance)
|
||||
) {
|
||||
candidateElements.push(element);
|
||||
}
|
||||
}
|
||||
|
||||
if (!candidateElements || candidateElements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (candidateElements.length === 1) {
|
||||
return candidateElements[0];
|
||||
}
|
||||
|
||||
const distanceFilteredCandidateElements = candidateElements
|
||||
// Resolve by distance
|
||||
.filter(
|
||||
(el) =>
|
||||
distanceToElement(el, elementsMap, point) <= getBindingGap(el, arrow) ||
|
||||
isPointInElement(point, el, elementsMap),
|
||||
);
|
||||
|
||||
if (distanceFilteredCandidateElements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return distanceFilteredCandidateElements[0] as NonDeleted<ExcalidrawBindableElement>;
|
||||
return candidate.distance >= 0 && overlaps.length > 0
|
||||
? overlaps[0].element
|
||||
: candidate.element;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -444,6 +501,7 @@ export const intersectElementWithLineSegment = (
|
||||
// Do the actual intersection test against the element's shape
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "stickynote":
|
||||
case "image":
|
||||
case "text":
|
||||
case "iframe":
|
||||
@@ -485,6 +543,8 @@ export const intersectElementWithLineSegment = (
|
||||
}
|
||||
};
|
||||
|
||||
const CURVE_BOUNDS_EPSILON = 1e-6;
|
||||
|
||||
const curveIntersections = (
|
||||
curves: Curve<GlobalPoint>[],
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
@@ -493,15 +553,19 @@ const curveIntersections = (
|
||||
angle: Radians,
|
||||
onlyFirst = false,
|
||||
) => {
|
||||
// Pad the segment bounds so an axis-aligned segment passing exactly through
|
||||
// the joint of two curves still overlaps their bounds, as
|
||||
// `doBoundsIntersect` treats bounds that only touch as not intersecting
|
||||
const b2 = [
|
||||
Math.min(segment[0][0], segment[1][0]) - CURVE_BOUNDS_EPSILON,
|
||||
Math.min(segment[0][1], segment[1][1]) - CURVE_BOUNDS_EPSILON,
|
||||
Math.max(segment[0][0], segment[1][0]) + CURVE_BOUNDS_EPSILON,
|
||||
Math.max(segment[0][1], segment[1][1]) + CURVE_BOUNDS_EPSILON,
|
||||
] as Bounds;
|
||||
|
||||
for (const c of curves) {
|
||||
// Optimize by doing a cheap bounding box check first
|
||||
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
|
||||
const b2 = [
|
||||
Math.min(segment[0][0], segment[1][0]),
|
||||
Math.min(segment[0][1], segment[1][1]),
|
||||
Math.max(segment[0][0], segment[1][0]),
|
||||
Math.max(segment[0][1], segment[1][1]),
|
||||
] as Bounds;
|
||||
|
||||
if (!doBoundsIntersect(b1, b2)) {
|
||||
continue;
|
||||
@@ -761,6 +825,26 @@ export const isPointInElement = (
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isFreeDrawElement(element)) {
|
||||
// Test before the rotated-bounds check below: the smoothed fill can bulge
|
||||
// past the recorded points, and how much of it those bounds would clip
|
||||
// depends on the angle. An asymmetric loop's rotated bounds center is also
|
||||
// not its rotation pivot.
|
||||
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap, true);
|
||||
const [px, py] = pointRotateRads(
|
||||
point,
|
||||
pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2),
|
||||
-element.angle as Radians,
|
||||
);
|
||||
|
||||
// The fill follows the centerline rather than the stroke outline, and uses
|
||||
// the same even-odd rule as RoughJS's filled curves.
|
||||
return polygonIncludesPoint(
|
||||
pointFrom<LocalPoint>(px - element.x, py - element.y),
|
||||
getFreedrawFillPolygon(element),
|
||||
);
|
||||
}
|
||||
|
||||
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
|
||||
|
||||
if (!isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2))) {
|
||||
@@ -784,61 +868,3 @@ export const isPointInElement = (
|
||||
|
||||
return intersections.length % 2 === 1;
|
||||
};
|
||||
|
||||
export const isBindableElementInsideOtherBindable = (
|
||||
innerElement: ExcalidrawBindableElement,
|
||||
outerElement: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
): boolean => {
|
||||
// Get corner points of the inner element based on its type
|
||||
const getCornerPoints = (
|
||||
element: ExcalidrawElement,
|
||||
offset: number,
|
||||
): GlobalPoint[] => {
|
||||
const { x, y, width, height, angle } = element;
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
|
||||
if (element.type === "diamond") {
|
||||
// Diamond has 4 corner points at the middle of each side
|
||||
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
||||
getDiamondPoints(element);
|
||||
const corners: GlobalPoint[] = [
|
||||
pointFrom(x + topX, y + topY - offset), // top
|
||||
pointFrom(x + rightX + offset, y + rightY), // right
|
||||
pointFrom(x + bottomX, y + bottomY + offset), // bottom
|
||||
pointFrom(x + leftX - offset, y + leftY), // left
|
||||
];
|
||||
return corners.map((corner) => pointRotateRads(corner, center, angle));
|
||||
}
|
||||
if (element.type === "ellipse") {
|
||||
// For ellipse, test points at the extremes (top, right, bottom, left)
|
||||
const cx = x + width / 2;
|
||||
const cy = y + height / 2;
|
||||
const rx = width / 2;
|
||||
const ry = height / 2;
|
||||
const corners: GlobalPoint[] = [
|
||||
pointFrom(cx, cy - ry - offset), // top
|
||||
pointFrom(cx + rx + offset, cy), // right
|
||||
pointFrom(cx, cy + ry + offset), // bottom
|
||||
pointFrom(cx - rx - offset, cy), // left
|
||||
];
|
||||
return corners.map((corner) => pointRotateRads(corner, center, angle));
|
||||
}
|
||||
// Rectangle and other rectangular shapes (image, text, etc.)
|
||||
const corners: GlobalPoint[] = [
|
||||
pointFrom(x - offset, y - offset), // top-left
|
||||
pointFrom(x + width + offset, y - offset), // top-right
|
||||
pointFrom(x + width + offset, y + height + offset), // bottom-right
|
||||
pointFrom(x - offset, y + height + offset), // bottom-left
|
||||
];
|
||||
return corners.map((corner) => pointRotateRads(corner, center, angle));
|
||||
};
|
||||
|
||||
const offset = (-1 * Math.max(innerElement.width, innerElement.height)) / 20; // 5% offset
|
||||
const innerCorners = getCornerPoints(innerElement, offset);
|
||||
|
||||
// Check if all corner points of the inner element are inside the outer element
|
||||
return innerCorners.every((corner) =>
|
||||
isPointInElement(corner, outerElement, elementsMap),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,22 +2,31 @@ import type { ElementOrToolType } from "@excalidraw/excalidraw/types";
|
||||
|
||||
export const hasBackground = (type: ElementOrToolType) =>
|
||||
type === "rectangle" ||
|
||||
type === "stickynote" ||
|
||||
type === "iframe" ||
|
||||
type === "embeddable" ||
|
||||
type === "ellipse" ||
|
||||
type === "diamond" ||
|
||||
type === "line" ||
|
||||
type === "freedraw";
|
||||
type === "freedraw" ||
|
||||
type === "autoshape" ||
|
||||
// tool-only type; makes the `G` background shortcut work for bucket fill
|
||||
type === "bucketfill";
|
||||
|
||||
export const hasFillStyle = (type: ElementOrToolType) =>
|
||||
hasBackground(type) && type !== "stickynote";
|
||||
|
||||
export const hasStrokeColor = (type: ElementOrToolType) =>
|
||||
type === "rectangle" ||
|
||||
type === "stickynote" ||
|
||||
type === "ellipse" ||
|
||||
type === "diamond" ||
|
||||
type === "freedraw" ||
|
||||
type === "arrow" ||
|
||||
type === "line" ||
|
||||
type === "text" ||
|
||||
type === "embeddable";
|
||||
type === "embeddable" ||
|
||||
type === "autoshape";
|
||||
|
||||
export const hasStrokeWidth = (type: ElementOrToolType) =>
|
||||
type === "rectangle" ||
|
||||
@@ -27,7 +36,8 @@ export const hasStrokeWidth = (type: ElementOrToolType) =>
|
||||
type === "diamond" ||
|
||||
type === "freedraw" ||
|
||||
type === "arrow" ||
|
||||
type === "line";
|
||||
type === "line" ||
|
||||
type === "autoshape";
|
||||
|
||||
export const hasStrokeStyle = (type: ElementOrToolType) =>
|
||||
type === "rectangle" ||
|
||||
@@ -36,7 +46,13 @@ export const hasStrokeStyle = (type: ElementOrToolType) =>
|
||||
type === "ellipse" ||
|
||||
type === "diamond" ||
|
||||
type === "arrow" ||
|
||||
type === "line";
|
||||
type === "line" ||
|
||||
type === "autoshape";
|
||||
|
||||
export const hasRoughness = (type: ElementOrToolType) =>
|
||||
hasStrokeStyle(type) || type === "stickynote";
|
||||
|
||||
export const hasFreedrawMode = (type: ElementOrToolType) => type === "freedraw";
|
||||
|
||||
export const canChangeRoundness = (type: ElementOrToolType) =>
|
||||
type === "rectangle" ||
|
||||
@@ -44,6 +60,7 @@ export const canChangeRoundness = (type: ElementOrToolType) =>
|
||||
type === "embeddable" ||
|
||||
type === "line" ||
|
||||
type === "diamond" ||
|
||||
type === "stickynote" ||
|
||||
type === "image";
|
||||
|
||||
export const toolIsArrow = (type: ElementOrToolType) => type === "arrow";
|
||||
|
||||
@@ -0,0 +1,821 @@
|
||||
import {
|
||||
getBoundsFromPoints,
|
||||
getElementAbsoluteCoords,
|
||||
} from "@excalidraw/element/bounds";
|
||||
import {
|
||||
newArrowElement,
|
||||
newElement,
|
||||
newLinearElement,
|
||||
} from "@excalidraw/element/newElement";
|
||||
import {
|
||||
ELEMENT_PENDING_DRAW_SHAPE_OPACITY,
|
||||
getStrokeWidthByKey,
|
||||
ROUNDNESS,
|
||||
} from "@excalidraw/common";
|
||||
import {
|
||||
convexHull,
|
||||
distanceToLineSegment,
|
||||
elongation,
|
||||
kurtosis,
|
||||
lineSegment,
|
||||
orientPrincipalAxes,
|
||||
pointFrom,
|
||||
polygonArea,
|
||||
principalAxes,
|
||||
principalCoords,
|
||||
skewness,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { App, AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { LocalPoint, GlobalPoint, Radians } from "@excalidraw/math";
|
||||
import type { Bounds } from "@excalidraw/common";
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawEllipseElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawFrameLikeElement,
|
||||
ExcalidrawRectangleElement,
|
||||
ElementsMap,
|
||||
ExcalidrawNonSelectionElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeleted,
|
||||
ExcalidrawLineElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { getFrameLikeElements } from "./frame";
|
||||
import {
|
||||
isLinearElement,
|
||||
isLineElement,
|
||||
isUsingAdaptiveRadius,
|
||||
} from "./typeChecks";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
LAST_POINTS?: readonly GlobalPoint[];
|
||||
}
|
||||
}
|
||||
|
||||
// A moment-based shape recognizer.
|
||||
//
|
||||
// NOTE: Rectangle vs. diamond is deliberately *not* rotation invariant. A 45
|
||||
// rectangle = diamond.
|
||||
|
||||
type NonDeletedRecognizedShapeElement = NonDeleted<
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawEllipseElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawArrowElement
|
||||
| ExcalidrawLineElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
>;
|
||||
|
||||
type Shape = NonDeletedRecognizedShapeElement["type"];
|
||||
|
||||
interface ShapeRecognitionResult<P extends LocalPoint | GlobalPoint> {
|
||||
// The type of the recognized shape, or "freedraw" if no good match was found
|
||||
type: Shape;
|
||||
|
||||
// The original input points (not simplified)
|
||||
points: readonly P[];
|
||||
|
||||
// Bounding box of the original input points, used for positioning the
|
||||
// converted shape
|
||||
boundingBox: Bounds;
|
||||
}
|
||||
|
||||
// Number of points every stroke is resampled to before feature extraction.
|
||||
const RESAMPLE_N = 64;
|
||||
|
||||
// Minimum apparent (on-screen) size of a stroke's larger bounding-box
|
||||
// dimension, in pixels, for recognition to run — below this the stroke reads
|
||||
// as an accidental scribble. Compared against `sceneSize * zoom` so the same
|
||||
// physical gesture behaves the same at every zoom level.
|
||||
//
|
||||
// Maximum not required. Runtime is O(point count), points come in at roughly
|
||||
// one per frame.
|
||||
const RECOGNITION_MIN_SCREEN_SIZE = 25;
|
||||
|
||||
// A stroke whose endpoints are farther apart than this fraction of its own
|
||||
// path length is treated as open (a line or an arrow) rather than closed (a
|
||||
// rectangle, diamond or ellipse).
|
||||
const CLOSED_GAP_MAX_RATIO = 0.15;
|
||||
|
||||
// Maximum elongation for an open stroke to count as straight.
|
||||
const LINEAR_MAX_ELONGATION = 0.25;
|
||||
|
||||
// Fraction of the start→tip distance around the tip inside which an arrowhead
|
||||
// may live: points there are exempt from the shaft straightness check.
|
||||
const ARROWHEAD_ZONE_RATIO = 0.5;
|
||||
|
||||
// Maximum distance any point outside the arrowhead zone may stray from the
|
||||
// start→tip chord, as a fraction of the chord length. Loose enough for a lazy
|
||||
// bow or a small squiggle, tight enough to reject elbows, arcs and sawtooths
|
||||
// that only *statistically* look straight (their point-cloud elongation
|
||||
// passes, their geometry doesn't).
|
||||
const LINEAR_MAX_SHAFT_DEVIATION = 0.15;
|
||||
|
||||
// Minimum |skew| along the major axis for an open, straight stroke to be an
|
||||
// arrow rather than a plain line. Arrowhead add "mass" -> skews that way.
|
||||
const ARROW_MIN_SKEW = 0.3;
|
||||
|
||||
// Maximum feature-space distance to a shape prototype, in units of the
|
||||
// per-feature tolerances in CLOSED_SHAPE_PROTOTYPES. Beyond this the stroke is
|
||||
// not enough like any known shape and stays freedraw.
|
||||
const CLOSED_SHAPE_MAX_DISTANCE = 1.5;
|
||||
|
||||
// Half-width, in resampled points, of the chord window used to measure local
|
||||
// turning along the stroke. Wide enough to smooth pen wobble, narrow enough
|
||||
// that adjacent corners of a small quadrilateral stay separate peaks.
|
||||
const TURN_WINDOW = 3;
|
||||
|
||||
// =============================================================================
|
||||
// Feature extraction
|
||||
// =============================================================================
|
||||
|
||||
// Resample `pts` to exactly `n` evenly-spaced points along the stroke path.
|
||||
function resample<P extends LocalPoint | GlobalPoint>(
|
||||
pts: readonly P[],
|
||||
n: number,
|
||||
): P[] {
|
||||
let totalLen = 0;
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
totalLen += Math.hypot(
|
||||
pts[i][0] - pts[i - 1][0],
|
||||
pts[i][1] - pts[i - 1][1],
|
||||
);
|
||||
}
|
||||
|
||||
const interval = totalLen / (n - 1);
|
||||
let accumulated = 0;
|
||||
const result: P[] = [pts[0]];
|
||||
let prev = pts[0];
|
||||
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const curr = pts[i];
|
||||
const segLen = Math.hypot(curr[0] - prev[0], curr[1] - prev[1]);
|
||||
if (accumulated + segLen >= interval) {
|
||||
// Insert interpolated points within this segment
|
||||
let remaining = interval - accumulated;
|
||||
while (remaining <= segLen + 1e-10) {
|
||||
const t = remaining / segLen;
|
||||
const newPt: P = [
|
||||
prev[0] + t * (curr[0] - prev[0]),
|
||||
prev[1] + t * (curr[1] - prev[1]),
|
||||
] as P;
|
||||
result.push(newPt);
|
||||
if (result.length === n) {
|
||||
return result;
|
||||
}
|
||||
prev = newPt;
|
||||
accumulated = 0;
|
||||
remaining += interval;
|
||||
}
|
||||
accumulated = segLen - (remaining - interval);
|
||||
} else {
|
||||
accumulated += segLen;
|
||||
}
|
||||
prev = curr;
|
||||
}
|
||||
|
||||
// Fill any remaining slots with the last point (rounding errors)
|
||||
while (result.length < n) {
|
||||
result.push(pts[pts.length - 1]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Statistical description of a stroke, in terms that don't depend on where,
|
||||
// how big, or (except where noted) how rotated it was drawn.
|
||||
interface StrokeFeatures {
|
||||
// Straight-line distance between the endpoints over the stroke's own path
|
||||
// length. ~0 when the stroke returns to its start.
|
||||
gapRatio: number;
|
||||
|
||||
// Point cloud elongation along the major axis.
|
||||
elongation: number;
|
||||
|
||||
// Skew of the points projected onto the major axis. Signed so that it is
|
||||
// <= 0 by construction.
|
||||
majorSkew: number;
|
||||
|
||||
// Convex hull area over axis-aligned bounding box area, in the screen
|
||||
// frame: ~1 for a rectangle, ~PI/4 for an ellipse, ~1/2 for a diamond.
|
||||
hullFillRatio: number;
|
||||
|
||||
// Share of the stroke's total turning concentrated in its 4 strongest
|
||||
// corner loci: ~1 for any quadrilateral — however tapered or slanted, all
|
||||
// turning happens at the corners — vs. ~0.5 for an ellipse, whose turning
|
||||
// is spread along the whole outline. This is what tells a sloppy,
|
||||
// trapezoid-ish rectangle (hull fill ≈ PI/4, same as an ellipse's) from an
|
||||
// actual ellipse: sharp corners.
|
||||
cornerTurnShare: number;
|
||||
|
||||
// kurtosis along x times kurtosis along y, in the screen frame. The two
|
||||
// factors each vary with the aspect ratio, but their product barely does:
|
||||
// ~1.83 for any rectangle, 2.25 for any ellipse, 3.24 for any diamond.
|
||||
kurtosisProduct: number;
|
||||
|
||||
// How far the stroke strays from the straight chord between its start and
|
||||
// its farthest point ("tip"), as a fraction of that chord's length — points
|
||||
// within ARROWHEAD_ZONE_RATIO of the tip are exempt, so an arrowhead
|
||||
// doesn't count as straying. ~0 for a line or arrow however wobbly, large
|
||||
// for elbows, arcs and sawtooths, whose point-cloud statistics alone can
|
||||
// still look line-like.
|
||||
shaftDeviationRatio: number;
|
||||
}
|
||||
|
||||
// See StrokeFeatures.shaftDeviationRatio.
|
||||
function shaftDeviationRatio<P extends LocalPoint | GlobalPoint>(
|
||||
pts: readonly P[],
|
||||
): number {
|
||||
const start = pts[0];
|
||||
let tip = start;
|
||||
let tipDistance = 0;
|
||||
for (const point of pts) {
|
||||
const distance = Math.hypot(point[0] - start[0], point[1] - start[1]);
|
||||
if (distance > tipDistance) {
|
||||
tipDistance = distance;
|
||||
tip = point;
|
||||
}
|
||||
}
|
||||
if (tipDistance === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const chord = lineSegment(start, tip);
|
||||
let maxDeviation = 0;
|
||||
for (const point of pts) {
|
||||
const tipDist = Math.hypot(point[0] - tip[0], point[1] - tip[1]);
|
||||
if (tipDist <= ARROWHEAD_ZONE_RATIO * tipDistance) {
|
||||
continue;
|
||||
}
|
||||
maxDeviation = Math.max(maxDeviation, distanceToLineSegment(point, chord));
|
||||
}
|
||||
return maxDeviation / tipDistance;
|
||||
}
|
||||
|
||||
// The turn angle at every resampled point, measured between the incoming and
|
||||
// outgoing chords `TURN_WINDOW` points away. The stroke is deliberately NOT
|
||||
// treated as a closed loop: wrapping around would let the closure artifacts of
|
||||
// a hand-drawn outline (overshooting or undershooting the starting point)
|
||||
// fabricate a sharp corner that was never drawn.
|
||||
function windowedTurns<P extends LocalPoint | GlobalPoint>(
|
||||
pts: readonly P[],
|
||||
): number[] {
|
||||
const turns: number[] = [];
|
||||
for (let i = TURN_WINDOW; i < pts.length - TURN_WINDOW; i++) {
|
||||
const [ax, ay] = pts[i - TURN_WINDOW];
|
||||
const [bx, by] = pts[i];
|
||||
const [cx, cy] = pts[i + TURN_WINDOW];
|
||||
const v1x = bx - ax;
|
||||
const v1y = by - ay;
|
||||
const v2x = cx - bx;
|
||||
const v2y = cy - by;
|
||||
turns.push(
|
||||
Math.abs(Math.atan2(v1x * v2y - v1y * v2x, v1x * v2x + v1y * v2y)),
|
||||
);
|
||||
}
|
||||
return turns;
|
||||
}
|
||||
|
||||
// See StrokeFeatures.cornerTurnShare. Greedily picks the 4 strongest turn
|
||||
// peaks, each absorbing its ±TURN_WINDOW neighborhood so one physical corner
|
||||
// (whose turn smears across the window) counts once.
|
||||
function cornerTurnShare<P extends LocalPoint | GlobalPoint>(
|
||||
pts: readonly P[],
|
||||
): number {
|
||||
const turns = windowedTurns(pts);
|
||||
const total = turns.reduce((sum, turn) => sum + turn, 0);
|
||||
if (total === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const taken = new Array<boolean>(turns.length).fill(false);
|
||||
let top4 = 0;
|
||||
for (let corner = 0; corner < 4; corner++) {
|
||||
let peak = -1;
|
||||
let peakTurn = 0;
|
||||
for (let i = 0; i < turns.length; i++) {
|
||||
if (!taken[i] && turns[i] > peakTurn) {
|
||||
peakTurn = turns[i];
|
||||
peak = i;
|
||||
}
|
||||
}
|
||||
if (peak < 0) {
|
||||
break;
|
||||
}
|
||||
for (let i = peak - TURN_WINDOW; i <= peak + TURN_WINDOW; i++) {
|
||||
if (i >= 0 && i < turns.length && !taken[i]) {
|
||||
top4 += turns[i];
|
||||
taken[i] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return top4 / total;
|
||||
}
|
||||
|
||||
function extractFeatures<P extends LocalPoint | GlobalPoint>(
|
||||
points: readonly P[],
|
||||
): StrokeFeatures {
|
||||
// Resample first: features are moments of the point set, so they would
|
||||
// otherwise be biased by pointer speed, which crowds samples wherever the
|
||||
// user drew slowly.
|
||||
const pts = resample(points, RESAMPLE_N);
|
||||
|
||||
let pathLength = 0;
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
pathLength += Math.hypot(
|
||||
pts[i][0] - pts[i - 1][0],
|
||||
pts[i][1] - pts[i - 1][1],
|
||||
);
|
||||
}
|
||||
const gap = Math.hypot(
|
||||
pts[pts.length - 1][0] - pts[0][0],
|
||||
pts[pts.length - 1][1] - pts[0][1],
|
||||
);
|
||||
|
||||
const axes = orientPrincipalAxes(pts, principalAxes(pts));
|
||||
const majorProjection = principalCoords(pts, axes).map(([u]) => u);
|
||||
|
||||
const hull = convexHull(pts);
|
||||
const [minX, minY, maxX, maxY] = getBoundsFromPoints(pts);
|
||||
const boxArea = (maxX - minX) * (maxY - minY);
|
||||
|
||||
return {
|
||||
gapRatio: pathLength > 0 ? gap / pathLength : 0,
|
||||
elongation: elongation(axes),
|
||||
majorSkew: skewness(majorProjection),
|
||||
hullFillRatio: boxArea > 0 ? polygonArea(hull) / boxArea : 0,
|
||||
cornerTurnShare: cornerTurnShare(pts),
|
||||
kurtosisProduct:
|
||||
kurtosis(pts.map(([x]) => x)) * kurtosis(pts.map(([, y]) => y)),
|
||||
shaftDeviationRatio: shaftDeviationRatio(pts),
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Classification
|
||||
// =============================================================================
|
||||
|
||||
const CLOSED_SHAPE_PROTOTYPES: readonly {
|
||||
type: Shape;
|
||||
hullFillRatio: number;
|
||||
cornerTurnShare: number;
|
||||
kurtosisProduct: number;
|
||||
}[] = [
|
||||
{
|
||||
type: "rectangle",
|
||||
hullFillRatio: 1,
|
||||
cornerTurnShare: 0.95,
|
||||
kurtosisProduct: 1.83,
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
hullFillRatio: 0.5,
|
||||
cornerTurnShare: 0.95,
|
||||
kurtosisProduct: 3.24,
|
||||
},
|
||||
{
|
||||
type: "ellipse",
|
||||
hullFillRatio: Math.PI / 4,
|
||||
cornerTurnShare: 0.55,
|
||||
kurtosisProduct: 2.25,
|
||||
},
|
||||
];
|
||||
|
||||
// Hull fill no longer has to separate rectangles from ellipses on its own
|
||||
// (cornerTurnShare carries that), so it can afford the slack that lets a
|
||||
// tapered, hand-drawn quadrilateral (fill ≈ 0.8) still read as a rectangle.
|
||||
const HULL_FILL_RATIO_TOLERANCE = 0.2;
|
||||
const CORNER_TURN_SHARE_TOLERANCE = 0.2;
|
||||
const KURTOSIS_PRODUCT_TOLERANCE = 0.7;
|
||||
|
||||
// Pick the closed shape whose prototype the stroke's features sit closest to,
|
||||
// or freedraw when even the nearest one is too far away.
|
||||
function classifyClosedStroke(features: StrokeFeatures): Shape {
|
||||
let best: Shape = "freedraw";
|
||||
let bestDistance = CLOSED_SHAPE_MAX_DISTANCE;
|
||||
|
||||
for (const prototype of CLOSED_SHAPE_PROTOTYPES) {
|
||||
const distance = Math.hypot(
|
||||
(features.hullFillRatio - prototype.hullFillRatio) /
|
||||
HULL_FILL_RATIO_TOLERANCE,
|
||||
(features.cornerTurnShare - prototype.cornerTurnShare) /
|
||||
CORNER_TURN_SHARE_TOLERANCE,
|
||||
(features.kurtosisProduct - prototype.kurtosisProduct) /
|
||||
KURTOSIS_PRODUCT_TOLERANCE,
|
||||
);
|
||||
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = prototype.type;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
// An open stroke is a line or an arrow, provided it is straight enough; the
|
||||
// arrowhead is what makes the point distribution lopsided.
|
||||
function classifyOpenStroke(features: StrokeFeatures): Shape {
|
||||
if (
|
||||
features.elongation > LINEAR_MAX_ELONGATION ||
|
||||
features.shaftDeviationRatio > LINEAR_MAX_SHAFT_DEVIATION
|
||||
) {
|
||||
return "freedraw";
|
||||
}
|
||||
return Math.abs(features.majorSkew) >= ARROW_MIN_SKEW ? "arrow" : "line";
|
||||
}
|
||||
|
||||
function classify(features: StrokeFeatures): Shape {
|
||||
return features.gapRatio > CLOSED_GAP_MAX_RATIO
|
||||
? classifyOpenStroke(features)
|
||||
: classifyClosedStroke(features);
|
||||
}
|
||||
|
||||
// Arrow endpoint selection
|
||||
|
||||
// When an arrow is recognized, the original input's last point may not
|
||||
// correspond to the actual arrow tip. The tip must lie on the bounding-box
|
||||
// perimeter and is the perimeter point farthest from the start (first input
|
||||
// point). We find the closest original input point to that ideal tip.
|
||||
function getArrowEndpoint<P extends LocalPoint | GlobalPoint>(
|
||||
points: readonly P[],
|
||||
boundingBox: Bounds,
|
||||
startPoint: P,
|
||||
): P {
|
||||
const [minX, minY, maxX, maxY] = boundingBox;
|
||||
const w = maxX - minX;
|
||||
const h = maxY - minY;
|
||||
|
||||
// Degenerate bounding box – fall back to last point
|
||||
if (w === 0 && h === 0) {
|
||||
return points[points.length - 1];
|
||||
}
|
||||
|
||||
// Perimeter corners / edge points to check (4 corners + 4 edge midpoints
|
||||
// give good coverage of the perimeter).
|
||||
const perimeterPoints: LocalPoint[] = [
|
||||
pointFrom(minX, minY),
|
||||
pointFrom((minX + maxX) / 2, minY),
|
||||
pointFrom(maxX, minY),
|
||||
pointFrom(maxX, (minY + maxY) / 2),
|
||||
pointFrom(maxX, maxY),
|
||||
pointFrom((minX + maxX) / 2, maxY),
|
||||
pointFrom(minX, maxY),
|
||||
pointFrom(minX, (minY + maxY) / 2),
|
||||
];
|
||||
|
||||
// Ideal endpoint = perimeter point farthest from start.
|
||||
let idealDist = -1;
|
||||
let idealEndpoint: LocalPoint = pointFrom(maxX, maxY);
|
||||
for (const pp of perimeterPoints) {
|
||||
const d = Math.hypot(pp[0] - startPoint[0], pp[1] - startPoint[1]);
|
||||
if (d > idealDist) {
|
||||
idealDist = d;
|
||||
idealEndpoint = pp;
|
||||
}
|
||||
}
|
||||
|
||||
// Find the original input point closest to the ideal endpoint.
|
||||
let bestDist = Infinity;
|
||||
let bestPoint: P = points[points.length - 1];
|
||||
for (const pt of points) {
|
||||
const d = Math.hypot(pt[0] - idealEndpoint[0], pt[1] - idealEndpoint[1]);
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
bestPoint = pt;
|
||||
}
|
||||
}
|
||||
return bestPoint;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Public API
|
||||
// =============================================================================
|
||||
|
||||
// Recognizes common shapes from free-draw input points
|
||||
export const recognizeShape = <P extends LocalPoint | GlobalPoint>(
|
||||
points: P[],
|
||||
previousElement: ExcalidrawElement | null,
|
||||
zoom: number = 1,
|
||||
): ShapeRecognitionResult<P> => {
|
||||
// DEBUG (will be removed lated)
|
||||
if (typeof window !== "undefined") {
|
||||
window.LAST_POINTS = points.map(([x, y]) => pointFrom<GlobalPoint>(x, y));
|
||||
}
|
||||
|
||||
const boundingBox = getBoundsFromPoints(points);
|
||||
|
||||
const [minX, minY, maxX, maxY] = boundingBox;
|
||||
const maxDim = Math.max(maxX - minX, maxY - minY);
|
||||
if (points.length < 3 || maxDim * zoom < RECOGNITION_MIN_SCREEN_SIZE) {
|
||||
return { type: "freedraw", points, boundingBox };
|
||||
}
|
||||
|
||||
const recognized = classify(extractFeatures(points));
|
||||
const type =
|
||||
previousElement?.type === "arrow" && recognized !== "arrow"
|
||||
? "freedraw"
|
||||
: recognized;
|
||||
|
||||
return { type, points, boundingBox };
|
||||
};
|
||||
|
||||
// Converts a freedraw element to the detected shape.
|
||||
//
|
||||
// `frames` lets callers pass the scene's cached frame-like elements (e.g.
|
||||
// scene.getNonDeletedFramesLikes()) to avoid spreading and re-filtering the
|
||||
// whole element map on every invocation; when omitted it is derived from
|
||||
// `elementsMap`.
|
||||
export const convertToShape = (
|
||||
points: GlobalPoint[],
|
||||
appState: AppState,
|
||||
elementsMap: ElementsMap,
|
||||
previousElement: ExcalidrawElement | null,
|
||||
frames?: readonly ExcalidrawFrameLikeElement[],
|
||||
): NonDeletedRecognizedShapeElement | undefined => {
|
||||
const recognizedShape = recognizeShape(
|
||||
points,
|
||||
previousElement,
|
||||
appState.zoom.value,
|
||||
);
|
||||
|
||||
const boundingBox = recognizedShape.boundingBox;
|
||||
const [minX, minY, maxX, maxY] = boundingBox;
|
||||
|
||||
const frameLikeElements =
|
||||
frames ?? getFrameLikeElements([...elementsMap.values()]);
|
||||
const frameId =
|
||||
frameLikeElements.find((frame) => {
|
||||
const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame, elementsMap);
|
||||
return fx1 <= minX && fy1 <= minY && fx2 >= maxX && fy2 >= maxY;
|
||||
})?.id ?? null;
|
||||
|
||||
const roundness =
|
||||
appState.currentItemRoundness === "round"
|
||||
? {
|
||||
type: isUsingAdaptiveRadius(recognizedShape.type)
|
||||
? ROUNDNESS.ADAPTIVE_RADIUS
|
||||
: ROUNDNESS.PROPORTIONAL_RADIUS,
|
||||
}
|
||||
: null;
|
||||
|
||||
switch (recognizedShape.type) {
|
||||
case "rectangle":
|
||||
case "diamond":
|
||||
case "ellipse": {
|
||||
return newElement({
|
||||
type: recognizedShape.type,
|
||||
x: recognizedShape.boundingBox[0],
|
||||
y: recognizedShape.boundingBox[1],
|
||||
width: recognizedShape.boundingBox[2] - recognizedShape.boundingBox[0],
|
||||
height: recognizedShape.boundingBox[3] - recognizedShape.boundingBox[1],
|
||||
groupIds: [],
|
||||
angle: 0 as Radians,
|
||||
frameId,
|
||||
roundness,
|
||||
roughness: appState.currentItemRoughness,
|
||||
backgroundColor: appState.currentItemBackgroundColor,
|
||||
strokeColor: appState.currentItemStrokeColor,
|
||||
fillStyle: appState.currentItemFillStyle,
|
||||
opacity: appState.currentItemOpacity,
|
||||
strokeStyle: appState.currentItemStrokeStyle,
|
||||
strokeWidth: getStrokeWidthByKey(
|
||||
recognizedShape.type,
|
||||
appState.currentItemStrokeWidthKey,
|
||||
),
|
||||
}) as NonDeletedRecognizedShapeElement;
|
||||
}
|
||||
case "arrow": {
|
||||
const [arrowX, arrowY] = recognizedShape.points[0];
|
||||
|
||||
const [globalEndX, globalEndY] = getArrowEndpoint(
|
||||
recognizedShape.points,
|
||||
recognizedShape.boundingBox,
|
||||
recognizedShape.points[0],
|
||||
);
|
||||
const endPoint: LocalPoint = pointFrom<LocalPoint>(
|
||||
globalEndX - arrowX,
|
||||
globalEndY - arrowY,
|
||||
);
|
||||
const arrowLen = Math.hypot(
|
||||
globalEndX - recognizedShape.points[0][0],
|
||||
globalEndY - recognizedShape.points[0][1],
|
||||
);
|
||||
if (arrowLen < 60) {
|
||||
const tempElement = newLinearElement({
|
||||
type: "line",
|
||||
x: arrowX,
|
||||
y: arrowY,
|
||||
points: [
|
||||
pointFrom<LocalPoint>(
|
||||
recognizedShape.points[0][0] - arrowX,
|
||||
recognizedShape.points[0][1] - arrowY,
|
||||
),
|
||||
endPoint,
|
||||
],
|
||||
groupIds: [],
|
||||
frameId,
|
||||
locked: false,
|
||||
angle: 0 as Radians,
|
||||
roundness,
|
||||
roughness: appState.currentItemRoughness,
|
||||
backgroundColor: appState.currentItemBackgroundColor,
|
||||
strokeColor: appState.currentItemStrokeColor,
|
||||
fillStyle: appState.currentItemFillStyle,
|
||||
opacity: appState.currentItemOpacity,
|
||||
strokeStyle: appState.currentItemStrokeStyle,
|
||||
strokeWidth: getStrokeWidthByKey(
|
||||
recognizedShape.type,
|
||||
appState.currentItemStrokeWidthKey,
|
||||
),
|
||||
});
|
||||
|
||||
const normalized =
|
||||
LinearElementEditor.getNormalizeElementPointsAndCoords(tempElement);
|
||||
|
||||
return newLinearElement({
|
||||
...tempElement,
|
||||
...normalized,
|
||||
}) as NonDeletedRecognizedShapeElement;
|
||||
}
|
||||
const tempElement = newArrowElement({
|
||||
type: "arrow",
|
||||
x: arrowX,
|
||||
y: arrowY,
|
||||
startArrowhead: appState.currentItemStartArrowhead,
|
||||
endArrowhead: appState.currentItemEndArrowhead,
|
||||
points: [
|
||||
pointFrom<LocalPoint>(
|
||||
recognizedShape.points[0][0] - arrowX,
|
||||
recognizedShape.points[0][1] - arrowY,
|
||||
),
|
||||
endPoint,
|
||||
],
|
||||
groupIds: [],
|
||||
frameId,
|
||||
locked: false,
|
||||
angle: 0 as Radians,
|
||||
roundness,
|
||||
roughness: appState.currentItemRoughness,
|
||||
backgroundColor: appState.currentItemBackgroundColor,
|
||||
strokeColor: appState.currentItemStrokeColor,
|
||||
fillStyle: appState.currentItemFillStyle,
|
||||
opacity: appState.currentItemOpacity,
|
||||
strokeStyle: appState.currentItemStrokeStyle,
|
||||
strokeWidth: getStrokeWidthByKey(
|
||||
recognizedShape.type,
|
||||
appState.currentItemStrokeWidthKey,
|
||||
),
|
||||
});
|
||||
|
||||
const normalized =
|
||||
LinearElementEditor.getNormalizeElementPointsAndCoords(tempElement);
|
||||
|
||||
return newArrowElement({
|
||||
...tempElement,
|
||||
...normalized,
|
||||
});
|
||||
}
|
||||
case "line": {
|
||||
const [lineX, lineY] = recognizedShape.points[0];
|
||||
const endPoint =
|
||||
recognizedShape.points[recognizedShape.points.length - 1];
|
||||
const tempElement = newLinearElement({
|
||||
type: recognizedShape.type,
|
||||
x: lineX,
|
||||
y: lineY,
|
||||
points: [
|
||||
pointFrom<LocalPoint>(
|
||||
recognizedShape.points[0][0] - lineX,
|
||||
recognizedShape.points[0][1] - lineY,
|
||||
),
|
||||
pointFrom<LocalPoint>(endPoint[0] - lineX, endPoint[1] - lineY),
|
||||
],
|
||||
groupIds: [],
|
||||
frameId,
|
||||
locked: false,
|
||||
angle: 0 as Radians,
|
||||
roundness,
|
||||
roughness: appState.currentItemRoughness,
|
||||
backgroundColor: appState.currentItemBackgroundColor,
|
||||
strokeColor: appState.currentItemStrokeColor,
|
||||
fillStyle: appState.currentItemFillStyle,
|
||||
opacity: appState.currentItemOpacity,
|
||||
strokeStyle: appState.currentItemStrokeStyle,
|
||||
strokeWidth: getStrokeWidthByKey(
|
||||
recognizedShape.type,
|
||||
appState.currentItemStrokeWidthKey,
|
||||
),
|
||||
});
|
||||
|
||||
const normalized =
|
||||
LinearElementEditor.getNormalizeElementPointsAndCoords(tempElement);
|
||||
|
||||
return newLinearElement({
|
||||
...tempElement,
|
||||
...normalized,
|
||||
}) as NonDeletedRecognizedShapeElement;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Whether two drawShape preview elements are visually identical, so a redundant
|
||||
// preview recompute on pointermove can skip setState (avoiding a no-op React
|
||||
// re-render and roughjs shape regeneration).
|
||||
const isDrawShapePreviewEqual = (
|
||||
a: NonDeletedExcalidrawElement | null,
|
||||
b: NonDeletedExcalidrawElement,
|
||||
): boolean => {
|
||||
if (
|
||||
!a ||
|
||||
a.type !== b.type ||
|
||||
a.x !== b.x ||
|
||||
a.y !== b.y ||
|
||||
a.width !== b.width ||
|
||||
a.height !== b.height ||
|
||||
a.angle !== b.angle
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (isLinearElement(a) && isLinearElement(b)) {
|
||||
if (a.points.length !== b.points.length) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < a.points.length; i++) {
|
||||
if (
|
||||
a.points[i][0] !== b.points[i][0] ||
|
||||
a.points[i][1] !== b.points[i][1]
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Append to the trail and recompute the shape preview. This runs inside
|
||||
// withBatchedUpdatesThrottled (throttleRAF), so it fires at most once per
|
||||
// animation frame regardless of raw pointermove rate.
|
||||
export const convertToShapeHandlePointerMoveFromPointerDown = (
|
||||
app: App,
|
||||
pointerCoords: { x: number; y: number },
|
||||
) => {
|
||||
if (app.state.activeTool.type === "autoshape") {
|
||||
app.drawShape.trail.addPointToPath(pointerCoords.x, pointerCoords.y);
|
||||
|
||||
const drawShapeTrailPoints = app.drawShape.trail.getCurrentPoints();
|
||||
// note: size gate inside recognizeShape
|
||||
if (drawShapeTrailPoints.length >= 3) {
|
||||
const shapePreview = convertToShape(
|
||||
drawShapeTrailPoints,
|
||||
app.state,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
app.state.newElement,
|
||||
app.scene.getNonDeletedFramesLikes(),
|
||||
) as ExcalidrawNonSelectionElement | undefined;
|
||||
|
||||
// Lines get no live preview — nearly every stroke reads as a line at
|
||||
// some early point, so previewing them is mostly flicker. Releasing
|
||||
// still commits the line: finalize() re-recognizes from the trail when
|
||||
// no preview exists.
|
||||
if (shapePreview && !isLineElement(shapePreview)) {
|
||||
const prevPreview = app.state.newElement;
|
||||
const nextPreview = {
|
||||
...shapePreview,
|
||||
// Keep a stable id across the gesture while the recognized type
|
||||
// is unchanged, so the preview element's identity doesn't churn
|
||||
// frame to frame.
|
||||
id:
|
||||
prevPreview?.type === shapePreview.type
|
||||
? prevPreview.id
|
||||
: shapePreview.id,
|
||||
created:
|
||||
prevPreview?.type === shapePreview.type
|
||||
? prevPreview.created
|
||||
: shapePreview.created,
|
||||
seed: 1,
|
||||
opacity: ELEMENT_PENDING_DRAW_SHAPE_OPACITY,
|
||||
isDeleted: false as const,
|
||||
} as NonDeletedRecognizedShapeElement;
|
||||
|
||||
if (!isDrawShapePreviewEqual(prevPreview, nextPreview)) {
|
||||
app.setState({ newElement: nextPreview });
|
||||
}
|
||||
} else if (app.state.newElement !== null) {
|
||||
app.setState({ newElement: null });
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
@@ -26,7 +26,6 @@ import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawImageElement,
|
||||
ImageCrop,
|
||||
NonDeleted,
|
||||
} from "./types";
|
||||
|
||||
export const MINIMAL_CROP_SIZE = 10;
|
||||
@@ -406,7 +405,7 @@ export const cropElement = (
|
||||
};
|
||||
|
||||
const recomputeOrigin = (
|
||||
stateAtCropStart: NonDeleted<ExcalidrawElement>,
|
||||
stateAtCropStart: ExcalidrawElement,
|
||||
transformHandle: TransformHandleType,
|
||||
width: number,
|
||||
height: number,
|
||||
|
||||
+185
-38
@@ -14,6 +14,7 @@ import type {
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextElement,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
Ordered,
|
||||
OrderedExcalidrawElement,
|
||||
SceneElementsMap,
|
||||
@@ -46,6 +47,7 @@ import { mutateElement, newElementWith } from "./mutateElement";
|
||||
import { getBoundTextElementId, redrawTextBoundingBox } from "./textElement";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isArrowElement,
|
||||
isBindableElement,
|
||||
isBoundToContainer,
|
||||
isTextElement,
|
||||
@@ -1441,6 +1443,48 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
}
|
||||
|
||||
try {
|
||||
// Whether this delta results in a visible change is decided from the
|
||||
// *applied* result, not from the property patches: binding repair and
|
||||
// layout (text bounding boxes, bound arrows) run below and can move
|
||||
// elements the delta never named, and an `isDeleted` flip or a
|
||||
// `boundElements` edit is not visible by itself — deleting a note's
|
||||
// empty label is the canonical case. History relies on this answer to
|
||||
// skip such entries on undo/redo (see `History`'s apply loop).
|
||||
//
|
||||
// `idsToCheck` is every element whose "before" and "after" get
|
||||
// compared: the changed elements plus what layout can move for them —
|
||||
// a text's container, and everything in `boundElements` (a container's
|
||||
// label and arrows). The set is walked as it grows, so an arrow added
|
||||
// for its container brings in its own label in turn. The dev/test
|
||||
// assertion after the redraw verifies the set is complete.
|
||||
const idsToCheck = new Set(changedElements.keys());
|
||||
// Unchanged elements are shared between `elements` and `nextElements`,
|
||||
// and layout mutates them in place — their "before" must be a shallow
|
||||
// copy taken now. Changed elements already have a fresh instance in
|
||||
// `nextElements`, so the instance in `elements` is their "before" as
|
||||
// is. Only `idsToCheck` is saved; the whole scene is never copied.
|
||||
const previousElements = new Map<string, OrderedExcalidrawElement>();
|
||||
for (const id of idsToCheck) {
|
||||
const previous = elements.get(id);
|
||||
const next = nextElements.get(id);
|
||||
for (const element of [previous, next]) {
|
||||
if (element) {
|
||||
if (isTextElement(element) && element.containerId) {
|
||||
idsToCheck.add(element.containerId);
|
||||
}
|
||||
for (const binding of element.boundElements ?? []) {
|
||||
idsToCheck.add(binding.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (previous) {
|
||||
previousElements.set(
|
||||
id,
|
||||
previous === next ? { ...previous } : previous,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// the following reorder performs mutations, but only on new instances of changed elements,
|
||||
// unless something goes really bad and it fallbacks to fixing all invalid indices
|
||||
nextElements = ElementsDelta.reorderElements(
|
||||
@@ -1449,8 +1493,53 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
flags,
|
||||
);
|
||||
|
||||
// Guard for `idsToCheck`: layout bumps the version of everything it
|
||||
// mutates, so an element whose version moved during the redraw but was
|
||||
// never collected is a layout dependency the expansion above does not
|
||||
// know about — and a visible change that undo would silently skip.
|
||||
// Fail loudly instead (dev and tests only; this is a scene-wide pass).
|
||||
const versionsBeforeRedraw =
|
||||
isTestEnv() || isDevEnv()
|
||||
? new Map(
|
||||
Array.from(nextElements, ([id, element]) => [
|
||||
id,
|
||||
element.version,
|
||||
]),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
ElementsDelta.redrawElements(nextElements, changedElements);
|
||||
|
||||
if (versionsBeforeRedraw) {
|
||||
for (const [id, element] of nextElements) {
|
||||
if (
|
||||
versionsBeforeRedraw.get(id) !== element.version &&
|
||||
!idsToCheck.has(id)
|
||||
) {
|
||||
throw new Error(
|
||||
`Redrawn element "${id}" is missing from idsToCheck`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!flags.containsVisibleDifference) {
|
||||
for (const id of idsToCheck) {
|
||||
if (
|
||||
ElementsDelta.checkForVisibleDifference(
|
||||
previousElements.get(id),
|
||||
nextElements.get(id),
|
||||
previousElements,
|
||||
nextElements,
|
||||
)
|
||||
) {
|
||||
flags.containsVisibleDifference = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
flags.containsVisibleDifference = true;
|
||||
console.error(
|
||||
`Couldn't mutate elements after applying elements change`,
|
||||
e,
|
||||
@@ -1459,9 +1548,9 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
if (isTestEnv() || isDevEnv()) {
|
||||
throw e;
|
||||
}
|
||||
} finally {
|
||||
return [nextElements, flags.containsVisibleDifference];
|
||||
}
|
||||
|
||||
return [nextElements, flags.containsVisibleDifference];
|
||||
}
|
||||
|
||||
public squash(delta: ElementsDelta): this {
|
||||
@@ -1624,12 +1713,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
|
||||
if (element) {
|
||||
// as the element was brought from the snapshot, it automatically results in a possible zindex difference
|
||||
// (whether it is *visible* is decided in `applyTo`, from the applied result)
|
||||
flags.containsZindexDifference = true;
|
||||
|
||||
// as the element was force deleted, we need to check if adding it back results in a visible change
|
||||
if (!partial.isDeleted || (partial.isDeleted && !element.isDeleted)) {
|
||||
flags.containsVisibleDifference = true;
|
||||
}
|
||||
} else {
|
||||
// not in elements, not in snapshot? element might have been added remotely!
|
||||
element = newElementWith(
|
||||
@@ -1686,17 +1771,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
});
|
||||
}
|
||||
|
||||
if (!flags.containsVisibleDifference) {
|
||||
// strip away fractional index, as even if it would be different, it doesn't have to result in visible change
|
||||
const { index, ...rest } = directlyApplicablePartial;
|
||||
const containsVisibleDifference = ElementsDelta.checkForVisibleDifference(
|
||||
element,
|
||||
rest,
|
||||
);
|
||||
|
||||
flags.containsVisibleDifference = containsVisibleDifference;
|
||||
}
|
||||
|
||||
// visibility is not decided here: `applyTo` compares the applied result
|
||||
// once layout has run, so that its effects count too
|
||||
if (!flags.containsZindexDifference) {
|
||||
flags.containsZindexDifference =
|
||||
delta.deleted.index !== delta.inserted.index;
|
||||
@@ -1706,29 +1782,96 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for visible changes regardless of whether they were removed, added or updated.
|
||||
* Whether an element looks different after the delta — and the layout that
|
||||
* followed it — was applied. The input to history's "skip entries with no
|
||||
* visible change" loop.
|
||||
*
|
||||
* Both sides are first reduced to what actually paints:
|
||||
* - a missing or deleted element paints nothing;
|
||||
* - an empty text element paints nothing either — unless it carries arrow
|
||||
* bindings (an arrow may end at it) or sits on an arrow (an empty label
|
||||
* still cuts the arrow's gap);
|
||||
* so an empty label's deletion or restoration compares as nothing against
|
||||
* nothing. Two painting elements are then compared property by property:
|
||||
* - `version`, `versionNonce`, `updated`, `created` are metadata, and
|
||||
* `index` is covered by the separate z-order check (`reorderElements`);
|
||||
* - `boundElements` is compared as `type:id` references with references to
|
||||
* empty labels dropped, so adding or removing an empty label's reference
|
||||
* is not visible on its own, and binding repair that rebuilt equal
|
||||
* objects is not either;
|
||||
* - everything else counts. Nested arrays (`points`, binding fixed points)
|
||||
* compare by reference, which can only over-report a change — the safe
|
||||
* direction, where undo stops one entry early.
|
||||
*
|
||||
* `previousElements` holds only `idsToCheck` (see `applyTo`); the label
|
||||
* lookups it serves are for elements whose labels are in that set.
|
||||
*/
|
||||
private static checkForVisibleDifference(
|
||||
element: OrderedExcalidrawElement,
|
||||
partial: ElementPartial,
|
||||
previous: OrderedExcalidrawElement | undefined,
|
||||
next: OrderedExcalidrawElement | undefined,
|
||||
previousElements: ReadonlyMap<string, OrderedExcalidrawElement>,
|
||||
nextElements: ReadonlyMap<string, OrderedExcalidrawElement>,
|
||||
) {
|
||||
if (element.isDeleted && partial.isDeleted !== false) {
|
||||
// when it's deleted and partial is not false, it cannot end up with a visible change
|
||||
return false;
|
||||
const visibleElement = (
|
||||
element: OrderedExcalidrawElement | undefined,
|
||||
elements: ReadonlyMap<string, OrderedExcalidrawElement>,
|
||||
) => {
|
||||
if (!element || element.isDeleted) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
isTextElement(element) &&
|
||||
!element.text.trim() &&
|
||||
!element.boundElements?.length &&
|
||||
!isArrowElement(elements.get(element.containerId ?? ""))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return element;
|
||||
};
|
||||
|
||||
const before = visibleElement(previous, previousElements);
|
||||
const after = visibleElement(next, nextElements);
|
||||
if (!before || !after) {
|
||||
return before !== after;
|
||||
}
|
||||
|
||||
if (element.isDeleted && partial.isDeleted === false) {
|
||||
// when we add an element, it results in a visible change
|
||||
return true;
|
||||
}
|
||||
const visibleBindings = (
|
||||
element: OrderedExcalidrawElement,
|
||||
elements: ReadonlyMap<string, OrderedExcalidrawElement>,
|
||||
) =>
|
||||
(element.boundElements ?? [])
|
||||
.filter((binding) => {
|
||||
const label = elements.get(binding.id);
|
||||
return (
|
||||
binding.type !== "text" ||
|
||||
isArrowElement(element) ||
|
||||
!label ||
|
||||
!isTextElement(label) ||
|
||||
!!label.text.trim()
|
||||
);
|
||||
})
|
||||
.map(({ id, type }) => `${type}:${id}`);
|
||||
|
||||
if (element.isDeleted === false && partial.isDeleted) {
|
||||
// when we remove an element, it results in a visible change
|
||||
return true;
|
||||
}
|
||||
|
||||
// check for any difference on a visible element
|
||||
return Delta.isRightDifferent(element, partial);
|
||||
return Delta.getDifferences(before, after).some((key) => {
|
||||
switch (key) {
|
||||
// Ordering is checked separately; the remaining fields are metadata.
|
||||
case "index":
|
||||
case "version":
|
||||
case "versionNonce":
|
||||
case "updated":
|
||||
case "created":
|
||||
return false;
|
||||
case "boundElements":
|
||||
return !isShallowEqual(
|
||||
visibleBindings(before, previousElements),
|
||||
visibleBindings(after, nextElements),
|
||||
);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1968,9 +2111,13 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
for (const element of changed.values()) {
|
||||
if (!element.isDeleted && isBindableElement(element)) {
|
||||
// TODO: with precise bindings this is quite expensive, so consider optimisation so it's only triggered when the arrow does not intersect (imprecise) element bounds
|
||||
updateBoundElements(element, scene, {
|
||||
changedElements: changed,
|
||||
});
|
||||
updateBoundElements(
|
||||
element as NonDeletedExcalidrawElement, // NOTE: Assumed correct, no runtime check for isDeleted due to performance reasons
|
||||
scene,
|
||||
{
|
||||
changedElements: changed,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
curvePointDistance,
|
||||
distanceToLineSegment,
|
||||
pointRotateRads,
|
||||
polygonIncludesPointNonZero,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
|
||||
@@ -34,6 +35,7 @@ export const distanceToElement = (
|
||||
switch (element.type) {
|
||||
case "selection":
|
||||
case "rectangle":
|
||||
case "stickynote":
|
||||
case "image":
|
||||
case "text":
|
||||
case "iframe":
|
||||
@@ -47,8 +49,9 @@ export const distanceToElement = (
|
||||
return distanceToEllipseElement(element, elementsMap, p);
|
||||
case "line":
|
||||
case "arrow":
|
||||
case "freedraw":
|
||||
return distanceToLinearOrFreeDraElement(element, elementsMap, p);
|
||||
case "freedraw":
|
||||
return distanceToFreeDrawElement(element, elementsMap, p);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -75,9 +78,7 @@ const distanceToRectanguloidElement = (
|
||||
|
||||
return Math.min(
|
||||
...sides.map((s) => distanceToLineSegment(rotatedPoint, s)),
|
||||
...corners
|
||||
.map((a) => curvePointDistance(a, rotatedPoint))
|
||||
.filter((d): d is number => d !== null),
|
||||
...corners.map((a) => curvePointDistance(a, rotatedPoint)),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -104,9 +105,7 @@ const distanceToDiamondElement = (
|
||||
|
||||
return Math.min(
|
||||
...sides.map((s) => distanceToLineSegment(rotatedPoint, s)),
|
||||
...curves
|
||||
.map((a) => curvePointDistance(a, rotatedPoint))
|
||||
.filter((d): d is number => d !== null),
|
||||
...curves.map((a) => curvePointDistance(a, rotatedPoint)),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -145,3 +144,30 @@ const distanceToLinearOrFreeDraElement = (
|
||||
...curves.map((a) => curvePointDistance(a, p)),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the distance of a point to a freedraw element.
|
||||
*
|
||||
* @param element The freedraw element
|
||||
* @param p The point to consider
|
||||
* @returns 0 if the point is within the inked area, otherwise the euclidean
|
||||
* distance to the stroke outline
|
||||
*/
|
||||
const distanceToFreeDrawElement = (
|
||||
element: ExcalidrawFreeDrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
p: GlobalPoint,
|
||||
) => {
|
||||
const [lines] = deconstructLinearOrFreeDrawElement(element, elementsMap);
|
||||
|
||||
if (lines.length === 0) {
|
||||
return Infinity;
|
||||
}
|
||||
|
||||
const polygon = lines.map((line) => line[0]);
|
||||
if (polygonIncludesPointNonZero(p, polygon)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(...lines.map((s) => distanceToLineSegment(p, s)));
|
||||
};
|
||||
|
||||
@@ -5,9 +5,11 @@ import { getCommonBoundingBox } from "./bounds";
|
||||
|
||||
import { getSelectedElementsByGroup } from "./groups";
|
||||
|
||||
import { getNonDeletedElements } from ".";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||
import type { ElementsMap, NonDeletedExcalidrawElement } from "./types";
|
||||
|
||||
export interface Distribution {
|
||||
space: "between";
|
||||
@@ -15,12 +17,12 @@ export interface Distribution {
|
||||
}
|
||||
|
||||
export const distributeElements = (
|
||||
selectedElements: ExcalidrawElement[],
|
||||
selectedElements: NonDeletedExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
distribution: Distribution,
|
||||
appState: Readonly<AppState>,
|
||||
scene: Scene,
|
||||
): ExcalidrawElement[] => {
|
||||
): NonDeletedExcalidrawElement[] => {
|
||||
const [start, mid, end, extent] =
|
||||
distribution.axis === "x"
|
||||
? (["minX", "midX", "maxX", "width"] as const)
|
||||
@@ -32,6 +34,7 @@ export const distributeElements = (
|
||||
elementsMap,
|
||||
appState,
|
||||
)
|
||||
.map(getNonDeletedElements) // Nothing to distribute on deleted elements
|
||||
.map((group) => [group, getCommonBoundingBox(group)] as const)
|
||||
.sort((a, b) => a[1][mid] - b[1][mid]);
|
||||
|
||||
|
||||
@@ -15,7 +15,11 @@ import type {
|
||||
|
||||
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { unbindBindingElement, updateBoundElements } from "./binding";
|
||||
import {
|
||||
getSimultaneouslyUpdatedElementIds,
|
||||
unbindBindingElement,
|
||||
updateBoundElements,
|
||||
} from "./binding";
|
||||
import { getCommonBounds } from "./bounds";
|
||||
import { getPerfectElementSize } from "./sizeHelpers";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
@@ -30,7 +34,7 @@ import {
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
import type { ExcalidrawElement } from "./types";
|
||||
import type { ExcalidrawElement, ExcalidrawTextElement } from "./types";
|
||||
|
||||
export const dragSelectedElements = (
|
||||
pointerDownState: PointerDownState,
|
||||
@@ -103,23 +107,14 @@ export const dragSelectedElements = (
|
||||
gridSize,
|
||||
);
|
||||
|
||||
const elementsToUpdateIds = new Set(
|
||||
Array.from(elementsToUpdate, (el) => el.id),
|
||||
// one array for the whole update, so that every element's bound-arrow
|
||||
// update and the unbinding checks share its cached id set
|
||||
const elementsToUpdateArray = Array.from(elementsToUpdate);
|
||||
const elementsToUpdateIds = getSimultaneouslyUpdatedElementIds(
|
||||
elementsToUpdateArray,
|
||||
);
|
||||
|
||||
elementsToUpdate.forEach((element) => {
|
||||
const isArrow = !isArrowElement(element);
|
||||
const isStartBoundElementSelected =
|
||||
isArrow ||
|
||||
(element.startBinding
|
||||
? elementsToUpdateIds.has(element.startBinding.elementId)
|
||||
: false);
|
||||
const isEndBoundElementSelected =
|
||||
isArrow ||
|
||||
(element.endBinding
|
||||
? elementsToUpdateIds.has(element.endBinding.elementId)
|
||||
: false);
|
||||
|
||||
if (!isArrowElement(element)) {
|
||||
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
|
||||
|
||||
@@ -137,13 +132,12 @@ export const dragSelectedElements = (
|
||||
);
|
||||
}
|
||||
updateBoundElements(element, scene, {
|
||||
simultaneouslyUpdated: Array.from(elementsToUpdate),
|
||||
simultaneouslyUpdated: elementsToUpdateArray,
|
||||
});
|
||||
} else if (
|
||||
// NOTE: Add a little initial drag to the arrow dragging when the arrow
|
||||
// is the single element being dragged to avoid accidentally unbinding
|
||||
// the arrow when the user just wants to select it.
|
||||
|
||||
elementsToUpdate.size > 1 ||
|
||||
Math.max(Math.abs(adjustedOffset.x), Math.abs(adjustedOffset.y)) >
|
||||
DRAGGING_THRESHOLD ||
|
||||
@@ -151,9 +145,12 @@ export const dragSelectedElements = (
|
||||
) {
|
||||
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
|
||||
|
||||
const shouldUnbindStart =
|
||||
element.startBinding && !isStartBoundElementSelected;
|
||||
const shouldUnbindEnd = element.endBinding && !isEndBoundElementSelected;
|
||||
const shouldUnbindStart = element.startBinding
|
||||
? !elementsToUpdateIds.has(element.startBinding.elementId)
|
||||
: true;
|
||||
const shouldUnbindEnd = element.endBinding
|
||||
? !elementsToUpdateIds.has(element.endBinding.elementId)
|
||||
: true;
|
||||
if (shouldUnbindStart || shouldUnbindEnd) {
|
||||
// NOTE: Moving the bound arrow should unbind it, otherwise we would
|
||||
// have weird situations, like 0 lenght arrow when the user moves
|
||||
@@ -203,7 +200,7 @@ const calculateOffset = (
|
||||
|
||||
const updateElementCoords = (
|
||||
pointerDownState: PointerDownState,
|
||||
element: NonDeletedExcalidrawElement,
|
||||
element: ExcalidrawElement,
|
||||
scene: Scene,
|
||||
dragOffset: { x: number; y: number },
|
||||
) => {
|
||||
@@ -228,6 +225,72 @@ export const getDragOffsetXY = (
|
||||
return [x - x1, y - y1];
|
||||
};
|
||||
|
||||
/**
|
||||
* Sizes a text element as it is dragged out.
|
||||
*
|
||||
* A dragged text pins one point and grows away from it; `anchorRatio` says
|
||||
* where along the box that point sits — 0 for its left edge, 1 for its right,
|
||||
* 0.5 for its centre.
|
||||
*
|
||||
* A free text pins the point the drag started from and takes the ratio from
|
||||
* the drag direction, so it can be pulled either way. A text bound to an arrow
|
||||
* endpoint instead pins whatever the binding placed it against and takes the
|
||||
* ratio from its alignment — which is also what keeps it from growing back
|
||||
* over the arrow, since dragging that way makes no progress rather than
|
||||
* flipping the box around.
|
||||
*/
|
||||
export const dragNewTextElement = ({
|
||||
newElement,
|
||||
anchorX,
|
||||
anchorRatio,
|
||||
pointerX,
|
||||
nextY,
|
||||
zoom,
|
||||
scene,
|
||||
informMutation = true,
|
||||
}: {
|
||||
newElement: ExcalidrawTextElement;
|
||||
anchorX: number;
|
||||
/** 0 = anchored by its left edge, 1 = by its right, 0.5 = by its centre */
|
||||
anchorRatio: number;
|
||||
pointerX: number;
|
||||
/** free text re-tops itself to the drag origin; a bound one must not move */
|
||||
nextY?: number;
|
||||
zoom: NormalizedZoomValue;
|
||||
scene: Scene;
|
||||
informMutation?: boolean;
|
||||
}) => {
|
||||
const offset = pointerX - anchorX;
|
||||
|
||||
// how far the pointer has travelled away from the anchor along the direction
|
||||
// the box may grow — negative once it heads back the other way
|
||||
const reach =
|
||||
anchorRatio === 0 ? offset : anchorRatio === 1 ? -offset : Math.abs(offset);
|
||||
|
||||
const width = Math.max(
|
||||
// a centred box grows on both sides, so it widens at twice the reach
|
||||
anchorRatio === 0.5 ? reach * 2 : reach,
|
||||
getMinTextElementWidth(
|
||||
getFontString({
|
||||
fontSize: newElement.fontSize,
|
||||
fontFamily: newElement.fontFamily,
|
||||
}),
|
||||
newElement.lineHeight,
|
||||
),
|
||||
);
|
||||
|
||||
scene.mutateElement(
|
||||
newElement,
|
||||
{
|
||||
x: anchorX - width * anchorRatio,
|
||||
...(nextY === undefined ? {} : { y: nextY }),
|
||||
width,
|
||||
...(reach > TEXT_AUTOWRAP_THRESHOLD / zoom ? { autoResize: false } : {}),
|
||||
},
|
||||
{ informMutation, isDragging: false },
|
||||
);
|
||||
};
|
||||
|
||||
export const dragNewElement = ({
|
||||
newElement,
|
||||
elementType,
|
||||
@@ -293,6 +356,22 @@ export const dragNewElement = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (isTextElement(newElement)) {
|
||||
// a text is only ever sized horizontally — its height follows the wrapped
|
||||
// content — so it grows away from the point the drag started at
|
||||
dragNewTextElement({
|
||||
newElement,
|
||||
anchorX: originX + (originOffset?.x ?? 0),
|
||||
anchorRatio: shouldResizeFromCenter ? 0.5 : x < originX ? 1 : 0,
|
||||
pointerX: x,
|
||||
nextY: originY + (originOffset?.y ?? 0),
|
||||
zoom,
|
||||
scene,
|
||||
informMutation,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let newX = x < originX ? originX - width : originX;
|
||||
let newY = y < originY ? originY - height : originY;
|
||||
|
||||
@@ -303,31 +382,6 @@ export const dragNewElement = ({
|
||||
newY = originY - height / 2;
|
||||
}
|
||||
|
||||
let textAutoResize = null;
|
||||
|
||||
if (isTextElement(newElement)) {
|
||||
height = newElement.height;
|
||||
const minWidth = getMinTextElementWidth(
|
||||
getFontString({
|
||||
fontSize: newElement.fontSize,
|
||||
fontFamily: newElement.fontFamily,
|
||||
}),
|
||||
newElement.lineHeight,
|
||||
);
|
||||
width = Math.max(width, minWidth);
|
||||
|
||||
if (Math.abs(x - originX) > TEXT_AUTOWRAP_THRESHOLD / zoom) {
|
||||
textAutoResize = {
|
||||
autoResize: false,
|
||||
};
|
||||
}
|
||||
|
||||
newY = originY;
|
||||
if (shouldResizeFromCenter) {
|
||||
newX = originX - width / 2;
|
||||
}
|
||||
}
|
||||
|
||||
if (width !== 0 && height !== 0) {
|
||||
let imageInitialDimension = null;
|
||||
if (isImageElement(newElement)) {
|
||||
@@ -344,7 +398,6 @@ export const dragNewElement = ({
|
||||
y: newY + (originOffset?.y ?? 0),
|
||||
width,
|
||||
height,
|
||||
...textAutoResize,
|
||||
...imageInitialDimension,
|
||||
},
|
||||
{ informMutation, isDragging: false },
|
||||
|
||||
@@ -30,6 +30,7 @@ import { bumpVersion } from "./mutateElement";
|
||||
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isArrowElement,
|
||||
isBoundToContainer,
|
||||
isFrameLikeElement,
|
||||
} from "./typeChecks";
|
||||
@@ -38,13 +39,54 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
|
||||
import { fixDuplicatedBindingsAfterDuplication } from "./binding";
|
||||
|
||||
import { ShapeCache } from "./shape";
|
||||
|
||||
import { isNonDeletedElement } from ".";
|
||||
|
||||
import type { ElementUpdate } from "./mutateElement";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElement,
|
||||
GroupId,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Lookups supplied to the host's `props.onDuplicate`, covering just the
|
||||
* elements taking part in the duplication.
|
||||
*/
|
||||
export type OnDuplicateData = {
|
||||
/** the duplicates, by their id */
|
||||
duplicateElements: ReadonlyMap<ExcalidrawElement["id"], ExcalidrawElement>;
|
||||
/**
|
||||
* The elements the duplicates were made from, by their id.
|
||||
*
|
||||
* On paste and library insert these are the inserted elements, which aren't
|
||||
* part of the scene (though they may share ids with the scene elements they
|
||||
* were copied from).
|
||||
*/
|
||||
originalElements: ReadonlyMap<ExcalidrawElement["id"], ExcalidrawElement>;
|
||||
/**
|
||||
* id of an original -> id of its duplicate (e.g. to remap element ids you
|
||||
* keep in `customData`, which the duplicate copied from its original)
|
||||
*/
|
||||
origIdToDuplicateId: ReadonlyMap<
|
||||
ExcalidrawElement["id"],
|
||||
ExcalidrawElement["id"]
|
||||
>;
|
||||
/**
|
||||
* id of a duplicate -> id of its original (e.g. to look up the original in
|
||||
* `originalElements` while modifying the duplicate)
|
||||
*/
|
||||
duplicateIdToOrigId: ReadonlyMap<
|
||||
ExcalidrawElement["id"],
|
||||
ExcalidrawElement["id"]
|
||||
>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Duplicate an element, often used in the alt-drag operation.
|
||||
* Note that this method has gotten a bit complicated since the
|
||||
@@ -72,6 +114,7 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
|
||||
|
||||
copy.id = randomId();
|
||||
copy.updated = getUpdatedTimestamp();
|
||||
copy.created = copy.updated;
|
||||
if (randomizeSeed) {
|
||||
copy.seed = randomInteger();
|
||||
bumpVersion(copy);
|
||||
@@ -111,6 +154,9 @@ export const duplicateElements = (
|
||||
* user interaction.
|
||||
*/
|
||||
type: "everything";
|
||||
// TODO remove/review this once we add frame children order migration
|
||||
// and invariant checks
|
||||
preserveFrameChildrenOrder?: boolean;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
@@ -154,22 +200,25 @@ export const duplicateElements = (
|
||||
// loop over them.
|
||||
const processedIds = new Map<ExcalidrawElement["id"], true>();
|
||||
const groupIdMap = new Map();
|
||||
const duplicatedElements: ExcalidrawElement[] = [];
|
||||
const duplicatedElements: NonDeletedExcalidrawElement[] = [];
|
||||
const origElements: ExcalidrawElement[] = [];
|
||||
const origIdToDuplicateId = new Map<
|
||||
ExcalidrawElement["id"],
|
||||
ExcalidrawElement["id"]
|
||||
>();
|
||||
const duplicateIdToOrigElement = new Map<
|
||||
const duplicateIdToOrigId = new Map<
|
||||
ExcalidrawElement["id"],
|
||||
ExcalidrawElement
|
||||
ExcalidrawElement["id"]
|
||||
>();
|
||||
const duplicateElementsMap = new Map<string, ExcalidrawElement>();
|
||||
const duplicateElementsMap = new Map<string, NonDeletedExcalidrawElement>();
|
||||
const origElementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>();
|
||||
const elementsMap = arrayToMap(elements) as ElementsMap;
|
||||
const _idsOfElementsToDuplicate =
|
||||
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") {
|
||||
@@ -204,18 +253,27 @@ export const duplicateElements = (
|
||||
|
||||
processedIds.set(element.id, true);
|
||||
|
||||
// SAFETY: this should never happen, but we
|
||||
// want to make sure we log it if it does
|
||||
if (!isNonDeletedElement(element)) {
|
||||
console.error(
|
||||
"[NONDELETED][INVARIANT] Element to duplicate should be non-deleted",
|
||||
);
|
||||
}
|
||||
|
||||
const newElement = duplicateElement(
|
||||
appState.editingGroupId,
|
||||
groupIdMap,
|
||||
element,
|
||||
opts.randomizeSeed,
|
||||
);
|
||||
) as NonDeletedExcalidrawElement;
|
||||
|
||||
processedIds.set(newElement.id, true);
|
||||
|
||||
duplicateElementsMap.set(newElement.id, newElement);
|
||||
origElementsMap.set(element.id, element);
|
||||
origIdToDuplicateId.set(element.id, newElement.id);
|
||||
duplicateIdToOrigElement.set(newElement.id, element);
|
||||
duplicateIdToOrigId.set(newElement.id, element.id);
|
||||
|
||||
origElements.push(element);
|
||||
duplicatedElements.push(newElement);
|
||||
@@ -250,6 +308,9 @@ export const duplicateElements = (
|
||||
elementsWithDuplicates.splice(index + 1, 0, ...castArray(elements));
|
||||
};
|
||||
|
||||
// main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const frameIdsToDuplicate = new Set(
|
||||
elements
|
||||
.filter(
|
||||
@@ -274,7 +335,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 +351,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) => {
|
||||
@@ -379,7 +452,9 @@ export const duplicateElements = (
|
||||
|
||||
if (opts.overrides) {
|
||||
for (const duplicateElement of duplicatedElements) {
|
||||
const origElement = duplicateIdToOrigElement.get(duplicateElement.id);
|
||||
const origElement = origElementsMap.get(
|
||||
duplicateIdToOrigId.get(duplicateElement.id)!,
|
||||
);
|
||||
if (origElement) {
|
||||
Object.assign(
|
||||
duplicateElement,
|
||||
@@ -396,11 +471,150 @@ export const duplicateElements = (
|
||||
return {
|
||||
duplicatedElements,
|
||||
duplicateElementsMap,
|
||||
origElementsMap,
|
||||
elementsWithDuplicates,
|
||||
origIdToDuplicateId,
|
||||
duplicateIdToOrigId,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Folds the elements returned by the host (`props.onDuplicate`) back into the
|
||||
* duplicates the editor created, so that everything that follows (frame
|
||||
* assignment, bound text redraw, selection, alt-drag handover) can keep
|
||||
* working with the editor's own objects, whether the host mutated the
|
||||
* duplicates or returned new objects for them.
|
||||
*
|
||||
* - A returned element with a duplicate's id is shallow-merged into that
|
||||
* duplicate, which takes its place in the returned array. Properties the
|
||||
* host omits are kept, so that a partial element can't invalidate the
|
||||
* duplicate. Safe only because the duplicates are fresh (not in the scene or
|
||||
* the store snapshot yet), which is why nothing but the passed duplicates is
|
||||
* ever merged into. Since the merge goes around `mutateElement`, what may
|
||||
* have been cached for the duplicate by then is invalidated here.
|
||||
* - A duplicate missing from the returned array (or returned as deleted) is
|
||||
* vetoed. So is the bound text of a vetoed container. What the remaining
|
||||
* duplicates reference of the vetoed ones is cleared, as if those were never
|
||||
* part of the duplication (see `fixDuplicatedBindingsAfterDuplication`).
|
||||
* - Any other returned element is used as is (existing elements must not be
|
||||
* mutated, so the host replaces them).
|
||||
* - `false` vetoes all the duplicates.
|
||||
*
|
||||
* @returns next elements, and the duplicates that weren't vetoed
|
||||
*/
|
||||
export const reconcileDuplicatedElements = <
|
||||
TDuplicate extends ExcalidrawElement,
|
||||
>(
|
||||
/** what the host returned from `props.onDuplicate`, if anything */
|
||||
hostElements: readonly ExcalidrawElement[] | void | false,
|
||||
/** elements that were passed to the host */
|
||||
nextElements: ExcalidrawElement[],
|
||||
duplicatedElements: TDuplicate[],
|
||||
): {
|
||||
elements: ExcalidrawElement[];
|
||||
duplicatedElements: TDuplicate[];
|
||||
} => {
|
||||
if (hostElements === false) {
|
||||
return { elements: nextElements, duplicatedElements: [] };
|
||||
}
|
||||
|
||||
if (!hostElements) {
|
||||
return { elements: nextElements, duplicatedElements };
|
||||
}
|
||||
|
||||
const duplicatesMap = arrayToMap(duplicatedElements);
|
||||
// (if a duplicate is returned more than once, the last one wins)
|
||||
const hostDuplicates = new Map<ExcalidrawElement["id"], ExcalidrawElement>();
|
||||
|
||||
for (const element of hostElements) {
|
||||
if (duplicatesMap.has(element.id)) {
|
||||
hostDuplicates.set(element.id, element);
|
||||
}
|
||||
}
|
||||
|
||||
// merge first, so that everything below sees the duplicates as the host
|
||||
// wants them (what the host returned may be partial)
|
||||
const survivedIds = new Set<ExcalidrawElement["id"]>();
|
||||
|
||||
for (const [id, element] of hostDuplicates) {
|
||||
const duplicate = duplicatesMap.get(id)!;
|
||||
|
||||
if (element !== duplicate) {
|
||||
Object.assign(duplicate, element);
|
||||
// The duplicate may have been measured already (e.g. to resolve the
|
||||
// frame it's pasted into), and we're going around `mutateElement`.
|
||||
// The shape is cached by identity, the bounds by version.
|
||||
ShapeCache.delete(duplicate);
|
||||
bumpVersion(duplicate);
|
||||
}
|
||||
|
||||
if (!duplicate.isDeleted) {
|
||||
survivedIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
const isVetoed = (id: ExcalidrawElement["id"]) =>
|
||||
duplicatesMap.has(id) && !survivedIds.has(id);
|
||||
|
||||
for (const id of survivedIds) {
|
||||
const duplicate = duplicatesMap.get(id)!;
|
||||
if (isBoundToContainer(duplicate) && isVetoed(duplicate.containerId)) {
|
||||
survivedIds.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
const elements: ExcalidrawElement[] = [];
|
||||
|
||||
for (const element of hostElements) {
|
||||
const duplicate = duplicatesMap.get(element.id);
|
||||
|
||||
if (!duplicate) {
|
||||
elements.push(element);
|
||||
} else if (
|
||||
survivedIds.has(element.id) &&
|
||||
hostDuplicates.get(element.id) === element
|
||||
) {
|
||||
elements.push(duplicate);
|
||||
}
|
||||
}
|
||||
|
||||
if (survivedIds.size === duplicatedElements.length) {
|
||||
return { elements, duplicatedElements };
|
||||
}
|
||||
|
||||
const survivedDuplicates = duplicatedElements.filter((duplicate) =>
|
||||
survivedIds.has(duplicate.id),
|
||||
);
|
||||
|
||||
for (const duplicate of survivedDuplicates) {
|
||||
const updates: Mutable<ElementUpdate<ExcalidrawArrowElement>> = {};
|
||||
|
||||
if (duplicate.boundElements?.some((binding) => isVetoed(binding.id))) {
|
||||
updates.boundElements = duplicate.boundElements.filter(
|
||||
(binding) => !isVetoed(binding.id),
|
||||
);
|
||||
}
|
||||
if (duplicate.frameId && isVetoed(duplicate.frameId)) {
|
||||
updates.frameId = null;
|
||||
}
|
||||
if (isArrowElement(duplicate)) {
|
||||
if (
|
||||
duplicate.startBinding &&
|
||||
isVetoed(duplicate.startBinding.elementId)
|
||||
) {
|
||||
updates.startBinding = null;
|
||||
}
|
||||
if (duplicate.endBinding && isVetoed(duplicate.endBinding.elementId)) {
|
||||
updates.endBinding = null;
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(duplicate, updates);
|
||||
}
|
||||
|
||||
return { elements, duplicatedElements: survivedDuplicates };
|
||||
};
|
||||
|
||||
// Simplified deep clone for the purpose of cloning ExcalidrawElement.
|
||||
//
|
||||
// Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
getSizeFromPoints,
|
||||
isDevEnv,
|
||||
arrayToMap,
|
||||
DEFAULT_ZOOM,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
@@ -30,8 +31,7 @@ import {
|
||||
getHeadingForElbowArrowSnap,
|
||||
getGlobalFixedPointForBindableElement,
|
||||
getBindingGap,
|
||||
maxBindingDistance_simple,
|
||||
BASE_BINDING_GAP_ELBOW,
|
||||
BASE_BINDING_GAP,
|
||||
} from "./binding";
|
||||
import { distanceToElement } from "./distance";
|
||||
import {
|
||||
@@ -1217,24 +1217,20 @@ const getElbowArrowData = (
|
||||
GlobalPoint
|
||||
>(nextPoints[nextPoints.length - 1], vector(arrow.x, arrow.y));
|
||||
|
||||
// NOTE: updates coming through `mutateElement` don't carry the zoom, so
|
||||
// those fall back to the zoom=1 binding distance
|
||||
const zoom = options?.zoom ?? DEFAULT_ZOOM;
|
||||
|
||||
let hoveredStartElement = null;
|
||||
let hoveredEndElement = null;
|
||||
if (options?.isDragging && options?.isBindingEnabled !== false) {
|
||||
const elements = Array.from(elementsMap.values());
|
||||
hoveredStartElement =
|
||||
getHoveredElement(
|
||||
origStartGlobalPoint,
|
||||
elementsMap,
|
||||
elements,
|
||||
options?.zoom,
|
||||
) || null;
|
||||
getHoveredElement(origStartGlobalPoint, elementsMap, elements, zoom) ||
|
||||
null;
|
||||
hoveredEndElement =
|
||||
getHoveredElement(
|
||||
origEndGlobalPoint,
|
||||
elementsMap,
|
||||
elements,
|
||||
options?.zoom,
|
||||
) || null;
|
||||
getHoveredElement(origEndGlobalPoint, elementsMap, elements, zoom) ||
|
||||
null;
|
||||
} else {
|
||||
hoveredStartElement = arrow.startBinding
|
||||
? getBindableElementForId(arrow.startBinding.elementId, elementsMap) ||
|
||||
@@ -1256,6 +1252,7 @@ const getElbowArrowData = (
|
||||
"start",
|
||||
arrow.startBinding?.fixedPoint,
|
||||
origStartGlobalPoint,
|
||||
zoom,
|
||||
hoveredStartElement,
|
||||
elementsMap,
|
||||
options?.isDragging,
|
||||
@@ -1273,6 +1270,7 @@ const getElbowArrowData = (
|
||||
"end",
|
||||
arrow.endBinding?.fixedPoint,
|
||||
origEndGlobalPoint,
|
||||
zoom,
|
||||
hoveredEndElement,
|
||||
elementsMap,
|
||||
options?.isDragging,
|
||||
@@ -1285,7 +1283,7 @@ const getElbowArrowData = (
|
||||
hoveredStartElement,
|
||||
origStartGlobalPoint,
|
||||
elementsMap,
|
||||
options?.zoom,
|
||||
zoom,
|
||||
);
|
||||
const endHeading = getBindPointHeading(
|
||||
endGlobalPoint,
|
||||
@@ -1293,7 +1291,7 @@ const getElbowArrowData = (
|
||||
hoveredEndElement,
|
||||
origEndGlobalPoint,
|
||||
elementsMap,
|
||||
options?.zoom,
|
||||
zoom,
|
||||
);
|
||||
const startPointBounds = [
|
||||
startGlobalPoint[0] - 2,
|
||||
@@ -1314,8 +1312,8 @@ const getElbowArrowData = (
|
||||
offsetFromHeading(
|
||||
startHeading,
|
||||
arrow.startArrowhead
|
||||
? getBindingGap(hoveredStartElement, { elbowed: true }) * 6
|
||||
: getBindingGap(hoveredStartElement, { elbowed: true }) * 2,
|
||||
? getBindingGap(hoveredStartElement) * 6
|
||||
: getBindingGap(hoveredStartElement) * 2,
|
||||
1,
|
||||
),
|
||||
)
|
||||
@@ -1327,8 +1325,8 @@ const getElbowArrowData = (
|
||||
offsetFromHeading(
|
||||
endHeading,
|
||||
arrow.endArrowhead
|
||||
? getBindingGap(hoveredEndElement, { elbowed: true }) * 6
|
||||
: getBindingGap(hoveredEndElement, { elbowed: true }) * 2,
|
||||
? getBindingGap(hoveredEndElement) * 6
|
||||
: getBindingGap(hoveredEndElement) * 2,
|
||||
1,
|
||||
),
|
||||
)
|
||||
@@ -1375,8 +1373,8 @@ const getElbowArrowData = (
|
||||
? 0
|
||||
: BASE_PADDING -
|
||||
(arrow.startArrowhead
|
||||
? BASE_BINDING_GAP_ELBOW * 6
|
||||
: BASE_BINDING_GAP_ELBOW * 2),
|
||||
? BASE_BINDING_GAP * 6
|
||||
: BASE_BINDING_GAP * 2),
|
||||
BASE_PADDING,
|
||||
),
|
||||
boundsOverlap
|
||||
@@ -1391,8 +1389,8 @@ const getElbowArrowData = (
|
||||
? 0
|
||||
: BASE_PADDING -
|
||||
(arrow.endArrowhead
|
||||
? BASE_BINDING_GAP_ELBOW * 6
|
||||
: BASE_BINDING_GAP_ELBOW * 2),
|
||||
? BASE_BINDING_GAP * 6
|
||||
: BASE_BINDING_GAP * 2),
|
||||
BASE_PADDING,
|
||||
),
|
||||
boundsOverlap,
|
||||
@@ -2124,8 +2122,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(
|
||||
@@ -2218,6 +2216,7 @@ const getGlobalPoint = (
|
||||
startOrEnd: "start" | "end",
|
||||
fixedPointRatio: [number, number] | undefined | null,
|
||||
initialPoint: GlobalPoint,
|
||||
zoom: AppState["zoom"],
|
||||
element?: ExcalidrawBindableElement | null,
|
||||
elementsMap?: ElementsMap,
|
||||
isDragging?: boolean,
|
||||
@@ -2231,6 +2230,7 @@ const getGlobalPoint = (
|
||||
element,
|
||||
startOrEnd,
|
||||
elementsMap,
|
||||
zoom,
|
||||
undefined,
|
||||
isMidpointSnappingEnabled,
|
||||
);
|
||||
@@ -2282,14 +2282,9 @@ const getHoveredElement = (
|
||||
origPoint: GlobalPoint,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
zoom?: AppState["zoom"],
|
||||
zoom: AppState["zoom"],
|
||||
) => {
|
||||
return getHoveredElementForBinding(
|
||||
origPoint,
|
||||
elements,
|
||||
elementsMap,
|
||||
maxBindingDistance_simple(zoom),
|
||||
);
|
||||
return getHoveredElementForBinding(origPoint, elements, elementsMap, zoom);
|
||||
};
|
||||
|
||||
const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean =>
|
||||
|
||||
@@ -13,9 +13,9 @@ import { wrapText } from "./textWrapping";
|
||||
import { isIframeElement } from "./typeChecks";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawIframeLikeElement,
|
||||
IframeData,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
|
||||
type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
|
||||
@@ -23,7 +23,7 @@ type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
|
||||
const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
|
||||
|
||||
const RE_YOUTUBE =
|
||||
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)/;
|
||||
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|live\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)/;
|
||||
|
||||
const RE_VIMEO =
|
||||
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
|
||||
@@ -401,7 +401,7 @@ export const getEmbedLink = (
|
||||
|
||||
export const createPlaceholderEmbeddableLabel = (
|
||||
element: ExcalidrawIframeLikeElement,
|
||||
): ExcalidrawElement => {
|
||||
): NonDeletedExcalidrawElement => {
|
||||
let text: string;
|
||||
if (isIframeElement(element)) {
|
||||
text = "IFrame element";
|
||||
|
||||
+362
-316
@@ -1,6 +1,11 @@
|
||||
import { KEYS, invariant, toBrandedType } from "@excalidraw/common";
|
||||
import { invariant, toBrandedType, type Bounds } from "@excalidraw/common";
|
||||
|
||||
import { type GlobalPoint, pointFrom, type LocalPoint } from "@excalidraw/math";
|
||||
import {
|
||||
clamp,
|
||||
type GlobalPoint,
|
||||
pointFrom,
|
||||
type LocalPoint,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
AppState,
|
||||
@@ -20,7 +25,11 @@ import {
|
||||
} from "./heading";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { newArrowElement, newElement } from "./newElement";
|
||||
import {
|
||||
newArrowElement,
|
||||
newElement,
|
||||
newStickyNoteElement,
|
||||
} from "./newElement";
|
||||
import { aabbForElement } from "./bounds";
|
||||
import { elementsAreInFrameBounds, elementOverlapsWithFrame } from "./frame";
|
||||
import {
|
||||
@@ -30,10 +39,12 @@ import {
|
||||
isFlowchartNodeElement,
|
||||
} from "./typeChecks";
|
||||
import {
|
||||
type NonDeleted,
|
||||
type ElementsMap,
|
||||
type ExcalidrawBindableElement,
|
||||
type ExcalidrawElement,
|
||||
type ExcalidrawFlowchartNodeElement,
|
||||
type NonDeletedExcalidrawElement,
|
||||
type NonDeletedSceneElementsMap,
|
||||
type Ordered,
|
||||
type OrderedExcalidrawElement,
|
||||
@@ -41,318 +52,247 @@ import {
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
type LinkDirection = "up" | "right" | "down" | "left";
|
||||
export type LinkDirection = "up" | "right" | "down" | "left";
|
||||
|
||||
const VERTICAL_OFFSET = 100;
|
||||
const HORIZONTAL_OFFSET = 100;
|
||||
|
||||
export const getLinkDirectionFromKey = (key: string): LinkDirection => {
|
||||
switch (key) {
|
||||
case KEYS.ARROW_UP:
|
||||
return "up";
|
||||
case KEYS.ARROW_DOWN:
|
||||
return "down";
|
||||
case KEYS.ARROW_RIGHT:
|
||||
return "right";
|
||||
case KEYS.ARROW_LEFT:
|
||||
return "left";
|
||||
default:
|
||||
return "right";
|
||||
type Interval = { start: number; end: number };
|
||||
|
||||
const mergeIntervals = (intervals: Interval[]): Interval[] => {
|
||||
const sorted = [...intervals].sort((a, b) => a.start - b.start);
|
||||
const merged: Interval[] = [];
|
||||
|
||||
for (const interval of sorted) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && interval.start <= last.end) {
|
||||
last.end = Math.max(last.end, interval.end);
|
||||
} else {
|
||||
merged.push({ ...interval });
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
};
|
||||
|
||||
const getNodeRelatives = (
|
||||
type: "predecessors" | "successors",
|
||||
const intervalIsFree = (start: number, size: number, occupied: Interval[]) =>
|
||||
occupied.every((o) => start + size <= o.start || start >= o.end);
|
||||
|
||||
// Nearest `start` for a segment of `size` avoiding every occupied interval,
|
||||
// searching both sides of `ideal`; a tie resolves toward the positive side.
|
||||
const findNearestFreeSlot = (
|
||||
ideal: number,
|
||||
size: number,
|
||||
occupied: Interval[],
|
||||
): number => {
|
||||
if (intervalIsFree(ideal, size, occupied)) {
|
||||
return ideal;
|
||||
}
|
||||
|
||||
const gapStarts = [-Infinity, ...occupied.map((o) => o.end)];
|
||||
const gapEnds = [...occupied.map((o) => o.start), Infinity];
|
||||
|
||||
let best = ideal;
|
||||
let bestDistance = Infinity;
|
||||
for (let i = 0; i < gapStarts.length; i++) {
|
||||
if (gapEnds[i] - gapStarts[i] < size) {
|
||||
continue;
|
||||
}
|
||||
const start = clamp(ideal, gapStarts[i], gapEnds[i] - size);
|
||||
const distance = Math.abs(start - ideal);
|
||||
if (distance <= bestDistance) {
|
||||
best = start;
|
||||
bestDistance = distance;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
};
|
||||
|
||||
// Walk the arrow bindings to collect every node that belongs to the same
|
||||
// flowchart as `node` — the whole connected component acts as the obstacle
|
||||
// set during placement (#8518).
|
||||
const getConnectedFlowchartNodes = (
|
||||
node: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
direction: LinkDirection,
|
||||
) => {
|
||||
const items = [...elementsMap.values()].reduce(
|
||||
(acc: { relative: ExcalidrawBindableElement; heading: Heading }[], el) => {
|
||||
let oppositeBinding;
|
||||
if (
|
||||
isElbowArrow(el) &&
|
||||
// we want check existence of the opposite binding, in the direction
|
||||
// we're interested in
|
||||
(oppositeBinding =
|
||||
el[type === "predecessors" ? "startBinding" : "endBinding"]) &&
|
||||
// similarly, we need to filter only arrows bound to target node
|
||||
el[type === "predecessors" ? "endBinding" : "startBinding"]
|
||||
?.elementId === node.id
|
||||
) {
|
||||
const relative = elementsMap.get(oppositeBinding.elementId);
|
||||
): ExcalidrawBindableElement[] => {
|
||||
const arrows = [...elementsMap.values()].filter(isElbowArrow);
|
||||
const visited = new Set<string>([node.id]);
|
||||
const queue: string[] = [node.id];
|
||||
const connected: ExcalidrawBindableElement[] = [];
|
||||
|
||||
if (!relative) {
|
||||
return acc;
|
||||
}
|
||||
while (queue.length > 0) {
|
||||
const currentId = queue.shift()!;
|
||||
for (const arrow of arrows) {
|
||||
const startId = arrow.startBinding?.elementId;
|
||||
const endId = arrow.endBinding?.elementId;
|
||||
|
||||
invariant(
|
||||
isBindableElement(relative),
|
||||
"not an ExcalidrawBindableElement",
|
||||
);
|
||||
|
||||
const edgePoint = (
|
||||
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
|
||||
) as Readonly<LocalPoint>;
|
||||
|
||||
const heading = headingForPointFromElement(
|
||||
node,
|
||||
aabbForElement(node, elementsMap),
|
||||
[edgePoint[0] + el.x, edgePoint[1] + el.y] as Readonly<GlobalPoint>,
|
||||
);
|
||||
|
||||
acc.push({
|
||||
relative,
|
||||
heading,
|
||||
});
|
||||
let neighborId: string | undefined;
|
||||
if (startId === currentId) {
|
||||
neighborId = endId;
|
||||
} else if (endId === currentId) {
|
||||
neighborId = startId;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
|
||||
if (!neighborId || visited.has(neighborId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
visited.add(neighborId);
|
||||
const neighbor = elementsMap.get(neighborId);
|
||||
if (neighbor && isBindableElement(neighbor)) {
|
||||
connected.push(neighbor);
|
||||
queue.push(neighborId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return connected;
|
||||
};
|
||||
|
||||
// Place a cluster of `count` equally-sized nodes next to `parent`:
|
||||
// - the primary axis (the creation direction) is fixed at exactly one gap
|
||||
// away from the parent, forming a search band the cluster will occupy
|
||||
// - along the cross axis the cluster slides into the free slot nearest the
|
||||
// parent-centered ideal, treating band obstacles as immovable
|
||||
// - `stickyCrossStart` anchors an already-visible pending cluster: growing it
|
||||
// extends it at either end so the existing pending nodes keep their
|
||||
// positions, unless the grown cluster no longer fits there
|
||||
const placeCluster = (
|
||||
parent: ExcalidrawFlowchartNodeElement,
|
||||
direction: LinkDirection,
|
||||
count: number,
|
||||
obstacles: readonly Bounds[],
|
||||
stickyCrossStart: number | null,
|
||||
): { positions: { x: number; y: number }[]; crossStart: number } => {
|
||||
const horizontal = direction === "left" || direction === "right";
|
||||
// INSIGHT: new nodes copy the parent's dimensions
|
||||
const nodePrimarySize = horizontal ? parent.width : parent.height;
|
||||
const nodeCrossSize = horizontal ? parent.height : parent.width;
|
||||
const primaryGap = horizontal ? HORIZONTAL_OFFSET : VERTICAL_OFFSET;
|
||||
const crossGap = horizontal ? VERTICAL_OFFSET : HORIZONTAL_OFFSET;
|
||||
|
||||
const parentPrimaryStart = horizontal ? parent.x : parent.y;
|
||||
const parentCrossCenter = horizontal
|
||||
? parent.y + parent.height / 2
|
||||
: parent.x + parent.width / 2;
|
||||
|
||||
const primaryStart =
|
||||
direction === "right" || direction === "down"
|
||||
? parentPrimaryStart + nodePrimarySize + primaryGap
|
||||
: parentPrimaryStart - primaryGap - nodePrimarySize;
|
||||
|
||||
// cross-axis intervals of the obstacles sharing the band, inflated so the
|
||||
// cluster keeps at least one gap of clearance
|
||||
const occupied = mergeIntervals(
|
||||
obstacles
|
||||
.filter((bounds) => {
|
||||
const start = horizontal ? bounds[0] : bounds[1];
|
||||
const end = horizontal ? bounds[2] : bounds[3];
|
||||
return start < primaryStart + nodePrimarySize && end > primaryStart;
|
||||
})
|
||||
.map((bounds) => ({
|
||||
start: (horizontal ? bounds[1] : bounds[0]) - crossGap,
|
||||
end: (horizontal ? bounds[3] : bounds[2]) + crossGap,
|
||||
})),
|
||||
);
|
||||
|
||||
switch (direction) {
|
||||
case "up":
|
||||
return items
|
||||
.filter((item) => compareHeading(item.heading, HEADING_UP))
|
||||
.map((item) => item.relative);
|
||||
case "down":
|
||||
return items
|
||||
.filter((item) => compareHeading(item.heading, HEADING_DOWN))
|
||||
.map((item) => item.relative);
|
||||
case "right":
|
||||
return items
|
||||
.filter((item) => compareHeading(item.heading, HEADING_RIGHT))
|
||||
.map((item) => item.relative);
|
||||
case "left":
|
||||
return items
|
||||
.filter((item) => compareHeading(item.heading, HEADING_LEFT))
|
||||
.map((item) => item.relative);
|
||||
}
|
||||
};
|
||||
const step = nodeCrossSize + crossGap;
|
||||
const clusterCrossSize = count * nodeCrossSize + (count - 1) * crossGap;
|
||||
|
||||
const getSuccessors = (
|
||||
node: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
direction: LinkDirection,
|
||||
) => {
|
||||
return getNodeRelatives("successors", node, elementsMap, direction);
|
||||
};
|
||||
const anchoredStart =
|
||||
stickyCrossStart === null
|
||||
? null
|
||||
: [stickyCrossStart, stickyCrossStart - step]
|
||||
.filter((start) => intervalIsFree(start, clusterCrossSize, occupied))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Math.abs(a + clusterCrossSize / 2 - parentCrossCenter) -
|
||||
Math.abs(b + clusterCrossSize / 2 - parentCrossCenter),
|
||||
)[0] ?? null;
|
||||
|
||||
export const getPredecessors = (
|
||||
node: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
direction: LinkDirection,
|
||||
) => {
|
||||
return getNodeRelatives("predecessors", node, elementsMap, direction);
|
||||
};
|
||||
const crossStart =
|
||||
anchoredStart ??
|
||||
findNearestFreeSlot(
|
||||
parentCrossCenter - clusterCrossSize / 2,
|
||||
clusterCrossSize,
|
||||
occupied,
|
||||
);
|
||||
|
||||
const getOffsets = (
|
||||
element: ExcalidrawFlowchartNodeElement,
|
||||
linkedNodes: ExcalidrawElement[],
|
||||
direction: LinkDirection,
|
||||
) => {
|
||||
const _HORIZONTAL_OFFSET = HORIZONTAL_OFFSET + element.width;
|
||||
|
||||
// check if vertical space or horizontal space is available first
|
||||
if (direction === "up" || direction === "down") {
|
||||
const _VERTICAL_OFFSET = VERTICAL_OFFSET + element.height;
|
||||
// check vertical space
|
||||
const minX = element.x;
|
||||
const maxX = element.x + element.width;
|
||||
|
||||
// vertical space is available
|
||||
if (
|
||||
linkedNodes.every(
|
||||
(linkedNode) =>
|
||||
linkedNode.x + linkedNode.width < minX || linkedNode.x > maxX,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
x: 0,
|
||||
y: _VERTICAL_OFFSET * (direction === "up" ? -1 : 1),
|
||||
};
|
||||
}
|
||||
} else if (direction === "right" || direction === "left") {
|
||||
const minY = element.y;
|
||||
const maxY = element.y + element.height;
|
||||
|
||||
if (
|
||||
linkedNodes.every(
|
||||
(linkedNode) =>
|
||||
linkedNode.y + linkedNode.height < minY || linkedNode.y > maxY,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
x:
|
||||
(HORIZONTAL_OFFSET + element.width) * (direction === "left" ? -1 : 1),
|
||||
y: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (direction === "up" || direction === "down") {
|
||||
const _VERTICAL_OFFSET = VERTICAL_OFFSET + element.height;
|
||||
const y = linkedNodes.length === 0 ? _VERTICAL_OFFSET : _VERTICAL_OFFSET;
|
||||
const x =
|
||||
linkedNodes.length === 0
|
||||
? 0
|
||||
: (linkedNodes.length + 1) % 2 === 0
|
||||
? ((linkedNodes.length + 1) / 2) * _HORIZONTAL_OFFSET
|
||||
: (linkedNodes.length / 2) * _HORIZONTAL_OFFSET * -1;
|
||||
|
||||
if (direction === "up") {
|
||||
return {
|
||||
x,
|
||||
y: y * -1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
};
|
||||
}
|
||||
|
||||
const _VERTICAL_OFFSET = VERTICAL_OFFSET + element.height;
|
||||
const x =
|
||||
(linkedNodes.length === 0 ? HORIZONTAL_OFFSET : HORIZONTAL_OFFSET) +
|
||||
element.width;
|
||||
const y =
|
||||
linkedNodes.length === 0
|
||||
? 0
|
||||
: (linkedNodes.length + 1) % 2 === 0
|
||||
? ((linkedNodes.length + 1) / 2) * _VERTICAL_OFFSET
|
||||
: (linkedNodes.length / 2) * _VERTICAL_OFFSET * -1;
|
||||
|
||||
if (direction === "left") {
|
||||
return {
|
||||
x: x * -1,
|
||||
y,
|
||||
};
|
||||
}
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
};
|
||||
};
|
||||
|
||||
const addNewNode = (
|
||||
element: ExcalidrawFlowchartNodeElement,
|
||||
appState: AppState,
|
||||
direction: LinkDirection,
|
||||
scene: Scene,
|
||||
) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const successors = getSuccessors(element, elementsMap, direction);
|
||||
const predeccessors = getPredecessors(element, elementsMap, direction);
|
||||
|
||||
const offsets = getOffsets(
|
||||
element,
|
||||
[...successors, ...predeccessors],
|
||||
direction,
|
||||
);
|
||||
|
||||
const nextNode = newElement({
|
||||
type: element.type,
|
||||
x: element.x + offsets.x,
|
||||
y: element.y + offsets.y,
|
||||
// TODO: extract this to a util
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
roundness: element.roundness,
|
||||
roughness: element.roughness,
|
||||
backgroundColor: element.backgroundColor,
|
||||
strokeColor: element.strokeColor,
|
||||
strokeWidth: element.strokeWidth,
|
||||
opacity: element.opacity,
|
||||
fillStyle: element.fillStyle,
|
||||
strokeStyle: element.strokeStyle,
|
||||
const positions = Array.from({ length: count }, (_, index) => {
|
||||
const cross = crossStart + index * step;
|
||||
return horizontal
|
||||
? { x: primaryStart, y: cross }
|
||||
: { x: cross, y: primaryStart };
|
||||
});
|
||||
|
||||
return { positions, crossStart };
|
||||
};
|
||||
|
||||
const cloneFlowchartNode = (
|
||||
template: ExcalidrawFlowchartNodeElement,
|
||||
x: number,
|
||||
y: number,
|
||||
) => {
|
||||
const commonNodeProps = {
|
||||
x,
|
||||
y,
|
||||
width: template.width,
|
||||
height: template.height,
|
||||
roundness: template.roundness,
|
||||
roughness: template.roughness,
|
||||
backgroundColor: template.backgroundColor,
|
||||
strokeColor: template.strokeColor,
|
||||
strokeWidth: template.strokeWidth,
|
||||
opacity: template.opacity,
|
||||
fillStyle: template.fillStyle,
|
||||
strokeStyle: template.strokeStyle,
|
||||
};
|
||||
|
||||
const node =
|
||||
template.type === "stickynote"
|
||||
? newStickyNoteElement({
|
||||
type: "stickynote",
|
||||
...commonNodeProps,
|
||||
baseHeight: template.baseHeight,
|
||||
})
|
||||
: newElement({
|
||||
type: template.type,
|
||||
...commonNodeProps,
|
||||
});
|
||||
|
||||
invariant(
|
||||
isFlowchartNodeElement(nextNode),
|
||||
isFlowchartNodeElement(node),
|
||||
"not an ExcalidrawFlowchartNodeElement",
|
||||
);
|
||||
|
||||
const bindingArrow = createBindingArrow(
|
||||
element,
|
||||
nextNode,
|
||||
direction,
|
||||
appState,
|
||||
scene,
|
||||
);
|
||||
|
||||
return {
|
||||
nextNode,
|
||||
bindingArrow,
|
||||
};
|
||||
return node;
|
||||
};
|
||||
|
||||
export const addNewNodes = (
|
||||
startNode: ExcalidrawFlowchartNodeElement,
|
||||
startNode: NonDeleted<ExcalidrawFlowchartNodeElement>,
|
||||
appState: AppState,
|
||||
direction: LinkDirection,
|
||||
scene: Scene,
|
||||
numberOfNodes: number,
|
||||
stickyCrossStart: number | null = null,
|
||||
) => {
|
||||
// always start from 0 and distribute evenly
|
||||
const newNodes: ExcalidrawElement[] = [];
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const obstacles = getConnectedFlowchartNodes(startNode, elementsMap).map(
|
||||
(node) => aabbForElement(node, elementsMap),
|
||||
);
|
||||
|
||||
for (let i = 0; i < numberOfNodes; i++) {
|
||||
let nextX: number;
|
||||
let nextY: number;
|
||||
if (direction === "left" || direction === "right") {
|
||||
const totalHeight =
|
||||
VERTICAL_OFFSET * (numberOfNodes - 1) +
|
||||
numberOfNodes * startNode.height;
|
||||
|
||||
const startY = startNode.y + startNode.height / 2 - totalHeight / 2;
|
||||
|
||||
let offsetX = HORIZONTAL_OFFSET + startNode.width;
|
||||
if (direction === "left") {
|
||||
offsetX *= -1;
|
||||
}
|
||||
nextX = startNode.x + offsetX;
|
||||
const offsetY = (VERTICAL_OFFSET + startNode.height) * i;
|
||||
nextY = startY + offsetY;
|
||||
} else {
|
||||
const totalWidth =
|
||||
HORIZONTAL_OFFSET * (numberOfNodes - 1) +
|
||||
numberOfNodes * startNode.width;
|
||||
const startX = startNode.x + startNode.width / 2 - totalWidth / 2;
|
||||
let offsetY = VERTICAL_OFFSET + startNode.height;
|
||||
|
||||
if (direction === "up") {
|
||||
offsetY *= -1;
|
||||
}
|
||||
nextY = startNode.y + offsetY;
|
||||
const offsetX = (HORIZONTAL_OFFSET + startNode.width) * i;
|
||||
nextX = startX + offsetX;
|
||||
}
|
||||
|
||||
const nextNode = newElement({
|
||||
type: startNode.type,
|
||||
x: nextX,
|
||||
y: nextY,
|
||||
// TODO: extract this to a util
|
||||
width: startNode.width,
|
||||
height: startNode.height,
|
||||
roundness: startNode.roundness,
|
||||
roughness: startNode.roughness,
|
||||
backgroundColor: startNode.backgroundColor,
|
||||
strokeColor: startNode.strokeColor,
|
||||
strokeWidth: startNode.strokeWidth,
|
||||
opacity: startNode.opacity,
|
||||
fillStyle: startNode.fillStyle,
|
||||
strokeStyle: startNode.strokeStyle,
|
||||
});
|
||||
|
||||
invariant(
|
||||
isFlowchartNodeElement(nextNode),
|
||||
"not an ExcalidrawFlowchartNodeElement",
|
||||
);
|
||||
const { positions, crossStart } = placeCluster(
|
||||
startNode,
|
||||
direction,
|
||||
numberOfNodes,
|
||||
obstacles,
|
||||
stickyCrossStart,
|
||||
);
|
||||
|
||||
const nodes: NonDeletedExcalidrawElement[] = [];
|
||||
for (const position of positions) {
|
||||
const nextNode = cloneFlowchartNode(startNode, position.x, position.y);
|
||||
const bindingArrow = createBindingArrow(
|
||||
startNode,
|
||||
nextNode,
|
||||
@@ -361,16 +301,15 @@ export const addNewNodes = (
|
||||
scene,
|
||||
);
|
||||
|
||||
newNodes.push(nextNode);
|
||||
newNodes.push(bindingArrow);
|
||||
nodes.push(nextNode, bindingArrow);
|
||||
}
|
||||
|
||||
return newNodes;
|
||||
return { nodes, crossStart };
|
||||
};
|
||||
|
||||
const createBindingArrow = (
|
||||
startBindingElement: ExcalidrawFlowchartNodeElement,
|
||||
endBindingElement: ExcalidrawFlowchartNodeElement,
|
||||
startBindingElement: NonDeleted<ExcalidrawFlowchartNodeElement>,
|
||||
endBindingElement: NonDeleted<ExcalidrawFlowchartNodeElement>,
|
||||
direction: LinkDirection,
|
||||
appState: AppState,
|
||||
scene: Scene,
|
||||
@@ -452,8 +391,16 @@ const createBindingArrow = (
|
||||
"orbit",
|
||||
"start",
|
||||
scene,
|
||||
appState.zoom,
|
||||
);
|
||||
bindBindingElement(
|
||||
bindingArrow,
|
||||
endBindingElement,
|
||||
"orbit",
|
||||
"end",
|
||||
scene,
|
||||
appState.zoom,
|
||||
);
|
||||
bindBindingElement(bindingArrow, endBindingElement, "orbit", "end", scene);
|
||||
|
||||
const changedElements = new Map<string, OrderedExcalidrawElement>();
|
||||
changedElements.set(
|
||||
@@ -490,7 +437,7 @@ const createBindingArrow = (
|
||||
[startBindingElement.id, startBindingElement],
|
||||
[endBindingElement.id, endBindingElement],
|
||||
[bindingArrow.id, bindingArrow],
|
||||
] as [string, Ordered<ExcalidrawElement>][]),
|
||||
] as [string, Ordered<NonDeletedExcalidrawElement>][]),
|
||||
),
|
||||
{ points: bindingArrow.points },
|
||||
);
|
||||
@@ -498,6 +445,7 @@ const createBindingArrow = (
|
||||
return {
|
||||
...bindingArrow,
|
||||
...update,
|
||||
isDeleted: bindingArrow.isDeleted,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -562,8 +510,8 @@ export class FlowChartNavigator {
|
||||
}
|
||||
|
||||
const nodes = [
|
||||
...getSuccessors(element, elementsMap, direction),
|
||||
...getPredecessors(element, elementsMap, direction),
|
||||
...FlowChartNavigator.getSuccessors(element, elementsMap, direction),
|
||||
...FlowChartNavigator.getPredecessors(element, elementsMap, direction),
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -612,8 +560,8 @@ export class FlowChartNavigator {
|
||||
|
||||
const otherLinkedNodes = otherDirections
|
||||
.map((dir) => [
|
||||
...getSuccessors(element, elementsMap, dir),
|
||||
...getPredecessors(element, elementsMap, dir),
|
||||
...FlowChartNavigator.getSuccessors(element, elementsMap, dir),
|
||||
...FlowChartNavigator.getPredecessors(element, elementsMap, dir),
|
||||
])
|
||||
.flat()
|
||||
.filter((linkedNode) => !this.visitedNodes.has(linkedNode.id));
|
||||
@@ -630,48 +578,145 @@ export class FlowChartNavigator {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static getNodeRelatives(
|
||||
type: "predecessors" | "successors",
|
||||
node: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
direction: LinkDirection,
|
||||
) {
|
||||
const items = [...elementsMap.values()].reduce(
|
||||
(
|
||||
acc: { relative: ExcalidrawBindableElement; heading: Heading }[],
|
||||
el,
|
||||
) => {
|
||||
let oppositeBinding;
|
||||
if (
|
||||
isElbowArrow(el) &&
|
||||
// we want check existence of the opposite binding, in the direction
|
||||
// we're interested in
|
||||
(oppositeBinding =
|
||||
el[type === "predecessors" ? "startBinding" : "endBinding"]) &&
|
||||
// similarly, we need to filter only arrows bound to target node
|
||||
el[type === "predecessors" ? "endBinding" : "startBinding"]
|
||||
?.elementId === node.id
|
||||
) {
|
||||
const relative = elementsMap.get(oppositeBinding.elementId);
|
||||
|
||||
if (!relative) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
invariant(
|
||||
isBindableElement(relative),
|
||||
"not an ExcalidrawBindableElement",
|
||||
);
|
||||
|
||||
const edgePoint = (
|
||||
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
|
||||
) as Readonly<LocalPoint>;
|
||||
|
||||
const heading = headingForPointFromElement(
|
||||
node,
|
||||
aabbForElement(node, elementsMap),
|
||||
[edgePoint[0] + el.x, edgePoint[1] + el.y] as Readonly<GlobalPoint>,
|
||||
);
|
||||
|
||||
acc.push({
|
||||
relative,
|
||||
heading,
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
switch (direction) {
|
||||
case "up":
|
||||
return items
|
||||
.filter((item) => compareHeading(item.heading, HEADING_UP))
|
||||
.map((item) => item.relative);
|
||||
case "down":
|
||||
return items
|
||||
.filter((item) => compareHeading(item.heading, HEADING_DOWN))
|
||||
.map((item) => item.relative);
|
||||
case "right":
|
||||
return items
|
||||
.filter((item) => compareHeading(item.heading, HEADING_RIGHT))
|
||||
.map((item) => item.relative);
|
||||
case "left":
|
||||
return items
|
||||
.filter((item) => compareHeading(item.heading, HEADING_LEFT))
|
||||
.map((item) => item.relative);
|
||||
}
|
||||
}
|
||||
|
||||
private static getSuccessors(
|
||||
node: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
direction: LinkDirection,
|
||||
) {
|
||||
return FlowChartNavigator.getNodeRelatives(
|
||||
"successors",
|
||||
node,
|
||||
elementsMap,
|
||||
direction,
|
||||
);
|
||||
}
|
||||
|
||||
private static getPredecessors(
|
||||
node: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
direction: LinkDirection,
|
||||
) {
|
||||
return FlowChartNavigator.getNodeRelatives(
|
||||
"predecessors",
|
||||
node,
|
||||
elementsMap,
|
||||
direction,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class FlowChartCreator {
|
||||
isCreatingChart: boolean = false;
|
||||
private numberOfNodes: number = 0;
|
||||
private direction: LinkDirection | null = "right";
|
||||
private direction: LinkDirection | null = null;
|
||||
// cross-axis anchor of the pending cluster, so growing it keeps the
|
||||
// already-visible pending nodes in place
|
||||
private clusterCrossStart: number | null = null;
|
||||
pendingNodes: PendingExcalidrawElements | null = null;
|
||||
|
||||
createNodes(
|
||||
startNode: ExcalidrawFlowchartNodeElement,
|
||||
startNode: NonDeleted<ExcalidrawFlowchartNodeElement>,
|
||||
appState: AppState,
|
||||
direction: LinkDirection,
|
||||
scene: Scene,
|
||||
) {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
if (direction !== this.direction) {
|
||||
const { nextNode, bindingArrow } = addNewNode(
|
||||
startNode,
|
||||
appState,
|
||||
direction,
|
||||
scene,
|
||||
);
|
||||
|
||||
if (direction !== this.direction) {
|
||||
this.numberOfNodes = 1;
|
||||
this.isCreatingChart = true;
|
||||
this.direction = direction;
|
||||
this.pendingNodes = [nextNode, bindingArrow];
|
||||
this.clusterCrossStart = null;
|
||||
} else {
|
||||
this.numberOfNodes += 1;
|
||||
const newNodes = addNewNodes(
|
||||
startNode,
|
||||
appState,
|
||||
direction,
|
||||
scene,
|
||||
this.numberOfNodes,
|
||||
);
|
||||
|
||||
this.isCreatingChart = true;
|
||||
this.direction = direction;
|
||||
this.pendingNodes = newNodes;
|
||||
}
|
||||
|
||||
const { nodes, crossStart } = addNewNodes(
|
||||
startNode,
|
||||
appState,
|
||||
direction,
|
||||
scene,
|
||||
this.numberOfNodes,
|
||||
this.clusterCrossStart,
|
||||
);
|
||||
|
||||
this.isCreatingChart = true;
|
||||
this.direction = direction;
|
||||
this.clusterCrossStart = crossStart;
|
||||
this.pendingNodes = nodes;
|
||||
|
||||
// add pending nodes to the same frame as the start node
|
||||
// if every pending node is at least intersecting with the frame
|
||||
if (startNode.frameId) {
|
||||
@@ -704,6 +749,7 @@ export class FlowChartCreator {
|
||||
this.pendingNodes = null;
|
||||
this.direction = null;
|
||||
this.numberOfNodes = 0;
|
||||
this.clusterCrossStart = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
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 { getBoundTextElementId } from "./textElement";
|
||||
import { hasBoundTextElement } from "./typeChecks";
|
||||
|
||||
import { isNonDeletedElement } from ".";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
FractionalIndex,
|
||||
Ordered,
|
||||
OrderedExcalidrawElement,
|
||||
SceneElementsMap,
|
||||
} from "./types";
|
||||
@@ -61,6 +67,8 @@ export const validateFractionalIndices = (
|
||||
const stringifyElement = (element: ExcalidrawElement | void) =>
|
||||
`${element?.index}:${element?.id}:${element?.type}:${element?.isDeleted}:${element?.version}:${element?.versionNonce}`;
|
||||
|
||||
const elementsMap = includeBoundTextValidation ? arrayToMap(elements) : null;
|
||||
|
||||
const indices = elements.map((x) => x.index);
|
||||
for (const [i, index] of indices.entries()) {
|
||||
const predecessorIndex = indices[i - 1];
|
||||
@@ -77,11 +85,23 @@ export const validateFractionalIndices = (
|
||||
}
|
||||
|
||||
// disabled by default, as we don't fix it
|
||||
if (includeBoundTextValidation && hasBoundTextElement(elements[i])) {
|
||||
if (
|
||||
includeBoundTextValidation &&
|
||||
elementsMap &&
|
||||
hasBoundTextElement(elements[i]) &&
|
||||
isNonDeletedElement(elements[i])
|
||||
) {
|
||||
const container = elements[i];
|
||||
const text = getBoundTextElement(container, arrayToMap(elements));
|
||||
const boundTextElementId = getBoundTextElementId(container);
|
||||
const text = boundTextElementId
|
||||
? elementsMap.get(boundTextElementId)
|
||||
: null;
|
||||
|
||||
if (text && text.index! <= container.index!) {
|
||||
if (
|
||||
text &&
|
||||
isNonDeletedElement(text) &&
|
||||
text.index! <= container.index!
|
||||
) {
|
||||
errorMessages.push(
|
||||
`Fractional indices invariant for bound elements has been compromised: "${stringifyElement(
|
||||
text,
|
||||
@@ -200,9 +220,9 @@ export const syncMovedIndices = (
|
||||
*
|
||||
* WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
|
||||
*/
|
||||
export const syncInvalidIndices = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): OrderedExcalidrawElement[] => {
|
||||
export const syncInvalidIndices = <T extends ExcalidrawElement>(
|
||||
elements: readonly T[],
|
||||
): Ordered<T>[] => {
|
||||
const elementsMap = arrayToMap(elements);
|
||||
const indicesGroups = getInvalidIndicesGroups(elements);
|
||||
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||
@@ -211,7 +231,7 @@ export const syncInvalidIndices = (
|
||||
mutateElement(element, elementsMap, { index });
|
||||
}
|
||||
|
||||
return elements as OrderedExcalidrawElement[];
|
||||
return elements as Ordered<T>[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -382,6 +402,13 @@ const isValidFractionalIndex = (
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Format validation
|
||||
validateOrderKey(index);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (predecessor && successor) {
|
||||
return predecessor < index && index < successor;
|
||||
}
|
||||
|
||||
+114
-52
@@ -1,6 +1,9 @@
|
||||
import { arrayToMap } from "@excalidraw/common";
|
||||
import { isPointWithinBounds, pointFrom } from "@excalidraw/math";
|
||||
import { doLineSegmentsIntersect } from "@excalidraw/utils/bbox";
|
||||
import {
|
||||
isPointWithinBounds,
|
||||
pointFrom,
|
||||
segmentsIntersectAt,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
AppClassProperties,
|
||||
@@ -19,15 +22,19 @@ import {
|
||||
getElementAbsoluteCoords,
|
||||
doBoundsIntersect,
|
||||
getElementBounds,
|
||||
boundsContainBounds,
|
||||
} from "./bounds";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import { syncMovedIndices } from "./fractionalIndex";
|
||||
import {
|
||||
isFrameElement,
|
||||
isFrameLikeElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import { getNonDeletedElements } from ".";
|
||||
|
||||
import type { ExcalidrawElementsIncludingDeleted } from "./Scene";
|
||||
|
||||
import type {
|
||||
@@ -76,7 +83,7 @@ export function isElementIntersectingFrame(
|
||||
|
||||
const intersecting = frameLineSegments.some((frameLineSegment) =>
|
||||
elementLineSegments.some((elementLineSegment) =>
|
||||
doLineSegmentsIntersect(frameLineSegment, elementLineSegment),
|
||||
segmentsIntersectAt(frameLineSegment, elementLineSegment),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -101,8 +108,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),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -260,8 +268,8 @@ export const getFrameLikeElements = (
|
||||
*
|
||||
* Considers non-frame bound elements (container or arrow labels) as root.
|
||||
*/
|
||||
export const getRootElements = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
export const getRootElements = <T extends ExcalidrawElement>(
|
||||
allElements: readonly T[],
|
||||
) => {
|
||||
const frameElements = arrayToMap(getFrameLikeElements(allElements));
|
||||
return allElements.filter(
|
||||
@@ -342,9 +350,12 @@ export const getElementsInResizingFrame = (
|
||||
const newGroupElementsCompletelyInFrame = Array.from(
|
||||
elementsCompletelyInFrame,
|
||||
).filter((element) => element.groupIds.length > 0);
|
||||
const nonDeletedNewGroupElementsCompletelyInFrame = getNonDeletedElements(
|
||||
newGroupElementsCompletelyInFrame,
|
||||
);
|
||||
|
||||
const groupIds = selectGroupsFromGivenElements(
|
||||
newGroupElementsCompletelyInFrame,
|
||||
nonDeletedNewGroupElementsCompletelyInFrame,
|
||||
appState,
|
||||
);
|
||||
|
||||
@@ -489,30 +500,56 @@ 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)
|
||||
*/
|
||||
export const addElementsToFrame = <T extends ElementsMapOrArray>(
|
||||
allElements: T,
|
||||
elementsToAdd: NonDeletedExcalidrawElement[],
|
||||
elementsToAdd: ExcalidrawElement[],
|
||||
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"]>();
|
||||
|
||||
@@ -523,7 +560,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,
|
||||
@@ -536,42 +574,68 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
|
||||
continue;
|
||||
}
|
||||
|
||||
// if the element is already in another frame (which is also in elementsToAdd),
|
||||
// it means that frame and children are selected at the same time
|
||||
// => keep original frame membership, do not add to the target frame
|
||||
if (
|
||||
element.frameId &&
|
||||
appState.selectedElementIds[element.id] &&
|
||||
appState.selectedElementIds[element.frameId]
|
||||
) {
|
||||
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 = (
|
||||
elementsToRemove: ReadonlySetLike<NonDeletedExcalidrawElement>,
|
||||
elementsToRemove: ReadonlySetLike<ExcalidrawElement>,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
const _elementsToRemove = new Map<
|
||||
@@ -621,13 +685,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();
|
||||
};
|
||||
|
||||
@@ -918,8 +980,8 @@ export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
|
||||
return element.name === null ? getDefaultFrameName(element) : element.name;
|
||||
};
|
||||
|
||||
export const getElementsOverlappingFrame = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
export const getElementsOverlappingFrame = <T extends ExcalidrawElement>(
|
||||
elements: readonly T[],
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
|
||||
@@ -15,15 +15,16 @@ import type {
|
||||
GroupId,
|
||||
ExcalidrawElement,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
ElementsMapOrArray,
|
||||
ElementsMap,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeletedElementsMapOrArray,
|
||||
} from "./types";
|
||||
|
||||
export const selectGroup = (
|
||||
groupId: GroupId,
|
||||
appState: InteractiveCanvasAppState,
|
||||
elements: readonly NonDeleted<ExcalidrawElement>[],
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
): Pick<
|
||||
InteractiveCanvasAppState,
|
||||
"selectedGroupIds" | "selectedElementIds" | "editingGroupId"
|
||||
@@ -68,14 +69,14 @@ export const selectGroupsForSelectedElements = (function () {
|
||||
"selectedGroupIds" | "editingGroupId" | "selectedElementIds"
|
||||
>;
|
||||
|
||||
let lastSelectedElements: readonly NonDeleted<ExcalidrawElement>[] | null =
|
||||
let lastSelectedElements: readonly NonDeletedExcalidrawElement[] | null =
|
||||
null;
|
||||
let lastElements: readonly NonDeleted<ExcalidrawElement>[] | null = null;
|
||||
let lastElements: readonly NonDeletedExcalidrawElement[] | null = null;
|
||||
let lastReturnValue: SelectGroupsReturnType | null = null;
|
||||
|
||||
const _selectGroups = (
|
||||
selectedElements: readonly NonDeleted<ExcalidrawElement>[],
|
||||
elements: readonly NonDeleted<ExcalidrawElement>[],
|
||||
selectedElements: readonly NonDeletedExcalidrawElement[],
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: Pick<AppState, "selectedElementIds" | "editingGroupId">,
|
||||
prevAppState: InteractiveCanvasAppState,
|
||||
): SelectGroupsReturnType => {
|
||||
@@ -212,7 +213,10 @@ export const selectGroupsForSelectedElements = (function () {
|
||||
* selection border around it.
|
||||
*/
|
||||
export const isSelectedViaGroup = (
|
||||
appState: InteractiveCanvasAppState,
|
||||
appState: Pick<
|
||||
InteractiveCanvasAppState,
|
||||
"editingGroupId" | "selectedGroupIds"
|
||||
>,
|
||||
element: ExcalidrawElement,
|
||||
) => getSelectedGroupForElement(appState, element) != null;
|
||||
|
||||
@@ -228,7 +232,7 @@ export const getSelectedGroupForElement = (
|
||||
.find((groupId) => appState.selectedGroupIds[groupId]);
|
||||
|
||||
export const getSelectedGroupIds = (
|
||||
appState: InteractiveCanvasAppState,
|
||||
appState: Pick<InteractiveCanvasAppState, "selectedGroupIds">,
|
||||
): GroupId[] =>
|
||||
Object.entries(appState.selectedGroupIds)
|
||||
.filter(([groupId, isSelected]) => isSelected)
|
||||
@@ -237,7 +241,7 @@ export const getSelectedGroupIds = (
|
||||
// given a list of elements, return the the actual group ids that should be selected
|
||||
// or used to update the elements
|
||||
export const selectGroupsFromGivenElements = (
|
||||
elements: readonly NonDeleted<ExcalidrawElement>[],
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: InteractiveCanvasAppState,
|
||||
) => {
|
||||
let nextAppState: InteractiveCanvasAppState = {
|
||||
@@ -282,14 +286,18 @@ export const editGroupForSelectedElement = (
|
||||
export const isElementInGroup = (element: ExcalidrawElement, groupId: string) =>
|
||||
element.groupIds.includes(groupId);
|
||||
|
||||
export const getElementsInGroup = (
|
||||
elements: ElementsMapOrArray,
|
||||
export const getElementsInGroup = <
|
||||
P extends NonDeletedExcalidrawElement | ExcalidrawElement,
|
||||
>(
|
||||
elements: P extends NonDeletedExcalidrawElement
|
||||
? NonDeletedElementsMapOrArray
|
||||
: ElementsMapOrArray,
|
||||
groupId: string,
|
||||
) => {
|
||||
const elementsInGroup: ExcalidrawElement[] = [];
|
||||
): P[] => {
|
||||
const elementsInGroup: P[] = [];
|
||||
for (const element of elements.values()) {
|
||||
if (isElementInGroup(element, groupId)) {
|
||||
elementsInGroup.push(element);
|
||||
elementsInGroup.push(element as P);
|
||||
}
|
||||
}
|
||||
return elementsInGroup;
|
||||
@@ -321,15 +329,14 @@ export const removeFromSelectedGroups = (
|
||||
selectedGroupIds: { [groupId: string]: boolean },
|
||||
) => groupIds.filter((groupId) => !selectedGroupIds[groupId]);
|
||||
|
||||
export const getMaximumGroups = (
|
||||
elements: ExcalidrawElement[],
|
||||
export const getMaximumGroups = <
|
||||
T extends NonDeletedExcalidrawElement | ExcalidrawElement,
|
||||
>(
|
||||
elements: T[],
|
||||
elementsMap: ElementsMap,
|
||||
): ExcalidrawElement[][] => {
|
||||
const groups: Map<String, ExcalidrawElement[]> = new Map<
|
||||
String,
|
||||
ExcalidrawElement[]
|
||||
>();
|
||||
elements.forEach((element: ExcalidrawElement) => {
|
||||
): T[][] => {
|
||||
const groups: Map<String, T[]> = new Map<String, T[]>();
|
||||
elements.forEach((element: T) => {
|
||||
const groupId =
|
||||
element.groupIds.length === 0
|
||||
? element.id
|
||||
@@ -340,7 +347,7 @@ export const getMaximumGroups = (
|
||||
// Include bound text if present when grouping
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement) {
|
||||
currentGroupMembers.push(boundTextElement);
|
||||
currentGroupMembers.push(boundTextElement as T);
|
||||
}
|
||||
groups.set(groupId, [...currentGroupMembers, element]);
|
||||
});
|
||||
@@ -383,7 +390,7 @@ export const elementsAreInSameGroup = (
|
||||
return maxGroup === elements.length;
|
||||
};
|
||||
|
||||
export const isInGroup = (element: NonDeletedExcalidrawElement) => {
|
||||
export const isInGroup = (element: ExcalidrawElement) => {
|
||||
return element.groupIds.length > 0;
|
||||
};
|
||||
|
||||
@@ -408,74 +415,52 @@ export const getNewGroupIdsForDuplication = (
|
||||
// given a list of selected elements, return the element grouped by their immediate group selected state
|
||||
// in the case if only one group is selected and all elements selected are within the group, it will respect group hierarchy in accordance to their nested grouping order
|
||||
export const getSelectedElementsByGroup = (
|
||||
selectedElements: ExcalidrawElement[],
|
||||
selectedElements: NonDeletedExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
appState: Readonly<AppState>,
|
||||
): ExcalidrawElement[][] => {
|
||||
appState: Readonly<Pick<AppState, "selectedGroupIds" | "editingGroupId">>,
|
||||
): NonDeletedExcalidrawElement[][] => {
|
||||
const buckets: Map<string, NonDeletedExcalidrawElement[]> = new Map();
|
||||
const selectedGroupIds = getSelectedGroupIds(appState);
|
||||
const unboundElements = selectedElements.filter(
|
||||
(element) => !isBoundToContainer(element),
|
||||
);
|
||||
const groups: Map<string, ExcalidrawElement[]> = new Map();
|
||||
const elements: Map<string, ExcalidrawElement[]> = new Map();
|
||||
const isSingleSelectedGroupCase =
|
||||
selectedGroupIds.length === 1 &&
|
||||
selectedElements.every((element) => isSelectedViaGroup(appState, element));
|
||||
|
||||
// helper function to add an element to the elements map
|
||||
const addToElementsMap = (element: ExcalidrawElement) => {
|
||||
// elements
|
||||
const currentElementMembers = elements.get(element.id) || [];
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
|
||||
if (boundTextElement) {
|
||||
currentElementMembers.push(boundTextElement);
|
||||
selectedElements.forEach((element) => {
|
||||
// skip dependent boundTextElements, they are appended after their container
|
||||
if (isBoundToContainer(element)) {
|
||||
return;
|
||||
}
|
||||
elements.set(element.id, [...currentElementMembers, element]);
|
||||
};
|
||||
|
||||
// helper function to add an element to the groups map
|
||||
const addToGroupsMap = (element: ExcalidrawElement, groupId: string) => {
|
||||
// groups
|
||||
const currentGroupMembers = groups.get(groupId) || [];
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
|
||||
if (boundTextElement) {
|
||||
currentGroupMembers.push(boundTextElement);
|
||||
}
|
||||
groups.set(groupId, [...currentGroupMembers, element]);
|
||||
};
|
||||
|
||||
// helper function to handle the case where a single group is selected
|
||||
// and all elements selected are within the group, it will respect group hierarchy in accordance to
|
||||
// their nested grouping order
|
||||
const handleSingleSelectedGroupCase = (
|
||||
element: ExcalidrawElement,
|
||||
selectedGroupId: GroupId,
|
||||
) => {
|
||||
const indexOfSelectedGroupId = element.groupIds.indexOf(selectedGroupId, 0);
|
||||
const nestedGroupCount = element.groupIds.slice(
|
||||
0,
|
||||
indexOfSelectedGroupId,
|
||||
).length;
|
||||
return nestedGroupCount > 0
|
||||
? addToGroupsMap(element, element.groupIds[indexOfSelectedGroupId - 1])
|
||||
: addToElementsMap(element);
|
||||
};
|
||||
|
||||
const isAllInSameGroup = selectedElements.every((element) =>
|
||||
isSelectedViaGroup(appState, element),
|
||||
);
|
||||
|
||||
unboundElements.forEach((element) => {
|
||||
let bucketKey: string;
|
||||
const selectedGroupId = getSelectedGroupIdForElement(
|
||||
element,
|
||||
appState.selectedGroupIds,
|
||||
);
|
||||
|
||||
if (!selectedGroupId) {
|
||||
addToElementsMap(element);
|
||||
} else if (selectedGroupIds.length === 1 && isAllInSameGroup) {
|
||||
handleSingleSelectedGroupCase(element, selectedGroupId);
|
||||
bucketKey = `${element.id}_element`;
|
||||
} else {
|
||||
addToGroupsMap(element, selectedGroupId);
|
||||
// if only one group is selected, grouping is based on inner hierarchy
|
||||
const keyIndex = isSingleSelectedGroupCase
|
||||
? element.groupIds.indexOf(selectedGroupId) - 1
|
||||
: element.groupIds.indexOf(selectedGroupId);
|
||||
|
||||
// edge case: single selected group where element is non member of inner group
|
||||
bucketKey =
|
||||
keyIndex < 0
|
||||
? `${element.id}_element`
|
||||
: `${element.groupIds[keyIndex]}_group`;
|
||||
}
|
||||
|
||||
const currentBucketMembers = buckets.get(bucketKey) ?? [];
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
|
||||
// preserve boundtext after container ordering
|
||||
buckets.set(bucketKey, [
|
||||
...currentBucketMembers,
|
||||
element,
|
||||
...(boundTextElement ? [boundTextElement] : []),
|
||||
]);
|
||||
});
|
||||
return Array.from(groups.values()).concat(Array.from(elements.values()));
|
||||
return [...buckets.values()];
|
||||
};
|
||||
|
||||
@@ -40,23 +40,27 @@ export const hashString = (s: string): number => {
|
||||
return hash >>> 0; // Ensure unsigned 32-bit integer
|
||||
};
|
||||
|
||||
export const getVisibleElements = (elements: readonly ExcalidrawElement[]) =>
|
||||
elements.filter(
|
||||
(el) => !el.isDeleted && !isInvisiblySmallElement(el),
|
||||
) as readonly NonDeletedExcalidrawElement[];
|
||||
|
||||
export const getNonDeletedElements = <T extends ExcalidrawElement>(
|
||||
elements: readonly T[],
|
||||
) =>
|
||||
elements.filter((element) => !element.isDeleted) as readonly NonDeleted<T>[];
|
||||
|
||||
export const isNonDeletedElement = <T extends ExcalidrawElement>(
|
||||
element: T,
|
||||
): element is NonDeleted<T> => !element.isDeleted;
|
||||
|
||||
export const getVisibleElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): readonly NonDeletedExcalidrawElement[] =>
|
||||
elements.filter(
|
||||
(el): el is NonDeletedExcalidrawElement =>
|
||||
isNonDeletedElement(el) && !isInvisiblySmallElement(el),
|
||||
);
|
||||
|
||||
export const getNonDeletedElements = <T extends ExcalidrawElement>(
|
||||
elements: readonly T[],
|
||||
): readonly NonDeleted<T>[] => elements.filter(isNonDeletedElement);
|
||||
|
||||
export * from "./align";
|
||||
export * from "./arrowEndpointText";
|
||||
export * from "./binding";
|
||||
export * from "./bounds";
|
||||
export * from "./bucketFill";
|
||||
export * from "./collision";
|
||||
export * from "./comparisons";
|
||||
export * from "./containerCache";
|
||||
@@ -90,6 +94,7 @@ export * from "./showSelectedShapeActions";
|
||||
export * from "./sizeHelpers";
|
||||
export * from "./sortElements";
|
||||
export * from "./store";
|
||||
export * from "./stickyNote";
|
||||
export * from "./textElement";
|
||||
export * from "./textMeasurements";
|
||||
export * from "./textWrapping";
|
||||
@@ -100,3 +105,4 @@ export * from "./utils";
|
||||
export * from "./zindex";
|
||||
export * from "./arrows/helpers";
|
||||
export * from "./arrowheads";
|
||||
export * from "./convertToShape";
|
||||
|
||||
@@ -8,7 +8,14 @@ import {
|
||||
pointDistance,
|
||||
vectorFromPoint,
|
||||
curveLength,
|
||||
curveLengthAtParameter,
|
||||
curvePointAtLength,
|
||||
lineSegmentClosestParameter,
|
||||
lineSegmentPointAt,
|
||||
curveClosestParameter,
|
||||
clamp,
|
||||
bezierEquation,
|
||||
isCurve,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { getCurvePathOps } from "@excalidraw/utils/shape";
|
||||
@@ -20,7 +27,6 @@ import {
|
||||
getGridPoint,
|
||||
invariant,
|
||||
isShallowEqual,
|
||||
getFeatureFlag,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
@@ -30,8 +36,11 @@ import {
|
||||
moveArrowAboveBindable,
|
||||
projectFixedPointOntoDiagonal,
|
||||
type Store,
|
||||
getLinearElementPathSegments,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { LinearPathSegment } from "@excalidraw/element";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
@@ -48,7 +57,6 @@ import {
|
||||
calculateFixedPointForNonElbowArrowBinding,
|
||||
getBindingStrategyForDraggingBindingElementEndpoints,
|
||||
isBindingEnabled,
|
||||
snapToMid,
|
||||
updateBoundPoint,
|
||||
} from "./binding";
|
||||
import {
|
||||
@@ -122,6 +130,15 @@ type PointMoveOtherUpdates = {
|
||||
suggestedBinding?: AppState["suggestedBinding"] | null;
|
||||
};
|
||||
|
||||
const BoundTextPositionCache = new WeakMap<
|
||||
ExcalidrawTextElementWithContainer,
|
||||
{
|
||||
containerVersion: ExcalidrawElement["version"];
|
||||
textVersion: ExcalidrawElement["version"];
|
||||
position: { x: number; y: number };
|
||||
}
|
||||
>();
|
||||
|
||||
export class LinearElementEditor {
|
||||
public readonly elementId: ExcalidrawElement["id"] & {
|
||||
_brand: "excalidrawLinearElementId";
|
||||
@@ -141,6 +158,7 @@ export class LinearElementEditor {
|
||||
};
|
||||
arrowStartIsInside: boolean;
|
||||
altFocusPoint: Readonly<GlobalPoint> | null;
|
||||
arrowOtherEndpointInitialBinding: FixedPointBinding | null;
|
||||
}>;
|
||||
|
||||
/** whether you're dragging a point */
|
||||
@@ -193,6 +211,7 @@ export class LinearElementEditor {
|
||||
added: false,
|
||||
},
|
||||
arrowStartIsInside: false,
|
||||
arrowOtherEndpointInitialBinding: null,
|
||||
altFocusPoint: null,
|
||||
};
|
||||
this.hoverPointIndex = -1;
|
||||
@@ -217,7 +236,7 @@ export class LinearElementEditor {
|
||||
static getElement<T extends ExcalidrawLinearElement>(
|
||||
id: InstanceType<typeof LinearElementEditor>["elementId"],
|
||||
elementsMap: ElementsMap,
|
||||
): T | null {
|
||||
): NonDeleted<T> | null {
|
||||
const element = elementsMap.get(id);
|
||||
if (element) {
|
||||
return element as NonDeleted<T>;
|
||||
@@ -344,7 +363,7 @@ export class LinearElementEditor {
|
||||
|
||||
// Apply the point movement if needed
|
||||
let suggestedBinding: AppState["suggestedBinding"] = null;
|
||||
const { positions, updates } = pointDraggingUpdates(
|
||||
const { positions, updates, hit } = pointDraggingUpdates(
|
||||
[idx],
|
||||
deltaX,
|
||||
deltaY,
|
||||
@@ -359,6 +378,7 @@ export class LinearElementEditor {
|
||||
linearElementEditor,
|
||||
);
|
||||
|
||||
const angleLocked = shouldRotateWithDiscreteAngle(event);
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
app.scene,
|
||||
@@ -370,7 +390,10 @@ export class LinearElementEditor {
|
||||
},
|
||||
{
|
||||
isBindingEnabled: app.state.isBindingEnabled,
|
||||
isMidpointSnappingEnabled: app.state.isMidpointSnappingEnabled,
|
||||
isMidpointSnappingEnabled:
|
||||
app.state.isMidpointSnappingEnabled &&
|
||||
!angleLocked &&
|
||||
!app.state.gridModeEnabled,
|
||||
},
|
||||
);
|
||||
// Set the suggested binding from the updates if available
|
||||
@@ -382,17 +405,20 @@ export class LinearElementEditor {
|
||||
|
||||
// Move the arrow over the bindable object in terms of z-index
|
||||
if (isBindingElement(element)) {
|
||||
moveArrowAboveBindable(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
if (hit) {
|
||||
moveArrowAboveBindable(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
element.points[element.points.length - 1],
|
||||
elementsMap,
|
||||
),
|
||||
element,
|
||||
element.points[element.points.length - 1],
|
||||
elements,
|
||||
elementsMap,
|
||||
),
|
||||
element,
|
||||
elements,
|
||||
elementsMap,
|
||||
app.scene,
|
||||
);
|
||||
app.scene,
|
||||
hit,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PERF: Avoid state updates if not absolutely necessary
|
||||
@@ -427,7 +453,9 @@ export class LinearElementEditor {
|
||||
"start",
|
||||
elementsMap,
|
||||
app.state.zoom,
|
||||
app.state.isMidpointSnappingEnabled,
|
||||
app.state.isMidpointSnappingEnabled &&
|
||||
!angleLocked &&
|
||||
!app.state.gridModeEnabled,
|
||||
)
|
||||
: linearElementEditor.initialState.altFocusPoint,
|
||||
},
|
||||
@@ -486,7 +514,7 @@ export class LinearElementEditor {
|
||||
selectedPointsIndices,
|
||||
)}) points(0..${
|
||||
element.points.length - 1
|
||||
}) lastClickedPoint(${lastClickedPoint})`,
|
||||
}) lastClickedPoint(${lastClickedPoint}) isElbowArrow: ${elbowed}`,
|
||||
);
|
||||
|
||||
// Fall back to the actual last point as a last resort.
|
||||
@@ -539,7 +567,7 @@ export class LinearElementEditor {
|
||||
|
||||
// Apply the point movement if needed
|
||||
let suggestedBinding: AppState["suggestedBinding"] = null;
|
||||
const { positions, updates } = pointDraggingUpdates(
|
||||
const { positions, updates, hit } = pointDraggingUpdates(
|
||||
selectedPointsIndices,
|
||||
deltaX,
|
||||
deltaY,
|
||||
@@ -554,6 +582,8 @@ export class LinearElementEditor {
|
||||
linearElementEditor,
|
||||
);
|
||||
|
||||
const angleLocked =
|
||||
shouldRotateWithDiscreteAngle(event) && singlePointDragged;
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
app.scene,
|
||||
@@ -565,7 +595,10 @@ export class LinearElementEditor {
|
||||
},
|
||||
{
|
||||
isBindingEnabled: app.state.isBindingEnabled,
|
||||
isMidpointSnappingEnabled: app.state.isMidpointSnappingEnabled,
|
||||
isMidpointSnappingEnabled:
|
||||
app.state.isMidpointSnappingEnabled &&
|
||||
!angleLocked &&
|
||||
!app.state.gridModeEnabled,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -578,19 +611,22 @@ export class LinearElementEditor {
|
||||
|
||||
// Move the arrow over the bindable object in terms of z-index
|
||||
if (isBindingElement(element) && startIsSelected !== endIsSelected) {
|
||||
moveArrowAboveBindable(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
if (hit) {
|
||||
moveArrowAboveBindable(
|
||||
LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
startIsSelected
|
||||
? element.points[0]
|
||||
: element.points[element.points.length - 1],
|
||||
elementsMap,
|
||||
),
|
||||
element,
|
||||
startIsSelected
|
||||
? element.points[0]
|
||||
: element.points[element.points.length - 1],
|
||||
elements,
|
||||
elementsMap,
|
||||
),
|
||||
element,
|
||||
elements,
|
||||
elementsMap,
|
||||
app.scene,
|
||||
);
|
||||
app.scene,
|
||||
hit,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Attached text might need to update if arrow dimensions change
|
||||
@@ -661,7 +697,9 @@ export class LinearElementEditor {
|
||||
"start",
|
||||
elementsMap,
|
||||
app.state.zoom,
|
||||
app.state.isMidpointSnappingEnabled,
|
||||
app.state.isMidpointSnappingEnabled &&
|
||||
!angleLocked &&
|
||||
!app.state.gridModeEnabled,
|
||||
)
|
||||
: linearElementEditor.initialState.altFocusPoint,
|
||||
},
|
||||
@@ -764,12 +802,13 @@ export class LinearElementEditor {
|
||||
...editingLinearElement.initialState,
|
||||
origin: null,
|
||||
arrowStartIsInside: false,
|
||||
arrowOtherEndpointInitialBinding: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static getEditorMidPoints = (
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
element: ExcalidrawLinearElement,
|
||||
elementsMap: ElementsMap,
|
||||
appState: InteractiveCanvasAppState,
|
||||
): (GlobalPoint | null)[] => {
|
||||
@@ -893,7 +932,7 @@ export class LinearElementEditor {
|
||||
};
|
||||
|
||||
static isSegmentTooShort<P extends GlobalPoint | LocalPoint>(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
element: ExcalidrawLinearElement,
|
||||
startPoint: P,
|
||||
endPoint: P,
|
||||
index: number,
|
||||
@@ -934,7 +973,7 @@ export class LinearElementEditor {
|
||||
}
|
||||
|
||||
static getSegmentMidPoint(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
element: ExcalidrawLinearElement,
|
||||
index: number,
|
||||
elementsMap: ElementsMap,
|
||||
): GlobalPoint {
|
||||
@@ -1015,6 +1054,8 @@ export class LinearElementEditor {
|
||||
): {
|
||||
didAddPoint: boolean;
|
||||
hitElement: NonDeleted<ExcalidrawElement> | null;
|
||||
/** the pointer went down on the arrow's label, grabbing it for a drag */
|
||||
hitBoundText: boolean;
|
||||
linearElementEditor: LinearElementEditor | null;
|
||||
} {
|
||||
const appState = app.state;
|
||||
@@ -1023,6 +1064,7 @@ export class LinearElementEditor {
|
||||
const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
|
||||
didAddPoint: false,
|
||||
hitElement: null,
|
||||
hitBoundText: false,
|
||||
linearElementEditor: null,
|
||||
};
|
||||
|
||||
@@ -1081,10 +1123,9 @@ export class LinearElementEditor {
|
||||
index: segmentMidpointIndex,
|
||||
added: false,
|
||||
},
|
||||
arrowStartIsInside:
|
||||
!!app.state.newElement &&
|
||||
(app.state.bindMode === "inside" || app.state.bindMode === "skip"),
|
||||
arrowStartIsInside: false,
|
||||
altFocusPoint: null,
|
||||
arrowOtherEndpointInitialBinding: element.startBinding,
|
||||
},
|
||||
selectedPointsIndices: [element.points.length - 1],
|
||||
lastUncommittedPoint: null,
|
||||
@@ -1095,16 +1136,51 @@ export class LinearElementEditor {
|
||||
return ret;
|
||||
}
|
||||
|
||||
const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor(
|
||||
const pointIndexUnderCursor = LinearElementEditor.getPointIndexUnderCursor(
|
||||
element,
|
||||
elementsMap,
|
||||
appState.zoom,
|
||||
scenePointer.x,
|
||||
scenePointer.y,
|
||||
);
|
||||
// if we clicked on a point, set the element as hitElement otherwise
|
||||
// it would get deselected if the point is outside the hitbox area
|
||||
if (clickedPointIndex >= 0 || segmentMidpoint) {
|
||||
const clickedPointIsHandle = LinearElementEditor.isPointHandle(
|
||||
element,
|
||||
pointIndexUnderCursor,
|
||||
);
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
let boundTextGrabOffset: { x: number; y: number } | null = null;
|
||||
if (
|
||||
!clickedPointIsHandle &&
|
||||
// the segment midpoint knob keeps precedence over the label sitting
|
||||
// on top of it, so a labeled arrow can still be bent at its middle
|
||||
!segmentMidpoint &&
|
||||
boundTextElement &&
|
||||
isArrowElement(element) &&
|
||||
app.arrowText.isBoundTextGrabbable(
|
||||
element,
|
||||
scenePointer.x,
|
||||
scenePointer.y,
|
||||
)
|
||||
) {
|
||||
const { x: textX, y: textY } =
|
||||
LinearElementEditor.getBoundTextElementPosition(
|
||||
element,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
boundTextGrabOffset = {
|
||||
x: scenePointer.x - (textX + boundTextElement.width / 2),
|
||||
y: scenePointer.y - (textY + boundTextElement.height / 2),
|
||||
};
|
||||
}
|
||||
|
||||
// when the label grab wins (possible over a non-interactive elbow route
|
||||
// point), the click must not double as a point click
|
||||
const clickedPointIndex = boundTextGrabOffset ? -1 : pointIndexUnderCursor;
|
||||
ret.hitBoundText = !!boundTextGrabOffset;
|
||||
|
||||
if (clickedPointIndex >= 0 || segmentMidpoint || boundTextGrabOffset) {
|
||||
ret.hitElement = element;
|
||||
}
|
||||
|
||||
@@ -1143,10 +1219,11 @@ export class LinearElementEditor {
|
||||
index: segmentMidpointIndex,
|
||||
added: false,
|
||||
},
|
||||
arrowStartIsInside:
|
||||
!!app.state.newElement &&
|
||||
(app.state.bindMode === "inside" || app.state.bindMode === "skip"),
|
||||
arrowStartIsInside: false,
|
||||
altFocusPoint: null,
|
||||
arrowOtherEndpointInitialBinding: nextSelectedPointsIndices?.includes(0)
|
||||
? element.endBinding
|
||||
: element.startBinding,
|
||||
},
|
||||
selectedPointsIndices: nextSelectedPointsIndices,
|
||||
pointerOffset: targetPoint
|
||||
@@ -1154,7 +1231,7 @@ export class LinearElementEditor {
|
||||
x: scenePointer.x - targetPoint[0],
|
||||
y: scenePointer.y - targetPoint[1],
|
||||
}
|
||||
: { x: 0, y: 0 },
|
||||
: boundTextGrabOffset ?? { x: 0, y: 0 },
|
||||
};
|
||||
|
||||
return ret;
|
||||
@@ -1254,7 +1331,7 @@ export class LinearElementEditor {
|
||||
|
||||
/** scene coords */
|
||||
static getPointGlobalCoordinates(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
element: ExcalidrawLinearElement,
|
||||
p: LocalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
): GlobalPoint {
|
||||
@@ -1272,7 +1349,7 @@ export class LinearElementEditor {
|
||||
|
||||
/** scene coords */
|
||||
static getPointsGlobalCoordinates(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
element: ExcalidrawLinearElement,
|
||||
elementsMap: ElementsMap,
|
||||
): GlobalPoint[] {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
@@ -1289,7 +1366,7 @@ export class LinearElementEditor {
|
||||
}
|
||||
|
||||
static getPointAtIndexGlobalCoordinates(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
element: ExcalidrawLinearElement,
|
||||
indexMaybeFromEnd: number, // -1 for last element
|
||||
elementsMap: ElementsMap,
|
||||
): GlobalPoint {
|
||||
@@ -1312,7 +1389,7 @@ export class LinearElementEditor {
|
||||
}
|
||||
|
||||
static pointFromAbsoluteCoords(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
element: ExcalidrawLinearElement,
|
||||
absoluteCoords: GlobalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
): LocalPoint {
|
||||
@@ -1335,8 +1412,21 @@ export class LinearElementEditor {
|
||||
return pointFrom(x - element.x, y - element.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the point at `index` is an interactive handle. Elbow arrows only
|
||||
* expose their endpoints; their intermediate route points are not draggable.
|
||||
*/
|
||||
static isPointHandle(element: ExcalidrawLinearElement, index: number) {
|
||||
return (
|
||||
index >= 0 &&
|
||||
(!isElbowArrow(element) ||
|
||||
index === 0 ||
|
||||
index === element.points.length - 1)
|
||||
);
|
||||
}
|
||||
|
||||
static getPointIndexUnderCursor(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
element: ExcalidrawLinearElement,
|
||||
elementsMap: ElementsMap,
|
||||
zoom: AppState["zoom"],
|
||||
x: number,
|
||||
@@ -1364,7 +1454,7 @@ export class LinearElementEditor {
|
||||
}
|
||||
|
||||
static createPointAt(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
element: ExcalidrawLinearElement,
|
||||
elementsMap: ElementsMap,
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
@@ -1798,7 +1888,7 @@ export class LinearElementEditor {
|
||||
}
|
||||
|
||||
private static _getShiftLockedDelta(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
element: ExcalidrawLinearElement,
|
||||
elementsMap: ElementsMap,
|
||||
referencePoint: LocalPoint,
|
||||
scenePointer: GlobalPoint,
|
||||
@@ -1839,41 +1929,200 @@ export class LinearElementEditor {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The point a bound text label is centered on — the middle of the linear
|
||||
* element, whether that's an actual point or the midpoint of the middle
|
||||
* segment.
|
||||
*/
|
||||
static getBoundTextElementCenter = (
|
||||
element: ExcalidrawLinearElement,
|
||||
elementsMap: ElementsMap,
|
||||
): GlobalPoint => {
|
||||
if (element.points.length % 2 === 1) {
|
||||
const index = Math.floor(element.points.length / 2);
|
||||
return LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
element.points[index],
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
|
||||
const index = element.points.length / 2 - 1;
|
||||
return LinearElementEditor.getSegmentMidPoint(
|
||||
element,
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
};
|
||||
|
||||
static handleBoundTextDragging(
|
||||
linearElementEditor: LinearElementEditor,
|
||||
scene: Scene,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
): LinearElementEditor | null {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const element = LinearElementEditor.getElement(
|
||||
linearElementEditor.elementId,
|
||||
elementsMap,
|
||||
);
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (!element || !isArrowElement(element) || !boundTextElement) {
|
||||
return null;
|
||||
}
|
||||
const pointerGlobalPoint = pointFrom<GlobalPoint>(
|
||||
pointerX - linearElementEditor.pointerOffset.x,
|
||||
pointerY - linearElementEditor.pointerOffset.y,
|
||||
);
|
||||
const { segments, lengths, prefixSums, totalLength } =
|
||||
getLinearElementPathMetrics(element, elementsMap);
|
||||
if (segments.length === 0 || totalLength === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let bestDistance = Infinity;
|
||||
let bestSegmentIndex = 0;
|
||||
let bestParameter = 0;
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const { t, distance } = pathSegmentClosestParameter(
|
||||
segments[i],
|
||||
pointerGlobalPoint,
|
||||
);
|
||||
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
bestSegmentIndex = i;
|
||||
bestParameter = t;
|
||||
}
|
||||
}
|
||||
|
||||
const lengthWithinSegment = pathSegmentLengthAtParameter(
|
||||
segments[bestSegmentIndex],
|
||||
bestParameter,
|
||||
lengths[bestSegmentIndex],
|
||||
);
|
||||
|
||||
const labelPosition = clamp(
|
||||
(prefixSums[bestSegmentIndex] + lengthWithinSegment) / totalLength,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
const pathPoint = LinearElementEditor.getPointAtPathParameter(
|
||||
element,
|
||||
labelPosition,
|
||||
elementsMap,
|
||||
);
|
||||
if (!pathPoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
scene.mutateElement(boundTextElement, {
|
||||
labelPosition,
|
||||
x: pathPoint[0] - boundTextElement.width / 2,
|
||||
y: pathPoint[1] - boundTextElement.height / 2,
|
||||
});
|
||||
|
||||
return {
|
||||
...linearElementEditor,
|
||||
isDragging: true,
|
||||
};
|
||||
}
|
||||
|
||||
static getPointAtPathParameter(
|
||||
container: ExcalidrawLinearElement,
|
||||
pathParameter: number,
|
||||
elementsMap: ElementsMap,
|
||||
): GlobalPoint | null {
|
||||
if (!Number.isFinite(pathParameter)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { segments, lengths, prefixSums, totalLength } =
|
||||
getLinearElementPathMetrics(container, elementsMap);
|
||||
if (segments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetLength = clamp(pathParameter, 0, 1) * totalLength;
|
||||
|
||||
// the first segment whose end lies at or beyond the target; the last one
|
||||
// catches a target that floating point pushed past the total length
|
||||
const found = segments.findIndex(
|
||||
(_, i) => targetLength <= prefixSums[i + 1],
|
||||
);
|
||||
const i = found === -1 ? segments.length - 1 : found;
|
||||
const lengthFraction =
|
||||
lengths[i] === 0
|
||||
? 0
|
||||
: clamp((targetLength - prefixSums[i]) / lengths[i], 0, 1);
|
||||
|
||||
return pathSegmentPointAtLength(segments[i], lengthFraction, lengths[i]);
|
||||
}
|
||||
|
||||
static getBoundTextElementPosition = (
|
||||
element: ExcalidrawLinearElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
elementsMap: ElementsMap,
|
||||
): { x: number; y: number } => {
|
||||
const points = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
// derived on every hit test, bounds query and render of the label, so
|
||||
// memoize on the pair of versions the result depends on
|
||||
const cached = BoundTextPositionCache.get(boundTextElement);
|
||||
if (
|
||||
cached &&
|
||||
cached.containerVersion === element.version &&
|
||||
cached.textVersion === boundTextElement.version
|
||||
) {
|
||||
return cached.position;
|
||||
}
|
||||
|
||||
const position = LinearElementEditor.computeBoundTextElementPosition(
|
||||
element,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
if (points.length < 2) {
|
||||
mutateElement(boundTextElement, elementsMap, { isDeleted: true });
|
||||
|
||||
BoundTextPositionCache.set(boundTextElement, {
|
||||
containerVersion: element.version,
|
||||
textVersion: boundTextElement.version,
|
||||
position,
|
||||
});
|
||||
|
||||
return position;
|
||||
};
|
||||
|
||||
private static computeBoundTextElementPosition = (
|
||||
element: ExcalidrawLinearElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
elementsMap: ElementsMap,
|
||||
): { x: number; y: number } => {
|
||||
if (element.points.length < 2) {
|
||||
return { x: boundTextElement.x, y: boundTextElement.y };
|
||||
}
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
if (element.points.length % 2 === 1) {
|
||||
const index = Math.floor(element.points.length / 2);
|
||||
const midPoint = LinearElementEditor.getPointGlobalCoordinates(
|
||||
if (isArrowElement(element) && boundTextElement.labelPosition != null) {
|
||||
const pathPoint = LinearElementEditor.getPointAtPathParameter(
|
||||
element,
|
||||
element.points[index],
|
||||
elementsMap,
|
||||
);
|
||||
x = midPoint[0] - boundTextElement.width / 2;
|
||||
y = midPoint[1] - boundTextElement.height / 2;
|
||||
} else {
|
||||
const index = element.points.length / 2 - 1;
|
||||
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||
element,
|
||||
index + 1,
|
||||
boundTextElement.labelPosition,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
|
||||
y = midSegmentMidpoint[1] - boundTextElement.height / 2;
|
||||
if (pathPoint) {
|
||||
return {
|
||||
x: pathPoint[0] - boundTextElement.width / 2,
|
||||
y: pathPoint[1] - boundTextElement.height / 2,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { x, y };
|
||||
|
||||
const center = LinearElementEditor.getBoundTextElementCenter(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
return {
|
||||
x: center[0] - boundTextElement.width / 2,
|
||||
y: center[1] - boundTextElement.height / 2,
|
||||
};
|
||||
};
|
||||
|
||||
static getMinMaxXYWithBoundText = (
|
||||
@@ -2136,16 +2385,17 @@ const pointDraggingUpdates = (
|
||||
): {
|
||||
positions: PointsPositionUpdates;
|
||||
updates?: PointMoveOtherUpdates;
|
||||
hit?: NonDeleted<ExcalidrawBindableElement> | null;
|
||||
} => {
|
||||
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,
|
||||
},
|
||||
];
|
||||
@@ -2176,6 +2426,7 @@ const pointDraggingUpdates = (
|
||||
newArrow: !!app.state.newElement,
|
||||
angleLocked,
|
||||
altKey,
|
||||
gridSize: app.getEffectiveGridSize(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2193,18 +2444,21 @@ const pointDraggingUpdates = (
|
||||
? {
|
||||
element: suggestedBindingElement,
|
||||
midPoint: app.state.isMidpointSnappingEnabled
|
||||
? snapToMid(
|
||||
suggestedBindingElement,
|
||||
elementsMap,
|
||||
? getSnapOutlineMidPoint(
|
||||
pointFrom<GlobalPoint>(
|
||||
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||
),
|
||||
suggestedBindingElement,
|
||||
elementsMap,
|
||||
app.state.zoom,
|
||||
element,
|
||||
)
|
||||
: undefined,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
hit: suggestedBindingElement,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2290,11 +2544,7 @@ const pointDraggingUpdates = (
|
||||
),
|
||||
};
|
||||
|
||||
if (
|
||||
startIsDragged &&
|
||||
(updates.startBinding.mode === "orbit" ||
|
||||
!getFeatureFlag("COMPLEX_BINDINGS"))
|
||||
) {
|
||||
if (startIsDragged) {
|
||||
updates.suggestedBinding = start.element
|
||||
? {
|
||||
element: start.element,
|
||||
@@ -2306,6 +2556,7 @@ const pointDraggingUpdates = (
|
||||
start.element,
|
||||
elementsMap,
|
||||
app.state.zoom,
|
||||
element,
|
||||
),
|
||||
}
|
||||
: null;
|
||||
@@ -2329,11 +2580,7 @@ const pointDraggingUpdates = (
|
||||
),
|
||||
};
|
||||
|
||||
if (
|
||||
endIsDragged &&
|
||||
(updates.endBinding.mode === "orbit" ||
|
||||
!getFeatureFlag("COMPLEX_BINDINGS"))
|
||||
) {
|
||||
if (endIsDragged) {
|
||||
updates.suggestedBinding = end.element
|
||||
? {
|
||||
element: end.element,
|
||||
@@ -2345,6 +2592,7 @@ const pointDraggingUpdates = (
|
||||
end.element,
|
||||
elementsMap,
|
||||
app.state.zoom,
|
||||
element,
|
||||
),
|
||||
}
|
||||
: null;
|
||||
@@ -2387,19 +2635,6 @@ const pointDraggingUpdates = (
|
||||
: updates.endBinding,
|
||||
};
|
||||
|
||||
// Needed to handle a special case where an existing arrow is dragged over
|
||||
// the same element it is bound to on the other side
|
||||
const startIsDraggingOverEndElement =
|
||||
element.endBinding &&
|
||||
nextArrow.startBinding &&
|
||||
startIsDragged &&
|
||||
nextArrow.startBinding.elementId === element.endBinding.elementId;
|
||||
const endIsDraggingOverStartElement =
|
||||
element.startBinding &&
|
||||
nextArrow.endBinding &&
|
||||
endIsDragged &&
|
||||
element.startBinding.elementId === nextArrow.endBinding.elementId;
|
||||
|
||||
// We need to update the non-dragged point too if bound,
|
||||
// so we look up the old binding to trigger updateBoundPoint
|
||||
const endBindable = nextArrow.endBinding
|
||||
@@ -2409,13 +2644,7 @@ const pointDraggingUpdates = (
|
||||
)! as ExcalidrawBindableElement)
|
||||
: null;
|
||||
|
||||
const endLocalPoint = startIsDraggingOverEndElement
|
||||
? nextArrow.points[nextArrow.points.length - 1]
|
||||
: endIsDraggingOverStartElement &&
|
||||
app.state.bindMode !== "inside" &&
|
||||
getFeatureFlag("COMPLEX_BINDINGS")
|
||||
? nextArrow.points[0]
|
||||
: endBindable
|
||||
const endLocalPoint = endBindable
|
||||
? updateBoundPoint(
|
||||
nextArrow,
|
||||
"endBinding",
|
||||
@@ -2439,32 +2668,17 @@ const pointDraggingUpdates = (
|
||||
)! as ExcalidrawBindableElement)
|
||||
: null;
|
||||
|
||||
const startLocalPoint =
|
||||
endIsDraggingOverStartElement && getFeatureFlag("COMPLEX_BINDINGS")
|
||||
? nextArrow.points[0]
|
||||
: startIsDraggingOverEndElement &&
|
||||
app.state.bindMode !== "inside" &&
|
||||
getFeatureFlag("COMPLEX_BINDINGS")
|
||||
? endLocalPoint
|
||||
: startBindable
|
||||
? updateBoundPoint(
|
||||
nextArrow,
|
||||
"startBinding",
|
||||
nextArrow.startBinding,
|
||||
startBindable,
|
||||
elementsMap,
|
||||
startIsDragged,
|
||||
) || nextArrow.points[0]
|
||||
: nextArrow.points[0];
|
||||
const startLocalPoint = startBindable
|
||||
? updateBoundPoint(
|
||||
nextArrow,
|
||||
"startBinding",
|
||||
nextArrow.startBinding,
|
||||
startBindable,
|
||||
elementsMap,
|
||||
startIsDragged,
|
||||
) || nextArrow.points[0]
|
||||
: nextArrow.points[0];
|
||||
|
||||
const endChanged =
|
||||
!startIsDraggingOverEndElement &&
|
||||
!(
|
||||
endIsDraggingOverStartElement &&
|
||||
app.state.bindMode !== "inside" &&
|
||||
getFeatureFlag("COMPLEX_BINDINGS")
|
||||
) &&
|
||||
!!endBindable;
|
||||
const startChanged =
|
||||
pointDistance(startLocalPoint, nextArrow.points[0]) !== 0;
|
||||
|
||||
@@ -2472,7 +2686,7 @@ const pointDraggingUpdates = (
|
||||
if (startBindable && startChanged) {
|
||||
indicesSet.add(0);
|
||||
}
|
||||
if (endBindable && endChanged) {
|
||||
if (endBindable) {
|
||||
indicesSet.add(element.points.length - 1);
|
||||
}
|
||||
const indices = Array.from(indicesSet);
|
||||
@@ -2497,6 +2711,7 @@ const pointDraggingUpdates = (
|
||||
];
|
||||
}),
|
||||
),
|
||||
hit: startIsDragged ? start.element : end.element,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2505,3 +2720,93 @@ const determineCustomLinearAngle = (
|
||||
draggedPoint: LocalPoint,
|
||||
) =>
|
||||
Math.atan2(draggedPoint[1] - pivotPoint[1], draggedPoint[0] - pivotPoint[0]);
|
||||
|
||||
type LinearElementPathMetrics = {
|
||||
segments: LinearPathSegment[];
|
||||
lengths: number[];
|
||||
prefixSums: number[];
|
||||
totalLength: number;
|
||||
};
|
||||
|
||||
const LinearElementPathMetricsCache = new WeakMap<
|
||||
ExcalidrawElement,
|
||||
{ version: ExcalidrawElement["version"]; metrics: LinearElementPathMetrics }
|
||||
>();
|
||||
|
||||
function getLinearElementPathMetrics(
|
||||
element: ExcalidrawLinearElement,
|
||||
elementsMap: ElementsMap,
|
||||
): LinearElementPathMetrics {
|
||||
const cached = LinearElementPathMetricsCache.get(element);
|
||||
|
||||
if (cached) {
|
||||
if (cached.version === element.version) {
|
||||
return cached.metrics;
|
||||
}
|
||||
|
||||
LinearElementPathMetricsCache.delete(element);
|
||||
}
|
||||
|
||||
const segments = getLinearElementPathSegments(element, elementsMap);
|
||||
const lengths = segments.map(pathSegmentLength);
|
||||
const prefixSums = new Array<number>(lengths.length + 1);
|
||||
|
||||
prefixSums[0] = 0;
|
||||
for (let i = 0; i < lengths.length; i++) {
|
||||
prefixSums[i + 1] = prefixSums[i] + lengths[i];
|
||||
}
|
||||
|
||||
const metrics: LinearElementPathMetrics = {
|
||||
segments,
|
||||
lengths,
|
||||
prefixSums,
|
||||
totalLength: prefixSums[lengths.length],
|
||||
};
|
||||
|
||||
LinearElementPathMetricsCache.set(element, {
|
||||
version: element.version,
|
||||
metrics,
|
||||
});
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
const pathSegmentLength = (segment: LinearPathSegment): number =>
|
||||
isCurve(segment)
|
||||
? curveLength(segment)
|
||||
: pointDistance(segment[0], segment[1]);
|
||||
|
||||
/**
|
||||
* The segment's parameter `t` (`0..1`) at the point closest to `point`, and
|
||||
* how far that point is from `point`.
|
||||
*/
|
||||
const pathSegmentClosestParameter = (
|
||||
segment: LinearPathSegment,
|
||||
point: GlobalPoint,
|
||||
): { t: number; distance: number } => {
|
||||
if (isCurve(segment)) {
|
||||
const t = curveClosestParameter(segment, point);
|
||||
return { t, distance: pointDistance(point, bezierEquation(segment, t)) };
|
||||
}
|
||||
|
||||
const t = lineSegmentClosestParameter(point, segment);
|
||||
return { t, distance: pointDistance(point, lineSegmentPointAt(segment, t)) };
|
||||
};
|
||||
|
||||
/** arc length from the segment's start to its parameter `t` */
|
||||
const pathSegmentLengthAtParameter = (
|
||||
segment: LinearPathSegment,
|
||||
t: number,
|
||||
segmentLength: number,
|
||||
): number =>
|
||||
isCurve(segment) ? curveLengthAtParameter(segment, t) : t * segmentLength;
|
||||
|
||||
/** point at `fraction` (`0..1`) of the segment's arc length */
|
||||
const pathSegmentPointAtLength = (
|
||||
segment: LinearPathSegment,
|
||||
fraction: number,
|
||||
segmentLength: number,
|
||||
): GlobalPoint =>
|
||||
isCurve(segment)
|
||||
? curvePointAtLength(segment, fraction, segmentLength)
|
||||
: lineSegmentPointAt(segment, fraction);
|
||||
|
||||
@@ -21,9 +21,12 @@ import type {
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "./types";
|
||||
|
||||
// `created` is lifetime metadata: it is set once when an element instance is
|
||||
// constructed (or duplicated) and never updated, so it is not part of an
|
||||
// element update. An explicit repair must construct a new element object.
|
||||
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
||||
Partial<TElement>,
|
||||
"id" | "updated"
|
||||
"id" | "updated" | "created"
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -61,7 +64,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
angle: 0 as Radians,
|
||||
...updateElbowArrowPoints(
|
||||
{
|
||||
...element,
|
||||
...(element as ExcalidrawElbowArrowElement),
|
||||
x: updates.x || element.x,
|
||||
y: updates.y || element.y,
|
||||
},
|
||||
|
||||
@@ -3,7 +3,10 @@ import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
DEFAULT_STICKY_NOTE_SIZE,
|
||||
STICKY_NOTE_MIN_SIZE,
|
||||
DEFAULT_VERTICAL_ALIGN,
|
||||
DEFAULT_STROKE_STREAMLINE,
|
||||
VERTICAL_ALIGN,
|
||||
randomInteger,
|
||||
randomId,
|
||||
@@ -21,6 +24,10 @@ import {
|
||||
getResizedElementAbsoluteCoords,
|
||||
} from "./bounds";
|
||||
import { newElementWith } from "./mutateElement";
|
||||
import {
|
||||
normalizeStickyNoteBackgroundColor,
|
||||
normalizeStickyNoteStrokeColor,
|
||||
} from "./stickyNote";
|
||||
import { getBoundTextMaxWidth } from "./textElement";
|
||||
import { normalizeText, measureText } from "./textMeasurements";
|
||||
import { wrapText } from "./textWrapping";
|
||||
@@ -48,6 +55,7 @@ import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawLineElement,
|
||||
ExcalidrawStickyNoteElement,
|
||||
} from "./types";
|
||||
|
||||
export type ElementConstructorOpts = MarkOptional<
|
||||
@@ -73,6 +81,7 @@ export type ElementConstructorOpts = MarkOptional<
|
||||
| "locked"
|
||||
| "opacity"
|
||||
| "customData"
|
||||
| "created"
|
||||
>;
|
||||
|
||||
const _newElementBase = <T extends ExcalidrawElement>(
|
||||
@@ -122,8 +131,13 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||
});
|
||||
}
|
||||
|
||||
const timestamp = getUpdatedTimestamp();
|
||||
|
||||
// assign type to guard against excess properties
|
||||
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
|
||||
const element: Merge<
|
||||
ExcalidrawGenericElement,
|
||||
{ type: T["type"]; isDeleted: false }
|
||||
> = {
|
||||
id: rest.id || randomId(),
|
||||
type,
|
||||
x,
|
||||
@@ -147,7 +161,9 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||
versionNonce: rest.versionNonce ?? 0,
|
||||
isDeleted: false as false,
|
||||
boundElements,
|
||||
updated: getUpdatedTimestamp(),
|
||||
updated: timestamp,
|
||||
// Preserve explicit null when reconstructing a legacy element with its id.
|
||||
created: rest.created === undefined ? timestamp : rest.created,
|
||||
link,
|
||||
locked,
|
||||
customData: rest.customData,
|
||||
@@ -162,6 +178,70 @@ export const newElement = (
|
||||
): NonDeleted<ExcalidrawGenericElement> =>
|
||||
_newElementBase<ExcalidrawGenericElement>(opts.type, opts);
|
||||
|
||||
/**
|
||||
* Style invariants of a sticky note: never-transparent colors, solid fill.
|
||||
* Applied by the constructor and by every normalization pass. Returns the
|
||||
* same object when nothing needs fixing.
|
||||
*/
|
||||
export const normalizeStickyNoteStyle = <T extends ExcalidrawStickyNoteElement>(
|
||||
element: T,
|
||||
): T => {
|
||||
return newElementWith(element as ExcalidrawStickyNoteElement, {
|
||||
backgroundColor: normalizeStickyNoteBackgroundColor(
|
||||
element.backgroundColor,
|
||||
),
|
||||
strokeColor: normalizeStickyNoteStrokeColor(element.strokeColor),
|
||||
fillStyle: "solid",
|
||||
}) as T;
|
||||
};
|
||||
|
||||
/**
|
||||
* Geometry invariants of a *finalized* sticky note: minimum size and
|
||||
* `baseHeight ≤ height`. Deliberately not part of the constructor — a
|
||||
* pointer-down draft starts at 0×0 like every other tool and is previewed at
|
||||
* its true dragged size; pointer-up, restore, the skeleton path and the
|
||||
* action post-passes enforce this.
|
||||
*/
|
||||
export const normalizeStickyNoteGeometry = <
|
||||
T extends ExcalidrawStickyNoteElement,
|
||||
>(
|
||||
element: T,
|
||||
): T => {
|
||||
const width = Math.max(element.width, STICKY_NOTE_MIN_SIZE);
|
||||
const baseHeight = Math.max(
|
||||
element.baseHeight || element.height || DEFAULT_STICKY_NOTE_SIZE,
|
||||
STICKY_NOTE_MIN_SIZE,
|
||||
);
|
||||
|
||||
return newElementWith(element as ExcalidrawStickyNoteElement, {
|
||||
width,
|
||||
height: Math.max(element.height, baseHeight),
|
||||
baseHeight,
|
||||
}) as T;
|
||||
};
|
||||
|
||||
/** all sticky note invariants (style + finalized geometry) */
|
||||
export const normalizeStickyNote = <T extends ExcalidrawStickyNoteElement>(
|
||||
element: T,
|
||||
): T => {
|
||||
return normalizeStickyNoteGeometry(normalizeStickyNoteStyle(element));
|
||||
};
|
||||
|
||||
export const newStickyNoteElement = (
|
||||
opts: {
|
||||
type: "stickynote";
|
||||
baseHeight?: number;
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawStickyNoteElement> => {
|
||||
const base = _newElementBase<ExcalidrawStickyNoteElement>("stickynote", opts);
|
||||
|
||||
// no size inflation here (see `normalizeStickyNoteGeometry`)
|
||||
return normalizeStickyNoteStyle({
|
||||
...base,
|
||||
baseHeight: opts.baseHeight ?? base.height,
|
||||
});
|
||||
};
|
||||
|
||||
export const newEmbeddableElement = (
|
||||
opts: {
|
||||
type: "embeddable";
|
||||
@@ -214,6 +294,25 @@ export const newMagicFrameElement = (
|
||||
return frameElement;
|
||||
};
|
||||
|
||||
/**
|
||||
* The point of the text box its alignment pins, as ratios of width/height.
|
||||
*
|
||||
* This is the point that stays put as the text grows — see the sides passed to
|
||||
* `adjustXYWithRotation` in `getAdjustedDimensions`.
|
||||
*/
|
||||
export const getTextAnchorRatios = (opts: {
|
||||
textAlign: ExcalidrawTextElement["textAlign"];
|
||||
verticalAlign: ExcalidrawTextElement["verticalAlign"];
|
||||
}) => ({
|
||||
x: opts.textAlign === "center" ? 0.5 : opts.textAlign === "right" ? 1 : 0,
|
||||
y:
|
||||
opts.verticalAlign === VERTICAL_ALIGN.MIDDLE
|
||||
? 0.5
|
||||
: opts.verticalAlign === VERTICAL_ALIGN.BOTTOM
|
||||
? 1
|
||||
: 0,
|
||||
});
|
||||
|
||||
/** computes element x/y offset based on textAlign/verticalAlign */
|
||||
const getTextElementPositionOffsets = (
|
||||
opts: {
|
||||
@@ -225,14 +324,11 @@ const getTextElementPositionOffsets = (
|
||||
height: number;
|
||||
},
|
||||
) => {
|
||||
const ratios = getTextAnchorRatios(opts);
|
||||
|
||||
return {
|
||||
x:
|
||||
opts.textAlign === "center"
|
||||
? metrics.width / 2
|
||||
: opts.textAlign === "right"
|
||||
? metrics.width
|
||||
: 0,
|
||||
y: opts.verticalAlign === "middle" ? metrics.height / 2 : 0,
|
||||
x: metrics.width * ratios.x,
|
||||
y: metrics.height * ratios.y,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -247,6 +343,8 @@ export const newTextElement = (
|
||||
containerId?: ExcalidrawTextContainer["id"] | null;
|
||||
lineHeight?: ExcalidrawTextElement["lineHeight"];
|
||||
autoResize?: ExcalidrawTextElement["autoResize"];
|
||||
labelPosition?: ExcalidrawTextElement["labelPosition"];
|
||||
baseFontSize?: ExcalidrawTextElement["baseFontSize"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawTextElement> => {
|
||||
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
|
||||
@@ -265,10 +363,11 @@ export const newTextElement = (
|
||||
metrics,
|
||||
);
|
||||
|
||||
const textElementProps: ExcalidrawTextElement = {
|
||||
const textElementProps: NonDeleted<ExcalidrawTextElement> = {
|
||||
..._newElementBase<ExcalidrawTextElement>("text", opts),
|
||||
text,
|
||||
fontSize,
|
||||
baseFontSize: opts.baseFontSize ?? null,
|
||||
fontFamily,
|
||||
textAlign,
|
||||
verticalAlign,
|
||||
@@ -280,9 +379,10 @@ export const newTextElement = (
|
||||
originalText: opts.originalText ?? text,
|
||||
autoResize: opts.autoResize ?? true,
|
||||
lineHeight,
|
||||
labelPosition: opts.labelPosition ?? null,
|
||||
};
|
||||
|
||||
const textElement: ExcalidrawTextElement = newElementWith(
|
||||
const textElement: NonDeleted<ExcalidrawTextElement> = newElementWith(
|
||||
textElementProps,
|
||||
{},
|
||||
);
|
||||
@@ -294,6 +394,8 @@ const getAdjustedDimensions = (
|
||||
element: ExcalidrawTextElement,
|
||||
elementsMap: ElementsMap,
|
||||
nextText: string,
|
||||
/** the width the text is to take regardless of its content */
|
||||
nextFixedWidth?: number,
|
||||
): {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -307,7 +409,9 @@ const getAdjustedDimensions = (
|
||||
);
|
||||
|
||||
// wrapped text
|
||||
if (!element.autoResize) {
|
||||
if (nextFixedWidth !== undefined) {
|
||||
nextWidth = nextFixedWidth;
|
||||
} else if (!element.autoResize) {
|
||||
nextWidth = element.width;
|
||||
}
|
||||
|
||||
@@ -346,9 +450,18 @@ const getAdjustedDimensions = (
|
||||
const deltaX2 = (x2 - nextX2) / 2;
|
||||
const deltaY2 = (y2 - nextY2) / 2;
|
||||
|
||||
// grow away from the edge(s) the alignment anchors the text to, so that
|
||||
// the anchor stays put as the text is edited. `verticalAlign` has no
|
||||
// visual effect on unbound text, but standalone text bound to an arrow
|
||||
// endpoint uses it to pin the side the arrow attaches to.
|
||||
[x, y] = adjustXYWithRotation(
|
||||
{
|
||||
s: true,
|
||||
n:
|
||||
verticalAlign === VERTICAL_ALIGN.MIDDLE ||
|
||||
verticalAlign === VERTICAL_ALIGN.BOTTOM,
|
||||
s:
|
||||
verticalAlign === VERTICAL_ALIGN.MIDDLE ||
|
||||
verticalAlign === VERTICAL_ALIGN.TOP,
|
||||
e: textAlign === "center" || textAlign === "left",
|
||||
w: textAlign === "center" || textAlign === "right",
|
||||
},
|
||||
@@ -422,10 +535,38 @@ export const refreshTextDimensions = (
|
||||
container: ExcalidrawTextContainer | null,
|
||||
elementsMap: ElementsMap,
|
||||
text = textElement.text,
|
||||
/**
|
||||
* For a free text that grows with its content (`autoResize`): the widest
|
||||
* it may grow to. Crossing it, the text stops growing and wraps at this
|
||||
* width from then on (`autoResize: false`), anchored as its growth was. A
|
||||
* text that is already wider keeps growing.
|
||||
*/
|
||||
maxWidth?: number,
|
||||
) => {
|
||||
if (textElement.isDeleted) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
maxWidth !== undefined &&
|
||||
!container &&
|
||||
textElement.autoResize &&
|
||||
textElement.width <= maxWidth
|
||||
) {
|
||||
const font = getFontString(textElement);
|
||||
if (measureText(text, font, textElement.lineHeight).width > maxWidth) {
|
||||
const wrappedText = wrapText(text, font, maxWidth);
|
||||
return {
|
||||
text: wrappedText,
|
||||
autoResize: false,
|
||||
...getAdjustedDimensions(
|
||||
textElement,
|
||||
elementsMap,
|
||||
wrappedText,
|
||||
maxWidth,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
if (container || !textElement.autoResize) {
|
||||
text = wrapText(
|
||||
text,
|
||||
@@ -444,6 +585,7 @@ export const newFreeDrawElement = (
|
||||
type: "freedraw";
|
||||
points?: ExcalidrawFreeDrawElement["points"];
|
||||
simulatePressure: boolean;
|
||||
strokeOptions?: ExcalidrawFreeDrawElement["strokeOptions"];
|
||||
pressures?: ExcalidrawFreeDrawElement["pressures"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawFreeDrawElement> => {
|
||||
@@ -452,6 +594,10 @@ export const newFreeDrawElement = (
|
||||
points: opts.points || [],
|
||||
pressures: opts.pressures || [],
|
||||
simulatePressure: opts.simulatePressure,
|
||||
strokeOptions: opts.strokeOptions ?? {
|
||||
variability: "variable",
|
||||
streamline: DEFAULT_STROKE_STREAMLINE,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import rough from "roughjs/bin/rough";
|
||||
|
||||
import {
|
||||
clamp,
|
||||
type GlobalPoint,
|
||||
isRightAngleRads,
|
||||
lineSegment,
|
||||
@@ -24,6 +25,11 @@ import {
|
||||
invariant,
|
||||
applyDarkModeFilter,
|
||||
isSafari,
|
||||
STICKY_NOTE_EDGE_SHADOW_OPACITY,
|
||||
STICKY_NOTE_EDGE_SHADOW_WIDTH,
|
||||
STICKY_NOTE_FOOTER,
|
||||
STICKY_NOTE_SHADOW_OPACITY,
|
||||
DEFAULT_ZOOM,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
@@ -31,9 +37,7 @@ import type {
|
||||
StaticCanvasAppState,
|
||||
Zoom,
|
||||
InteractiveCanvasAppState,
|
||||
ElementsPendingErasure,
|
||||
PendingExcalidrawElements,
|
||||
NormalizedZoomValue,
|
||||
ElementRenderOverrides,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type {
|
||||
@@ -61,21 +65,27 @@ import {
|
||||
isArrowElement,
|
||||
hasBoundTextElement,
|
||||
isMagicFrameElement,
|
||||
isFrameLikeElement,
|
||||
isImageElement,
|
||||
} from "./typeChecks";
|
||||
import { getContainingFrame } from "./frame";
|
||||
import { getCornerRadius } from "./utils";
|
||||
|
||||
import { ShapeCache } from "./shape";
|
||||
import {
|
||||
getStickyNoteFooter,
|
||||
getStickyNotePathCommands,
|
||||
type StickyNotePathCommand,
|
||||
} from "./stickyNote";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
ExcalidrawFrameLikeElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
ElementsMap,
|
||||
} from "./types";
|
||||
@@ -105,30 +115,90 @@ const getCanvasPadding = (element: ExcalidrawElement) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const getRenderOpacity = (
|
||||
element: ExcalidrawElement,
|
||||
containingFrame: ExcalidrawFrameLikeElement | null,
|
||||
elementsPendingErasure: ElementsPendingErasure,
|
||||
pendingNodes: Readonly<PendingExcalidrawElements> | null,
|
||||
globalAlpha: number = 1,
|
||||
) => {
|
||||
// multiplying frame opacity with element opacity to combine them
|
||||
// (e.g. frame 50% and element 50% opacity should result in 25% opacity)
|
||||
let opacity =
|
||||
(((containingFrame?.opacity ?? 100) * element.opacity) / 10000) *
|
||||
globalAlpha;
|
||||
export type RenderPositionOffset = Readonly<{ x: number; y: number }>;
|
||||
|
||||
// if pending erasure, multiply again to combine further
|
||||
// (so that erasing always results in lower opacity than original)
|
||||
const ZERO_RENDER_OFFSET: RenderPositionOffset = { x: 0, y: 0 };
|
||||
|
||||
/** Bound labels follow their container; a label's own offset is ignored. */
|
||||
export const getElementRenderOffset = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
overrides: ElementRenderOverrides | undefined,
|
||||
): RenderPositionOffset | undefined => {
|
||||
const container = isTextElement(element)
|
||||
? getContainerElement(element, elementsMap)
|
||||
: null;
|
||||
return overrides?.get((container ?? element).id)?.offset;
|
||||
};
|
||||
|
||||
export const getRenderElementWithPositionOverride = <
|
||||
TElement extends ExcalidrawElement,
|
||||
>(
|
||||
element: TElement,
|
||||
positionOffset: RenderPositionOffset,
|
||||
): TElement => {
|
||||
if (positionOffset.x === 0 && positionOffset.y === 0) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return {
|
||||
...element,
|
||||
x: element.x + positionOffset.x,
|
||||
y: element.y + positionOffset.y,
|
||||
} as TElement;
|
||||
};
|
||||
|
||||
export type ElementRenderState = Readonly<{
|
||||
/** Alpha including frame opacity and pending erasure; selection dimming is separate. */
|
||||
opacity: number;
|
||||
offset: RenderPositionOffset;
|
||||
}>;
|
||||
|
||||
/** Resolve visual state at the drawing boundary, preserving document cache keys. */
|
||||
export const resolveElementRenderState = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
renderConfig: Pick<
|
||||
StaticCanvasRenderConfig,
|
||||
| "elementRenderOverrides"
|
||||
| "elementsPendingErasure"
|
||||
| "pendingFlowchartNodes"
|
||||
>,
|
||||
allElementsMap: ElementsMap = elementsMap,
|
||||
): ElementRenderState => {
|
||||
const {
|
||||
elementRenderOverrides,
|
||||
elementsPendingErasure,
|
||||
pendingFlowchartNodes,
|
||||
} = renderConfig;
|
||||
const override = elementRenderOverrides?.get(element.id);
|
||||
const containingFrame = getContainingFrame(element, elementsMap);
|
||||
const frameOpacity = containingFrame
|
||||
? clamp(
|
||||
elementRenderOverrides?.get(containingFrame.id)?.opacity ??
|
||||
containingFrame.opacity,
|
||||
0,
|
||||
100,
|
||||
)
|
||||
: 100;
|
||||
// Frame and element alpha multiply (50% each produces 25%).
|
||||
let opacity =
|
||||
(frameOpacity * clamp(override?.opacity ?? element.opacity, 0, 100)) /
|
||||
10000;
|
||||
if (
|
||||
elementsPendingErasure.has(element.id) ||
|
||||
(pendingNodes && pendingNodes.some((node) => node.id === element.id)) ||
|
||||
pendingFlowchartNodes?.some((node) => node.id === element.id) ||
|
||||
(containingFrame && elementsPendingErasure.has(containingFrame.id))
|
||||
) {
|
||||
opacity *= ELEMENT_READY_TO_ERASE_OPACITY / 100;
|
||||
}
|
||||
const offset = getElementRenderOffset(
|
||||
element,
|
||||
allElementsMap,
|
||||
elementRenderOverrides,
|
||||
);
|
||||
|
||||
return opacity;
|
||||
return { opacity, offset: offset ?? ZERO_RENDER_OFFSET };
|
||||
};
|
||||
|
||||
export interface ExcalidrawElementWithCanvas {
|
||||
@@ -136,14 +206,11 @@ export interface ExcalidrawElementWithCanvas {
|
||||
canvas: HTMLCanvasElement;
|
||||
theme: AppState["theme"];
|
||||
scale: number;
|
||||
angle: number;
|
||||
zoomValue: AppState["zoom"]["value"];
|
||||
canvasOffsetX: number;
|
||||
canvasOffsetY: number;
|
||||
boundTextElementVersion: number | null;
|
||||
imageCrop: ExcalidrawImageElement["crop"] | null;
|
||||
containingFrameOpacity: number;
|
||||
boundTextCanvas: HTMLCanvasElement;
|
||||
}
|
||||
|
||||
const cappedElementCanvasSize = (
|
||||
@@ -257,69 +324,6 @@ const generateElementCanvas = (
|
||||
|
||||
context.restore();
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
const boundTextCanvas = document.createElement("canvas");
|
||||
const boundTextCanvasContext = boundTextCanvas.getContext("2d")!;
|
||||
|
||||
if (isArrowElement(element) && boundTextElement) {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
// 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));
|
||||
boundTextCanvas.width =
|
||||
maxDim * window.devicePixelRatio * scale + padding * scale * 10;
|
||||
boundTextCanvas.height =
|
||||
maxDim * window.devicePixelRatio * scale + padding * scale * 10;
|
||||
boundTextCanvasContext.translate(
|
||||
boundTextCanvas.width / 2,
|
||||
boundTextCanvas.height / 2,
|
||||
);
|
||||
boundTextCanvasContext.rotate(element.angle);
|
||||
boundTextCanvasContext.drawImage(
|
||||
canvas!,
|
||||
-canvas.width / 2,
|
||||
-canvas.height / 2,
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
);
|
||||
|
||||
const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords(
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
boundTextCanvasContext.rotate(-element.angle);
|
||||
const offsetX = (boundTextCanvas.width - canvas!.width) / 2;
|
||||
const offsetY = (boundTextCanvas.height - canvas!.height) / 2;
|
||||
const shiftX =
|
||||
boundTextCanvas.width / 2 -
|
||||
(boundTextCx - x1) * window.devicePixelRatio * scale -
|
||||
offsetX -
|
||||
padding * scale;
|
||||
|
||||
const shiftY =
|
||||
boundTextCanvas.height / 2 -
|
||||
(boundTextCy - y1) * window.devicePixelRatio * scale -
|
||||
offsetY -
|
||||
padding * scale;
|
||||
boundTextCanvasContext.translate(-shiftX, -shiftY);
|
||||
// Clear the bound text area
|
||||
boundTextCanvasContext.clearRect(
|
||||
-(boundTextElement.width / 2 + BOUND_TEXT_PADDING) *
|
||||
window.devicePixelRatio *
|
||||
scale,
|
||||
-(boundTextElement.height / 2 + BOUND_TEXT_PADDING) *
|
||||
window.devicePixelRatio *
|
||||
scale,
|
||||
(boundTextElement.width + BOUND_TEXT_PADDING * 2) *
|
||||
window.devicePixelRatio *
|
||||
scale,
|
||||
(boundTextElement.height + BOUND_TEXT_PADDING * 2) *
|
||||
window.devicePixelRatio *
|
||||
scale,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
element,
|
||||
canvas,
|
||||
@@ -328,12 +332,8 @@ const generateElementCanvas = (
|
||||
zoomValue: zoom.value,
|
||||
canvasOffsetX,
|
||||
canvasOffsetY,
|
||||
boundTextElementVersion:
|
||||
getBoundTextElement(element, elementsMap)?.version || null,
|
||||
containingFrameOpacity:
|
||||
getContainingFrame(element, elementsMap)?.opacity || 100,
|
||||
boundTextCanvas,
|
||||
angle: element.angle,
|
||||
imageCrop: isImageElement(element) ? element.crop : null,
|
||||
};
|
||||
};
|
||||
@@ -384,6 +384,50 @@ const drawImagePlaceholder = (
|
||||
);
|
||||
};
|
||||
|
||||
const drawStickyNotePath = (
|
||||
context: CanvasRenderingContext2D,
|
||||
commands: StickyNotePathCommand[],
|
||||
) => {
|
||||
context.beginPath();
|
||||
for (const command of commands) {
|
||||
if (command.type === "move") {
|
||||
context.moveTo(command.point.x, command.point.y);
|
||||
} else if (command.type === "line") {
|
||||
context.lineTo(command.point.x, command.point.y);
|
||||
} else {
|
||||
context.quadraticCurveTo(
|
||||
command.control.x,
|
||||
command.control.y,
|
||||
command.point.x,
|
||||
command.point.y,
|
||||
);
|
||||
}
|
||||
}
|
||||
context.closePath();
|
||||
};
|
||||
|
||||
const fillStickyNoteShape = (
|
||||
context: CanvasRenderingContext2D,
|
||||
commands: StickyNotePathCommand[],
|
||||
) => {
|
||||
drawStickyNotePath(context, commands);
|
||||
context.fill();
|
||||
};
|
||||
|
||||
const strokeStickyNoteEdge = (
|
||||
context: CanvasRenderingContext2D,
|
||||
commands: StickyNotePathCommand[],
|
||||
) => {
|
||||
context.save();
|
||||
drawStickyNotePath(context, commands);
|
||||
context.clip();
|
||||
context.lineWidth = STICKY_NOTE_EDGE_SHADOW_WIDTH * 2;
|
||||
context.strokeStyle = `rgba(0, 0, 0, ${STICKY_NOTE_EDGE_SHADOW_OPACITY})`;
|
||||
drawStickyNotePath(context, commands);
|
||||
context.stroke();
|
||||
context.restore();
|
||||
};
|
||||
|
||||
const drawElementOnCanvas = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
rc: RoughCanvas,
|
||||
@@ -391,6 +435,41 @@ const drawElementOnCanvas = (
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
) => {
|
||||
switch (element.type) {
|
||||
case "stickynote": {
|
||||
context.save();
|
||||
context.fillStyle = `rgba(0, 0, 0, ${STICKY_NOTE_SHADOW_OPACITY})`;
|
||||
fillStickyNoteShape(
|
||||
context,
|
||||
getStickyNotePathCommands(element, { shadow: true }),
|
||||
);
|
||||
|
||||
const commands = getStickyNotePathCommands(element);
|
||||
context.fillStyle = applyDarkModeFilter(
|
||||
element.backgroundColor,
|
||||
renderConfig.theme === THEME.DARK,
|
||||
);
|
||||
fillStickyNoteShape(context, commands);
|
||||
strokeStickyNoteEdge(context, commands);
|
||||
|
||||
// the label is absolute, so this cached canvas only goes stale at a
|
||||
// year boundary — and is regenerated on the next zoom, theme or
|
||||
// element change anyway
|
||||
const footer = getStickyNoteFooter(element);
|
||||
if (footer) {
|
||||
context.font = `${STICKY_NOTE_FOOTER.fontSize}px ${STICKY_NOTE_FOOTER.fontFamily}`;
|
||||
context.textAlign = "right";
|
||||
context.textBaseline = "alphabetic";
|
||||
context.fillStyle = applyDarkModeFilter(
|
||||
element.strokeColor,
|
||||
renderConfig.theme === THEME.DARK,
|
||||
);
|
||||
context.globalAlpha *= STICKY_NOTE_FOOTER.opacity;
|
||||
context.fillText(footer.text, footer.x, footer.y);
|
||||
}
|
||||
|
||||
context.restore();
|
||||
break;
|
||||
}
|
||||
case "rectangle":
|
||||
case "iframe":
|
||||
case "embeddable":
|
||||
@@ -422,10 +501,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 +634,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
|
||||
@@ -611,18 +690,12 @@ const generateElementWithCanvas = (
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||
) => {
|
||||
const zoom: Zoom = renderConfig
|
||||
? appState.zoom
|
||||
: {
|
||||
value: 1 as NormalizedZoomValue,
|
||||
};
|
||||
const zoom: Zoom = renderConfig ? appState.zoom : DEFAULT_ZOOM;
|
||||
const prevElementWithCanvas = elementWithCanvasCache.get(element);
|
||||
const shouldRegenerateBecauseZoom =
|
||||
prevElementWithCanvas &&
|
||||
prevElementWithCanvas.zoomValue !== zoom.value &&
|
||||
!appState?.shouldCacheIgnoreZoom;
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
const boundTextElementVersion = boundTextElement?.version || null;
|
||||
const imageCrop = isImageElement(element) ? element.crop : null;
|
||||
|
||||
const containingFrameOpacity =
|
||||
@@ -632,16 +705,8 @@ const generateElementWithCanvas = (
|
||||
!prevElementWithCanvas ||
|
||||
shouldRegenerateBecauseZoom ||
|
||||
prevElementWithCanvas.theme !== appState.theme ||
|
||||
prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion ||
|
||||
prevElementWithCanvas.imageCrop !== imageCrop ||
|
||||
prevElementWithCanvas.containingFrameOpacity !== containingFrameOpacity ||
|
||||
// since we rotate the canvas when copying from cached canvas, we don't
|
||||
// regenerate the cached canvas. But we need to in case of labels which are
|
||||
// cached alongside the arrow, and we want the labels to remain unrotated
|
||||
// with respect to the arrow.
|
||||
(isArrowElement(element) &&
|
||||
boundTextElement &&
|
||||
element.angle !== prevElementWithCanvas.angle)
|
||||
prevElementWithCanvas.containingFrameOpacity !== containingFrameOpacity
|
||||
) {
|
||||
const elementWithCanvas = generateElementCanvas(
|
||||
element,
|
||||
@@ -662,91 +727,206 @@ const generateElementWithCanvas = (
|
||||
return prevElementWithCanvas;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether an element's cached bitmap may be laid on the device-pixel grid:
|
||||
* unrotated or at a right angle, and not during a zoom gesture. The cached
|
||||
* path disables smoothing for exactly these — a nearest-neighbor 1:1 blit is
|
||||
* a pixel-exact copy, and rounding its origin keeps it off the half-pixel
|
||||
* boundary where it tears — and every eligible blit is snapped. It is
|
||||
* eligibility, not a guarantee of a 1:1 copy: freedraw is blitted with
|
||||
* smoothing on, and a size-capped cache is rescaled; snapping is harmless
|
||||
* for both.
|
||||
*
|
||||
* Not during a zoom gesture: the bitmaps stay at the old scale and are
|
||||
* resampled, and blurry shapes look better on low resolution (while still
|
||||
* zooming in) than sharp ones; snapping would only make elements twitch.
|
||||
* Right angles qualify because smoothing can be off there without aliasing
|
||||
* (for other angles it is terrible on Chromium); the right-angle test
|
||||
* tolerates float arithmetic.
|
||||
*/
|
||||
const canSnapElement = (
|
||||
element: ExcalidrawElement,
|
||||
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||
) =>
|
||||
!appState?.shouldCacheIgnoreZoom &&
|
||||
(!element.angle || isRightAngleRads(element.angle));
|
||||
|
||||
/**
|
||||
* Breaks a `Math.round` tie at exactly half a device pixel the same way
|
||||
* every frame. A label's offset from its container lands on one routinely
|
||||
* (an odd scene offset at 150%), and the float noise of a fractional drag
|
||||
* would otherwise flip it between the two neighbors.
|
||||
*/
|
||||
const SNAP_TIE_BIAS = 1e-6;
|
||||
|
||||
const drawElementFromCanvas = (
|
||||
elementWithCanvas: ExcalidrawElementWithCanvas,
|
||||
context: CanvasRenderingContext2D,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||
allElementsMap: NonDeletedSceneElementsMap,
|
||||
positionOffset: RenderPositionOffset,
|
||||
) => {
|
||||
const element = elementWithCanvas.element;
|
||||
// the ratio the cached bitmap was generated with (`generateElementCanvas`);
|
||||
// the blit's size math needs the same one, so it is not the owner window's
|
||||
const devicePixelRatio = window.devicePixelRatio;
|
||||
const padding = getCanvasPadding(element);
|
||||
const zoom = elementWithCanvas.scale;
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap);
|
||||
const cx = ((x1 + x2) / 2 + appState.scrollX) * window.devicePixelRatio;
|
||||
const cy = ((y1 + y2) / 2 + appState.scrollY) * window.devicePixelRatio;
|
||||
const cx =
|
||||
((x1 + x2) / 2 + positionOffset.x + appState.scrollX) * devicePixelRatio;
|
||||
const cy =
|
||||
((y1 + y2) / 2 + positionOffset.y + appState.scrollY) * devicePixelRatio;
|
||||
|
||||
context.save();
|
||||
context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio);
|
||||
context.scale(1 / devicePixelRatio, 1 / devicePixelRatio);
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, allElementsMap);
|
||||
|
||||
if (isArrowElement(element) && boundTextElement) {
|
||||
const offsetX =
|
||||
(elementWithCanvas.boundTextCanvas.width -
|
||||
elementWithCanvas.canvas!.width) /
|
||||
2;
|
||||
const offsetY =
|
||||
(elementWithCanvas.boundTextCanvas.height -
|
||||
elementWithCanvas.canvas!.height) /
|
||||
2;
|
||||
context.translate(cx, cy);
|
||||
context.drawImage(
|
||||
elementWithCanvas.boundTextCanvas,
|
||||
(-(x2 - x1) / 2) * window.devicePixelRatio - offsetX / zoom - padding,
|
||||
(-(y2 - y1) / 2) * window.devicePixelRatio - offsetY / zoom - padding,
|
||||
elementWithCanvas.boundTextCanvas.width / zoom,
|
||||
elementWithCanvas.boundTextCanvas.height / zoom,
|
||||
// punch the label "hole" by clipping the arrow's blit out of the label
|
||||
// rect (even-odd) instead of baking a rotated arrow copy with a cleared
|
||||
// hole into a separate (`maxDim`-squared!) canvas. The hole stays
|
||||
// axis-aligned in scene space while the blit below rotates.
|
||||
const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords(
|
||||
boundTextElement,
|
||||
allElementsMap,
|
||||
);
|
||||
} else {
|
||||
// we translate context to element center so that rotation and scale
|
||||
// originates from the element center
|
||||
context.translate(cx, cy);
|
||||
|
||||
context.rotate(element.angle);
|
||||
|
||||
if (
|
||||
"scale" in elementWithCanvas.element &&
|
||||
!isPendingImageElement(element, renderConfig)
|
||||
) {
|
||||
context.scale(
|
||||
elementWithCanvas.element.scale[0],
|
||||
elementWithCanvas.element.scale[1],
|
||||
);
|
||||
}
|
||||
|
||||
// revert afterwards we don't have account for it during drawing
|
||||
context.translate(-cx, -cy);
|
||||
|
||||
context.drawImage(
|
||||
elementWithCanvas.canvas!,
|
||||
(x1 + appState.scrollX) * window.devicePixelRatio -
|
||||
(padding * elementWithCanvas.scale) / elementWithCanvas.scale,
|
||||
(y1 + appState.scrollY) * window.devicePixelRatio -
|
||||
(padding * elementWithCanvas.scale) / elementWithCanvas.scale,
|
||||
elementWithCanvas.canvas!.width / elementWithCanvas.scale,
|
||||
elementWithCanvas.canvas!.height / elementWithCanvas.scale,
|
||||
// generously covers the arrow's blit at any rotation
|
||||
const outerHalf =
|
||||
Math.max(distance(x1, x2), distance(y1, y2)) * devicePixelRatio +
|
||||
padding * 10;
|
||||
context.beginPath();
|
||||
context.rect(cx - outerHalf, cy - outerHalf, outerHalf * 2, outerHalf * 2);
|
||||
context.rect(
|
||||
(boundTextCx -
|
||||
boundTextElement.width / 2 -
|
||||
BOUND_TEXT_PADDING +
|
||||
positionOffset.x +
|
||||
appState.scrollX) *
|
||||
devicePixelRatio,
|
||||
(boundTextCy -
|
||||
boundTextElement.height / 2 -
|
||||
BOUND_TEXT_PADDING +
|
||||
positionOffset.y +
|
||||
appState.scrollY) *
|
||||
devicePixelRatio,
|
||||
(boundTextElement.width + BOUND_TEXT_PADDING * 2) * devicePixelRatio,
|
||||
(boundTextElement.height + BOUND_TEXT_PADDING * 2) * devicePixelRatio,
|
||||
);
|
||||
context.clip("evenodd");
|
||||
}
|
||||
|
||||
if (
|
||||
import.meta.env.VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX ===
|
||||
"true" &&
|
||||
hasBoundTextElement(element)
|
||||
) {
|
||||
const textElement = getBoundTextElement(
|
||||
element,
|
||||
allElementsMap,
|
||||
) as ExcalidrawTextElementWithContainer;
|
||||
const coords = getContainerCoords(element);
|
||||
context.strokeStyle = "#c92a2a";
|
||||
context.lineWidth = 3;
|
||||
context.strokeRect(
|
||||
(coords.x + appState.scrollX) * window.devicePixelRatio,
|
||||
(coords.y + appState.scrollY) * window.devicePixelRatio,
|
||||
getBoundTextMaxWidth(element, textElement) * window.devicePixelRatio,
|
||||
getBoundTextMaxHeight(element, textElement) * window.devicePixelRatio,
|
||||
);
|
||||
}
|
||||
// we translate context to element center so that rotation and scale
|
||||
// originates from the element center
|
||||
context.translate(cx, cy);
|
||||
|
||||
context.rotate(element.angle);
|
||||
|
||||
if (
|
||||
"scale" in elementWithCanvas.element &&
|
||||
!isPendingImageElement(element, renderConfig)
|
||||
) {
|
||||
context.scale(
|
||||
elementWithCanvas.element.scale[0],
|
||||
elementWithCanvas.element.scale[1],
|
||||
);
|
||||
}
|
||||
|
||||
// revert afterwards we don't have account for it during drawing
|
||||
context.translate(-cx, -cy);
|
||||
|
||||
// the blit origin, in the space the context is in here (scaled by
|
||||
// canvas scale × zoom ÷ devicePixelRatio)
|
||||
let drawX =
|
||||
(x1 + positionOffset.x + appState.scrollX) * devicePixelRatio - padding;
|
||||
let drawY =
|
||||
(y1 + positionOffset.y + appState.scrollY) * devicePixelRatio - padding;
|
||||
|
||||
const transform = context.getTransform();
|
||||
|
||||
if (canSnapElement(element, appState)) {
|
||||
// blit the cached bitmap on whole device pixels. Nearest-neighbor at a
|
||||
// fractional offset is a pixel-exact copy shifted to the nearest pixel
|
||||
// — except at an exact half pixel, where a GPU-accelerated canvas
|
||||
// decides the rounding per scanline by float precision and a few rows
|
||||
// sample the neighboring row: doubled or broken strokes, varying with
|
||||
// scroll and position. A centered label lands on a half pixel
|
||||
// routinely.
|
||||
//
|
||||
// Done by moving the transform's origin onto the rounded device
|
||||
// position of the blit and drawing at (0, 0): the rotation, mirroring
|
||||
// and scale stay in `a`–`d` (a right angle keeps its scale in `b`/`c`),
|
||||
// and the origin is an exact integer even in the canvas's float32
|
||||
// matrix. Rounding `drawX` itself only lands on device pixels at 100%
|
||||
// zoom.
|
||||
const { a, b, c, d, e, f } = transform;
|
||||
const container = isTextElement(element)
|
||||
? getContainerElement(element, allElementsMap)
|
||||
: null;
|
||||
// A bound label shares its unrotated container's offset and anchor.
|
||||
// Other bitmaps anchor to themselves.
|
||||
const anchor = container && !container.angle ? container : element;
|
||||
const anchorCoords =
|
||||
anchor === element
|
||||
? null
|
||||
: getElementAbsoluteCoords(anchor, allElementsMap);
|
||||
const anchorSceneX = anchorCoords?.[0] ?? x1;
|
||||
const anchorSceneY = anchorCoords?.[1] ?? y1;
|
||||
const anchorPadding =
|
||||
anchor === element ? padding : getCanvasPadding(anchor);
|
||||
const anchorX =
|
||||
(anchorSceneX + positionOffset.x + appState.scrollX) * devicePixelRatio -
|
||||
anchorPadding;
|
||||
const anchorY =
|
||||
(anchorSceneY + positionOffset.y + appState.scrollY) * devicePixelRatio -
|
||||
anchorPadding;
|
||||
|
||||
// Form the relative vector before introducing the scroll translation.
|
||||
const dx = (x1 - anchorSceneX) * devicePixelRatio + anchorPadding - padding;
|
||||
const dy = (y1 - anchorSceneY) * devicePixelRatio + anchorPadding - padding;
|
||||
context.setTransform(
|
||||
a,
|
||||
b,
|
||||
c,
|
||||
d,
|
||||
Math.round(a * anchorX + c * anchorY + e) +
|
||||
Math.round(a * dx + c * dy + SNAP_TIE_BIAS),
|
||||
Math.round(b * anchorX + d * anchorY + f) +
|
||||
Math.round(b * dx + d * dy + SNAP_TIE_BIAS),
|
||||
);
|
||||
drawX = 0;
|
||||
drawY = 0;
|
||||
}
|
||||
|
||||
context.drawImage(
|
||||
elementWithCanvas.canvas!,
|
||||
drawX,
|
||||
drawY,
|
||||
elementWithCanvas.canvas!.width / elementWithCanvas.scale,
|
||||
elementWithCanvas.canvas!.height / elementWithCanvas.scale,
|
||||
);
|
||||
|
||||
context.setTransform(transform);
|
||||
|
||||
if (
|
||||
import.meta.env.VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX ===
|
||||
"true" &&
|
||||
hasBoundTextElement(element)
|
||||
) {
|
||||
const textElement = getBoundTextElement(
|
||||
element,
|
||||
allElementsMap,
|
||||
) as ExcalidrawTextElementWithContainer;
|
||||
const coords = getContainerCoords(element);
|
||||
context.strokeStyle = "#c92a2a";
|
||||
context.lineWidth = 3;
|
||||
context.strokeRect(
|
||||
(coords.x + positionOffset.x + appState.scrollX) * devicePixelRatio,
|
||||
(coords.y + positionOffset.y + appState.scrollY) * devicePixelRatio,
|
||||
getBoundTextMaxWidth(element, textElement) * devicePixelRatio,
|
||||
getBoundTextMaxHeight(element, textElement) * devicePixelRatio,
|
||||
);
|
||||
}
|
||||
context.restore();
|
||||
|
||||
@@ -761,7 +941,10 @@ export const renderSelectionElement = (
|
||||
) => {
|
||||
context.save();
|
||||
context.translate(element.x + appState.scrollX, element.y + appState.scrollY);
|
||||
context.fillStyle = "rgba(0, 0, 200, 0.04)";
|
||||
context.fillStyle = applyDarkModeFilter(
|
||||
"rgba(0, 0, 200, 0.04)",
|
||||
appState.theme === THEME.DARK,
|
||||
);
|
||||
|
||||
// render from 0.5px offset to get 1px wide line
|
||||
// https://stackoverflow.com/questions/7530593/html5-canvas-and-line-width/7531540#7531540
|
||||
@@ -785,20 +968,56 @@ export const renderElement = (
|
||||
context: CanvasRenderingContext2D,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||
renderState = resolveElementRenderState(
|
||||
element,
|
||||
elementsMap,
|
||||
renderConfig,
|
||||
allElementsMap,
|
||||
),
|
||||
) => {
|
||||
const reduceAlphaForSelection =
|
||||
appState.openDialog?.name === "elementLinkSelector" &&
|
||||
!appState.selectedElementIds[element.id] &&
|
||||
!appState.hoveredElementIds[element.id];
|
||||
|
||||
context.globalAlpha = getRenderOpacity(
|
||||
element,
|
||||
getContainingFrame(element, elementsMap),
|
||||
renderConfig.elementsPendingErasure,
|
||||
renderConfig.pendingFlowchartNodes,
|
||||
reduceAlphaForSelection ? DEFAULT_REDUCED_GLOBAL_ALPHA : 1,
|
||||
);
|
||||
context.save();
|
||||
context.globalAlpha =
|
||||
renderState.opacity *
|
||||
(reduceAlphaForSelection ? DEFAULT_REDUCED_GLOBAL_ALPHA : 1);
|
||||
// Cached bitmaps apply the offset before pixel snapping. Moving it into
|
||||
// the canvas transform first loses precision at half-device-pixel ties.
|
||||
if (
|
||||
(renderConfig.isExporting || isFrameLikeElement(element)) &&
|
||||
(renderState.offset.x || renderState.offset.y)
|
||||
) {
|
||||
context.translate(renderState.offset.x, renderState.offset.y);
|
||||
}
|
||||
try {
|
||||
drawElement(
|
||||
element,
|
||||
elementsMap,
|
||||
allElementsMap,
|
||||
rc,
|
||||
context,
|
||||
renderConfig,
|
||||
appState,
|
||||
renderState,
|
||||
);
|
||||
} finally {
|
||||
context.restore();
|
||||
}
|
||||
};
|
||||
|
||||
const drawElement = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
elementsMap: RenderableElementsMap,
|
||||
allElementsMap: NonDeletedSceneElementsMap,
|
||||
rc: RoughCanvas,
|
||||
context: CanvasRenderingContext2D,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||
renderState: ElementRenderState,
|
||||
) => {
|
||||
switch (element.type) {
|
||||
case "magicframe":
|
||||
case "frame": {
|
||||
@@ -811,10 +1030,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)) {
|
||||
@@ -873,12 +1092,14 @@ export const renderElement = (
|
||||
renderConfig,
|
||||
appState,
|
||||
allElementsMap,
|
||||
renderState.offset,
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "rectangle":
|
||||
case "stickynote":
|
||||
case "diamond":
|
||||
case "ellipse":
|
||||
case "line":
|
||||
@@ -889,8 +1110,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 +1135,46 @@ 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 (no temp-canvas bitmap blit which
|
||||
// introduces resampling blur). The label "hole" is cut by clipping
|
||||
// the arrow's own strokes out of the label rect (even-odd clip)
|
||||
// so that elements rendered beneath the arrow keep showing through
|
||||
// the gap.
|
||||
shiftX = element.width / 2 - (element.x - x1);
|
||||
shiftY = element.height / 2 - (element.y - y1);
|
||||
|
||||
tempCanvasContext.rotate(element.angle);
|
||||
const tempRc = rough.canvas(tempCanvas);
|
||||
|
||||
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,
|
||||
);
|
||||
// generously covers the arrow's painted extent at any rotation
|
||||
// (the hole rect stays axis-aligned in scene space)
|
||||
const outerHalf =
|
||||
Math.max(distance(x1, x2), distance(y1, y2)) +
|
||||
getCanvasPadding(element) * 10;
|
||||
|
||||
context.save();
|
||||
context.beginPath();
|
||||
context.rect(-outerHalf, -outerHalf, outerHalf * 2, outerHalf * 2);
|
||||
context.rect(holeX, holeY, holeWidth, holeHeight);
|
||||
context.clip("evenodd");
|
||||
context.rotate(element.angle);
|
||||
context.translate(-shiftX, -shiftY);
|
||||
drawElementOnCanvas(element, rc, context, renderConfig);
|
||||
context.restore();
|
||||
} else {
|
||||
context.rotate(element.angle);
|
||||
|
||||
@@ -999,17 +1204,9 @@ export const renderElement = (
|
||||
|
||||
const currentImageSmoothingStatus = context.imageSmoothingEnabled;
|
||||
|
||||
if (
|
||||
// do not disable smoothing during zoom as blurry shapes look better
|
||||
// on low resolution (while still zooming in) than sharp ones
|
||||
!appState?.shouldCacheIgnoreZoom &&
|
||||
// angle is 0 -> always disable smoothing
|
||||
(!element.angle ||
|
||||
// or check if angle is a right angle in which case we can still
|
||||
// disable smoothing without adversely affecting the result
|
||||
// We need less-than comparison because of FP artihmetic
|
||||
isRightAngleRads(element.angle))
|
||||
) {
|
||||
// see `canSnapElement` for why not during zoom gestures or at
|
||||
// other angles
|
||||
if (canSnapElement(element, appState)) {
|
||||
// Disabling smoothing makes output much sharper, especially for
|
||||
// text. Unless for non-right angles, where the aliasing is really
|
||||
// terrible on Chromium.
|
||||
@@ -1029,7 +1226,10 @@ export const renderElement = (
|
||||
context.globalAlpha = 0.1;
|
||||
|
||||
const uncroppedElementCanvas = generateElementCanvas(
|
||||
getUncroppedImageElement(elementWithCanvas.element, elementsMap),
|
||||
getUncroppedImageElement(
|
||||
elementWithCanvas.element,
|
||||
elementsMap,
|
||||
) as NonDeleted<ExcalidrawImageElement>,
|
||||
allElementsMap,
|
||||
appState.zoom,
|
||||
renderConfig,
|
||||
@@ -1043,6 +1243,8 @@ export const renderElement = (
|
||||
renderConfig,
|
||||
appState,
|
||||
allElementsMap,
|
||||
// The crop editor's uncropped preview stays at document coordinates.
|
||||
ZERO_RENDER_OFFSET,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1055,6 +1257,7 @@ export const renderElement = (
|
||||
renderConfig,
|
||||
appState,
|
||||
allElementsMap,
|
||||
renderState.offset,
|
||||
);
|
||||
|
||||
// reset
|
||||
@@ -1067,8 +1270,6 @@ export const renderElement = (
|
||||
throw new Error(`Unimplemented type ${element.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
context.globalAlpha = 1;
|
||||
};
|
||||
|
||||
export function getFreedrawOutlineAsSegments(
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import {
|
||||
MIN_FONT_SIZE,
|
||||
SHIFT_LOCKING_ANGLE,
|
||||
STICKY_NOTE_MIN_SIZE,
|
||||
rescalePoints,
|
||||
getFontString,
|
||||
} from "@excalidraw/common";
|
||||
@@ -57,10 +58,16 @@ import {
|
||||
isFreeDrawElement,
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
isStickyNoteElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import { isInGroup } from "./groups";
|
||||
import {
|
||||
getStickyNoteMinSize,
|
||||
getStickyNoteResizeIntent,
|
||||
updateStickyNoteLayout,
|
||||
} from "./stickyNote";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
@@ -234,7 +241,7 @@ const rotateSingleElement = (
|
||||
if (isBindingElement(element)) {
|
||||
update = {
|
||||
...update,
|
||||
} as ElementUpdate<ExcalidrawArrowElement>;
|
||||
} as ElementUpdate<NonDeletedExcalidrawElement>;
|
||||
|
||||
if (element.startBinding) {
|
||||
unbindBindingElement(element, "start", scene);
|
||||
@@ -388,7 +395,7 @@ export const resizeSingleTextElement = (
|
||||
shouldResizeFromCenter,
|
||||
);
|
||||
|
||||
const resizedElement: Partial<ExcalidrawTextElement> = {
|
||||
const resizedElement: Partial<NonDeleted<ExcalidrawTextElement>> = {
|
||||
width: Math.abs(newWidth),
|
||||
height: Math.abs(metrics.height),
|
||||
x: newOrigin.x,
|
||||
@@ -722,8 +729,8 @@ const getResizedOrigin = (
|
||||
export const resizeSingleElement = (
|
||||
nextWidth: number,
|
||||
nextHeight: number,
|
||||
latestElement: ExcalidrawElement,
|
||||
origElement: ExcalidrawElement,
|
||||
latestElement: NonDeletedExcalidrawElement,
|
||||
origElement: NonDeletedExcalidrawElement,
|
||||
originalElementsMap: ElementsMap,
|
||||
scene: Scene,
|
||||
handleDirection: TransformHandleDirection,
|
||||
@@ -749,11 +756,54 @@ export const resizeSingleElement = (
|
||||
);
|
||||
}
|
||||
|
||||
let boundTextFont: { fontSize?: number } = {};
|
||||
// Constraints and font scaling use magnitudes. Keep the signs for the
|
||||
// geometry below, where crossing the opposite edge flips the element.
|
||||
const flipFactorX = nextWidth < 0 ? -1 : 1;
|
||||
const flipFactorY = nextHeight < 0 ? -1 : 1;
|
||||
nextWidth = Math.abs(nextWidth);
|
||||
nextHeight = Math.abs(nextHeight);
|
||||
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||
const isResizingStickyNote = isStickyNoteElement(latestElement);
|
||||
let minSize: { width: number; height: number } | undefined;
|
||||
if (isResizingStickyNote) {
|
||||
// A note must fit one line at its label's font ceiling.
|
||||
minSize = boundTextElement
|
||||
? getStickyNoteMinSize({
|
||||
fontSize: boundTextElement.baseFontSize ?? boundTextElement.fontSize,
|
||||
fontFamily: boundTextElement.fontFamily,
|
||||
})
|
||||
: { width: STICKY_NOTE_MIN_SIZE, height: STICKY_NOTE_MIN_SIZE };
|
||||
} else if (boundTextElement && !shouldMaintainAspectRatio) {
|
||||
minSize = {
|
||||
width: getApproxMinLineWidth(
|
||||
getFontString(boundTextElement),
|
||||
boundTextElement.lineHeight,
|
||||
),
|
||||
height: getApproxMinLineHeight(
|
||||
boundTextElement.fontSize,
|
||||
boundTextElement.lineHeight,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (boundTextElement) {
|
||||
if (minSize) {
|
||||
nextWidth = Math.max(nextWidth, minSize.width);
|
||||
nextHeight = Math.max(nextHeight, minSize.height);
|
||||
if (shouldMaintainAspectRatio) {
|
||||
// Both dimensions must use the same scale even at the minimum size.
|
||||
const scale = Math.max(
|
||||
nextWidth / origElement.width,
|
||||
nextHeight / origElement.height,
|
||||
);
|
||||
nextWidth = origElement.width * scale;
|
||||
nextHeight = origElement.height * scale;
|
||||
}
|
||||
}
|
||||
|
||||
let boundTextFont: { fontSize?: number } = {};
|
||||
if (boundTextElement && !isResizingStickyNote) {
|
||||
const stateOfBoundTextElementAtResize = originalElementsMap.get(
|
||||
boundTextElement.id,
|
||||
) as typeof boundTextElement | undefined;
|
||||
@@ -780,20 +830,12 @@ export const resizeSingleElement = (
|
||||
boundTextFont = {
|
||||
fontSize: nextFont.size,
|
||||
};
|
||||
} else {
|
||||
const minWidth = getApproxMinLineWidth(
|
||||
getFontString(boundTextElement),
|
||||
boundTextElement.lineHeight,
|
||||
);
|
||||
const minHeight = getApproxMinLineHeight(
|
||||
boundTextElement.fontSize,
|
||||
boundTextElement.lineHeight,
|
||||
);
|
||||
nextWidth = Math.max(nextWidth, minWidth);
|
||||
nextHeight = Math.max(nextHeight, minHeight);
|
||||
}
|
||||
}
|
||||
|
||||
nextWidth *= flipFactorX;
|
||||
nextHeight *= flipFactorY;
|
||||
|
||||
const rescaledPoints = rescalePointsInElement(
|
||||
origElement,
|
||||
nextWidth,
|
||||
@@ -865,7 +907,7 @@ export const resizeSingleElement = (
|
||||
shouldMaintainAspectRatio
|
||||
) {
|
||||
const fontSize =
|
||||
(nextWidth / latestElement.width) * boundTextElement.fontSize;
|
||||
(Math.abs(nextWidth) / latestElement.width) * boundTextElement.fontSize;
|
||||
if (fontSize < MIN_FONT_SIZE) {
|
||||
return;
|
||||
}
|
||||
@@ -909,17 +951,35 @@ export const resizeSingleElement = (
|
||||
isDragging: false,
|
||||
});
|
||||
|
||||
if (boundTextElement && boundTextFont != null) {
|
||||
scene.mutateElement(boundTextElement, {
|
||||
fontSize: boundTextFont.fontSize,
|
||||
if (isStickyNoteElement(latestElement)) {
|
||||
updateStickyNoteLayout(latestElement, scene, {
|
||||
...getStickyNoteResizeIntent(
|
||||
latestElement,
|
||||
originalElementsMap,
|
||||
handleDirection,
|
||||
{
|
||||
proportional: shouldMaintainAspectRatio,
|
||||
fromCenter: shouldResizeFromCenter,
|
||||
},
|
||||
),
|
||||
// the arrow pass below is this function's — keep it single
|
||||
bindings: false,
|
||||
});
|
||||
} else {
|
||||
if (boundTextElement && boundTextFont != null) {
|
||||
scene.mutateElement(boundTextElement, {
|
||||
fontSize: boundTextFont.fontSize,
|
||||
});
|
||||
}
|
||||
handleBindTextResize(
|
||||
latestElement,
|
||||
scene,
|
||||
handleDirection,
|
||||
shouldMaintainAspectRatio,
|
||||
shouldResizeFromCenter,
|
||||
flipFactorY < 0,
|
||||
);
|
||||
}
|
||||
handleBindTextResize(
|
||||
latestElement,
|
||||
scene,
|
||||
handleDirection,
|
||||
shouldMaintainAspectRatio,
|
||||
);
|
||||
|
||||
updateBoundElements(latestElement, scene);
|
||||
}
|
||||
@@ -1200,7 +1260,10 @@ export const resizeMultipleElements = (
|
||||
}[],
|
||||
element,
|
||||
) => {
|
||||
const origElement = originalElementsMap!.get(element.id);
|
||||
// originalElementsMap holds snapshots of the (non-deleted) selection
|
||||
const origElement = originalElementsMap!.get(element.id) as
|
||||
| NonDeletedExcalidrawElement
|
||||
| undefined;
|
||||
if (origElement) {
|
||||
acc.push({ orig: origElement, latest: element });
|
||||
}
|
||||
@@ -1239,9 +1302,10 @@ export const resizeMultipleElements = (
|
||||
];
|
||||
}, [] as ExcalidrawTextElementWithContainer[]);
|
||||
|
||||
boundingBox = getCommonBoundingBox(
|
||||
targetElements.map(({ orig }) => orig).concat(boundTextElements),
|
||||
);
|
||||
boundingBox = getCommonBoundingBox([
|
||||
...targetElements.map(({ orig }) => orig),
|
||||
...boundTextElements,
|
||||
]);
|
||||
}
|
||||
const { minX, minY, maxX, maxY, midX, midY } = boundingBox;
|
||||
const width = maxX - minX;
|
||||
@@ -1436,7 +1500,8 @@ export const resizeMultipleElements = (
|
||||
getBoundTextElementId(orig) ?? "",
|
||||
) as ExcalidrawTextElementWithContainer | undefined;
|
||||
|
||||
if (boundTextElement) {
|
||||
// sticky notes derive their label's size from the layout below
|
||||
if (boundTextElement && !isStickyNoteElement(orig)) {
|
||||
if (keepAspectRatio) {
|
||||
const newFontSize = boundTextElement.fontSize * scale;
|
||||
if (newFontSize < MIN_FONT_SIZE) {
|
||||
@@ -1468,9 +1533,27 @@ export const resizeMultipleElements = (
|
||||
|
||||
scene.mutateElement(element, update);
|
||||
|
||||
updateBoundElements(element, scene, {
|
||||
simultaneouslyUpdated: elementsToUpdate,
|
||||
});
|
||||
if (isStickyNoteElement(element)) {
|
||||
// the content correction runs before the (single) arrow pass, which
|
||||
// must still skip arrows resized in the same gesture
|
||||
updateStickyNoteLayout(element, scene, {
|
||||
...getStickyNoteResizeIntent(
|
||||
element,
|
||||
originalElementsMap,
|
||||
handleDirection,
|
||||
{
|
||||
proportional: keepAspectRatio,
|
||||
fromCenter: shouldResizeFromCenter,
|
||||
flip: flipByX || flipByY,
|
||||
},
|
||||
),
|
||||
bindings: { simultaneouslyUpdated: elementsToUpdate },
|
||||
});
|
||||
} else {
|
||||
updateBoundElements(element, scene, {
|
||||
simultaneouslyUpdated: elementsToUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
if (isBindingElement(element)) {
|
||||
if (element.startBinding) {
|
||||
@@ -1486,12 +1569,23 @@ export const resizeMultipleElements = (
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement && boundTextFontSize) {
|
||||
if (
|
||||
boundTextElement &&
|
||||
boundTextFontSize &&
|
||||
!isStickyNoteElement(element)
|
||||
) {
|
||||
scene.mutateElement(boundTextElement, {
|
||||
fontSize: boundTextFontSize,
|
||||
angle: isLinearElement(element) ? undefined : angle,
|
||||
});
|
||||
handleBindTextResize(element, scene, handleDirection, true);
|
||||
handleBindTextResize(
|
||||
element,
|
||||
scene,
|
||||
handleDirection,
|
||||
true,
|
||||
shouldResizeFromCenter,
|
||||
flipByY,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import { arrayToMap, isShallowEqual, type Bounds } from "@excalidraw/common";
|
||||
import {
|
||||
lineSegment,
|
||||
pointFrom,
|
||||
pointRotateRads,
|
||||
type GlobalPoint,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
AppState,
|
||||
@@ -12,34 +6,20 @@ import type {
|
||||
InteractiveCanvasAppState,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import {
|
||||
boundsContainBounds,
|
||||
doBoundsIntersect,
|
||||
elementCenterPoint,
|
||||
getElementAbsoluteCoords,
|
||||
getElementBounds,
|
||||
pointInsideBounds,
|
||||
} from "./bounds";
|
||||
import { intersectElementWithLineSegment } from "./collision";
|
||||
import { elementsOverlappingBBox, getElementAbsoluteCoords } from "./bounds";
|
||||
import { isElementInViewport } from "./sizeHelpers";
|
||||
import {
|
||||
isArrowElement,
|
||||
isBoundToContainer,
|
||||
isFrameLikeElement,
|
||||
isFreeDrawElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import {
|
||||
elementOverlapsWithFrame,
|
||||
getContainingFrame,
|
||||
getFrameChildren,
|
||||
isElementIntersectingFrame,
|
||||
} from "./frame";
|
||||
import { getFrameChildren } from "./frame";
|
||||
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { selectGroupsForSelectedElements } from "./groups";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
|
||||
import { isNonDeletedElement } from ".";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
@@ -50,9 +30,8 @@ import type {
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
|
||||
const shouldIgnoreElementFromSelection = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
) => element.locked || isBoundToContainer(element);
|
||||
const shouldIgnoreElementFromSelection = (element: ExcalidrawElement) =>
|
||||
element.locked || isBoundToContainer(element);
|
||||
|
||||
const excludeElementsFromFrames = <T extends ExcalidrawElement>(
|
||||
selectedElements: readonly T[],
|
||||
@@ -88,14 +67,14 @@ export const excludeElementsInFramesFromSelection = <
|
||||
return excludeElementsFromFrames(selectedElements, framesInSelection);
|
||||
};
|
||||
|
||||
export const getElementsWithinSelection = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
selection: NonDeletedExcalidrawElement,
|
||||
export const getElementsWithinSelection = <T extends ExcalidrawElement>(
|
||||
elements: readonly T[],
|
||||
selection: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
// TODO remove (this flag is effectively unused AFAIK)
|
||||
excludeElementsInFrames: boolean = true,
|
||||
boxSelectionMode: BoxSelectionMode = "contain",
|
||||
): NonDeletedExcalidrawElement[] => {
|
||||
): T[] => {
|
||||
const [selectionStartX, selectionStartY, selectionEndX, selectionEndY] =
|
||||
getElementAbsoluteCoords(selection, elementsMap);
|
||||
const selectionX1 = Math.min(selectionStartX, selectionEndX);
|
||||
@@ -108,238 +87,22 @@ export const getElementsWithinSelection = (
|
||||
selectionX2,
|
||||
selectionY2,
|
||||
] as Bounds;
|
||||
const selectionEdges = [
|
||||
lineSegment<GlobalPoint>(
|
||||
pointFrom(selectionX1, selectionY1),
|
||||
pointFrom(selectionX2, selectionY1),
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
pointFrom(selectionX2, selectionY1),
|
||||
pointFrom(selectionX2, selectionY2),
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
pointFrom(selectionX2, selectionY2),
|
||||
pointFrom(selectionX1, selectionY2),
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
pointFrom(selectionX1, selectionY2),
|
||||
pointFrom(selectionX1, selectionY1),
|
||||
),
|
||||
];
|
||||
|
||||
const framesInSelection = excludeElementsInFrames
|
||||
? new Set<NonDeletedExcalidrawElement["id"]>()
|
||||
: null;
|
||||
let elementsInSelection: NonDeletedExcalidrawElement[] = [];
|
||||
|
||||
for (const element of elements) {
|
||||
if (shouldIgnoreElementFromSelection(element)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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 &&
|
||||
isElementIntersectingFrame(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);
|
||||
} else {
|
||||
elementsInSelection.push(element);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Handle the case where the label is overlapped by the selection box
|
||||
if (
|
||||
boxSelectionMode === "overlap" &&
|
||||
labelAABB &&
|
||||
doBoundsIntersect(selectionBounds, labelAABB)
|
||||
) {
|
||||
elementsInSelection.push(element);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Handle the case where the selection is not wrapping the element, but
|
||||
// it does intersect the element's outline (non-AABB).
|
||||
if (
|
||||
boxSelectionMode === "overlap" &&
|
||||
doBoundsIntersect(selectionBounds, elementAABB)
|
||||
) {
|
||||
let hasIntersection = false;
|
||||
|
||||
// Preliminary check potential intersection imprecision
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
hasIntersection = element.points.some((point) => {
|
||||
const rotatedPoint = pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
|
||||
return pointInsideBounds(rotatedPoint, selectionBounds);
|
||||
});
|
||||
} else {
|
||||
const nonRotatedElementBounds = getElementBounds(
|
||||
element,
|
||||
elementsMap,
|
||||
true,
|
||||
);
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
hasIntersection = [
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
|
||||
nonRotatedElementBounds[1],
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
nonRotatedElementBounds[2],
|
||||
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
|
||||
nonRotatedElementBounds[3],
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
nonRotatedElementBounds[0],
|
||||
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
].some((point) => {
|
||||
return pointInsideBounds(
|
||||
pointRotateRads(point, center, element.angle),
|
||||
selectionBounds,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasIntersection) {
|
||||
hasIntersection = selectionEdges.some(
|
||||
(selectionEdge) =>
|
||||
intersectElementWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
selectionEdge,
|
||||
strokeWidth / 2,
|
||||
true, // Stop at first hit for better performance
|
||||
).length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
if (hasIntersection) {
|
||||
if (framesInSelection && isFrameLikeElement(element)) {
|
||||
framesInSelection.add(element.id);
|
||||
}
|
||||
|
||||
elementsInSelection.push(element);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. We don't need to handle when the selection is inside the element
|
||||
// as it is separately handled in App.
|
||||
}
|
||||
|
||||
elementsInSelection = framesInSelection
|
||||
? excludeElementsFromFrames(elementsInSelection, framesInSelection)
|
||||
: elementsInSelection;
|
||||
|
||||
elementsInSelection = elementsInSelection.filter((element) => {
|
||||
const containingFrame = getContainingFrame(element, elementsMap);
|
||||
|
||||
if (containingFrame) {
|
||||
return elementOverlapsWithFrame(element, containingFrame, elementsMap);
|
||||
}
|
||||
|
||||
return true;
|
||||
return elementsOverlappingBBox({
|
||||
elements,
|
||||
bounds: selectionBounds,
|
||||
elementsMap,
|
||||
type: boxSelectionMode,
|
||||
shouldIgnoreElementFromSelection,
|
||||
excludeElementsInFrames,
|
||||
});
|
||||
|
||||
return elementsInSelection;
|
||||
};
|
||||
|
||||
export const getVisibleAndNonSelectedElements = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
selectedElements: readonly NonDeletedExcalidrawElement[],
|
||||
export const getVisibleAndNonSelectedElements = <
|
||||
T extends NonDeletedExcalidrawElement,
|
||||
>(
|
||||
elements: readonly T[],
|
||||
selectedElements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
@@ -404,30 +167,40 @@ export const getSelectedElements = (
|
||||
},
|
||||
) => {
|
||||
const addedElements = new Set<ExcalidrawElement["id"]>();
|
||||
const selectedElements: ExcalidrawElement[] = [];
|
||||
// selection can only contain non-deleted elements
|
||||
const selectedElements: NonDeletedExcalidrawElement[] = [];
|
||||
for (const element of elements.values()) {
|
||||
if (appState.selectedElementIds[element.id]) {
|
||||
selectedElements.push(element);
|
||||
addedElements.add(element.id);
|
||||
if (isNonDeletedElement(element)) {
|
||||
selectedElements.push(element as NonDeletedExcalidrawElement);
|
||||
addedElements.add(element.id);
|
||||
} else {
|
||||
console.error(
|
||||
"[NONDELETED][INVARIANT] getSelectedElements skipping deleted selected element which should not be in the selection",
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
opts?.includeBoundTextElement &&
|
||||
isBoundToContainer(element) &&
|
||||
isNonDeletedElement(element) &&
|
||||
appState.selectedElementIds[element?.containerId]
|
||||
) {
|
||||
selectedElements.push(element);
|
||||
selectedElements.push(element as NonDeletedExcalidrawElement);
|
||||
addedElements.add(element.id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (opts?.includeElementsInFrames) {
|
||||
const elementsToInclude: ExcalidrawElement[] = [];
|
||||
const elementsToInclude: NonDeletedExcalidrawElement[] = [];
|
||||
selectedElements.forEach((element) => {
|
||||
if (isFrameLikeElement(element)) {
|
||||
getFrameChildren(elements, element.id).forEach(
|
||||
(e) => !addedElements.has(e.id) && elementsToInclude.push(e),
|
||||
(e) =>
|
||||
!addedElements.has(e.id) &&
|
||||
elementsToInclude.push(e as NonDeletedExcalidrawElement),
|
||||
);
|
||||
}
|
||||
elementsToInclude.push(element);
|
||||
@@ -443,12 +216,15 @@ export const getTargetElements = (
|
||||
elements: ElementsMapOrArray,
|
||||
appState: Pick<
|
||||
AppState,
|
||||
"selectedElementIds" | "editingTextElement" | "newElement"
|
||||
"selectedElementIds" | "editingTextElement" | "newElement" | "activeTool"
|
||||
>,
|
||||
) =>
|
||||
appState.editingTextElement
|
||||
? [appState.editingTextElement]
|
||||
: appState.newElement
|
||||
: // the drawShape recognition preview in `newElement` is not a real
|
||||
// in-progress element — targeting it would make the styles panel track
|
||||
// whatever shape is currently recognized instead of the tool defaults
|
||||
appState.newElement && appState.activeTool.type !== "autoshape"
|
||||
? [appState.newElement]
|
||||
: getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
@@ -470,7 +246,7 @@ export const makeNextSelectedElementIds = (
|
||||
};
|
||||
|
||||
const _getLinearElementEditor = (
|
||||
targetElements: readonly ExcalidrawElement[],
|
||||
targetElements: readonly NonDeletedExcalidrawElement[],
|
||||
allElements: readonly NonDeletedExcalidrawElement[],
|
||||
) => {
|
||||
const linears = targetElements.filter(isLinearElement);
|
||||
@@ -490,7 +266,7 @@ const _getLinearElementEditor = (
|
||||
};
|
||||
|
||||
export const getSelectionStateForElements = (
|
||||
targetElements: readonly ExcalidrawElement[],
|
||||
targetElements: readonly NonDeletedExcalidrawElement[],
|
||||
allElements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
|
||||
+202
-90
@@ -1,5 +1,6 @@
|
||||
import { simplify } from "points-on-curve";
|
||||
import { getStroke } from "perfect-freehand";
|
||||
import { pointsOnBezierCurves, simplify } from "points-on-curve";
|
||||
import { getStroke, getStrokePoints } from "perfect-freehand";
|
||||
import { LaserPointer } from "@excalidraw/laser-pointer";
|
||||
|
||||
import {
|
||||
type GeometricShape,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
pointDistance,
|
||||
type LocalPoint,
|
||||
pointRotateRads,
|
||||
polygonFromPoints,
|
||||
} from "@excalidraw/math";
|
||||
import {
|
||||
ROUGHNESS,
|
||||
@@ -24,11 +26,12 @@ import {
|
||||
COLOR_PALETTE,
|
||||
LINE_POLYGON_POINT_MERGE_DISTANCE,
|
||||
applyDarkModeFilter,
|
||||
DEFAULT_STROKE_STREAMLINE,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { RoughGenerator } from "roughjs/bin/generator";
|
||||
|
||||
import type { GlobalPoint } from "@excalidraw/math";
|
||||
import type { GlobalPoint, Polygon } from "@excalidraw/math";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
@@ -66,7 +69,6 @@ import { shouldTestInside } from "./collision";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
ExcalidrawSelectionElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
@@ -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;
|
||||
}
|
||||
@@ -264,7 +260,7 @@ export const generateRoughOptions = (
|
||||
};
|
||||
|
||||
const modifyIframeLikeForRoughOptions = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
element: ExcalidrawElement,
|
||||
isExporting: boolean,
|
||||
embedsValidationStatus: EmbedsValidationStatus | null,
|
||||
) => {
|
||||
@@ -386,12 +382,11 @@ const getArrowheadShapes = (
|
||||
return [];
|
||||
}
|
||||
|
||||
const strokeColor = isDarkMode
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
: element.strokeColor;
|
||||
const backgroundFillColor = isDarkMode
|
||||
? applyDarkModeFilter(canvasBackgroundColor)
|
||||
: canvasBackgroundColor;
|
||||
const strokeColor = applyDarkModeFilter(element.strokeColor, isDarkMode);
|
||||
const backgroundFillColor = applyDarkModeFilter(
|
||||
canvasBackgroundColor,
|
||||
isDarkMode,
|
||||
);
|
||||
const cardinalityOneOrManyOffset = -0.25;
|
||||
const cardinalityZeroCircleScale = 0.8;
|
||||
|
||||
@@ -581,6 +576,48 @@ const getArrowheadShapes = (
|
||||
}
|
||||
};
|
||||
|
||||
/** The simplified centerline the freedraw fill is drawn along. */
|
||||
const getFreedrawFillCurvePoints = (element: ExcalidrawFreeDrawElement) =>
|
||||
simplify(element.points as Mutable<LocalPoint[]>, 0.75) as [number, number][];
|
||||
|
||||
const freedrawFillPolygonCache = new WeakMap<
|
||||
ExcalidrawFreeDrawElement,
|
||||
{ version: number; polygon: Polygon<LocalPoint> }
|
||||
>();
|
||||
|
||||
/**
|
||||
* Returns the flattened fill contour of a freedraw loop in local, unrotated
|
||||
* coordinates, following the rendered fill rather than the stroke outline.
|
||||
*/
|
||||
export const getFreedrawFillPolygon = (element: ExcalidrawFreeDrawElement) => {
|
||||
const cached = freedrawFillPolygonCache.get(element);
|
||||
if (cached?.version === element.version) {
|
||||
return cached.polygon;
|
||||
}
|
||||
|
||||
// Same curve as the rendered fill, without roughness jitter. RoughJS also
|
||||
// handles inputs simplified to two points.
|
||||
const ops = new RoughGenerator().curve(getFreedrawFillCurvePoints(element), {
|
||||
roughness: 0,
|
||||
disableMultiStroke: true,
|
||||
}).sets[0].ops;
|
||||
const bezierPoints: LocalPoint[] = [];
|
||||
// A single curve pass consists of a move followed by cubic control points.
|
||||
for (const { data } of ops) {
|
||||
for (let i = 0; i < data.length; i += 2) {
|
||||
bezierPoints.push(pointFrom<LocalPoint>(data[i], data[i + 1]));
|
||||
}
|
||||
}
|
||||
|
||||
// Omit the optional distance argument to avoid simplifying the boundary again.
|
||||
const polygon = polygonFromPoints(
|
||||
pointsOnBezierCurves(bezierPoints, 0.5) as LocalPoint[],
|
||||
);
|
||||
freedrawFillPolygonCache.set(element, { version: element.version, polygon });
|
||||
|
||||
return polygon;
|
||||
};
|
||||
|
||||
export const generateLinearCollisionShape = (
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
@@ -681,69 +718,44 @@ export const generateLinearCollisionShape = (
|
||||
});
|
||||
}
|
||||
case "freedraw": {
|
||||
if (element.points.length < 2) {
|
||||
const outlinePoints = getFreedrawOutlinePoints(element);
|
||||
|
||||
if (outlinePoints.length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const simplifiedPoints = simplify(
|
||||
element.points as Mutable<LocalPoint[]>,
|
||||
0.75,
|
||||
);
|
||||
const collisionOutline =
|
||||
element.strokeOptions?.variability === "constant"
|
||||
? (simplify(
|
||||
outlinePoints as Mutable<LocalPoint[]>,
|
||||
CONSTANT_WIDTH_FREEDRAW.COLLISION_SIMPLIFY_TOLERANCE,
|
||||
) as [number, number][])
|
||||
: outlinePoints;
|
||||
|
||||
return generator
|
||||
.curve(simplifiedPoints as [number, number][], options)
|
||||
.sets[0].ops.slice(0, element.points.length)
|
||||
.map((op, i) => {
|
||||
if (i === 0) {
|
||||
const p = pointRotateRads<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + op.data[0],
|
||||
element.y + op.data[1],
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
if (collisionOutline.length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return {
|
||||
op: "move",
|
||||
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
|
||||
};
|
||||
}
|
||||
// Close the outline polygon so its boundary never has a gap at the seam.
|
||||
const [firstX, firstY] = collisionOutline[0];
|
||||
const [lastX, lastY] = collisionOutline[collisionOutline.length - 1];
|
||||
const closedOutline =
|
||||
firstX === lastX && firstY === lastY
|
||||
? collisionOutline
|
||||
: [...collisionOutline, collisionOutline[0]];
|
||||
|
||||
return {
|
||||
op: "bcurveTo",
|
||||
data: [
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + op.data[0],
|
||||
element.y + op.data[1],
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + op.data[2],
|
||||
element.y + op.data[3],
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + op.data[4],
|
||||
element.y + op.data[5],
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
]
|
||||
.map((p) =>
|
||||
pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
|
||||
)
|
||||
.flat(),
|
||||
};
|
||||
});
|
||||
return closedOutline.map((point, idx) => {
|
||||
const p = pointRotateRads<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
|
||||
return {
|
||||
op: idx === 0 ? "move" : "lineTo",
|
||||
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -756,7 +768,7 @@ export const generateLinearCollisionShape = (
|
||||
* @private
|
||||
*/
|
||||
const _generateElementShape = (
|
||||
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
generator: RoughGenerator,
|
||||
{
|
||||
isExporting,
|
||||
@@ -968,12 +980,8 @@ const _generateElementShape = (
|
||||
// (1) background fill (rc shape), optional
|
||||
if (isPathALoop(element.points)) {
|
||||
// generate rough polygon to fill freedraw shape
|
||||
const simplifiedPoints = simplify(
|
||||
element.points as Mutable<LocalPoint[]>,
|
||||
0.75,
|
||||
);
|
||||
shapes.push(
|
||||
generator.curve(simplifiedPoints as [number, number][], {
|
||||
generator.curve(getFreedrawFillCurvePoints(element), {
|
||||
...generateRoughOptions(element, false, isDarkMode),
|
||||
stroke: "none",
|
||||
}),
|
||||
@@ -985,6 +993,9 @@ const _generateElementShape = (
|
||||
|
||||
return shapes;
|
||||
}
|
||||
// sticky notes are painted directly (canvas + SVG) from
|
||||
// `getStickyNoteRenderPoints`, never through roughjs
|
||||
case "stickynote":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
case "text":
|
||||
@@ -1079,6 +1090,7 @@ export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
): GeometricShape<Point> => {
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "stickynote":
|
||||
case "diamond":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
@@ -1178,27 +1190,127 @@ 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,
|
||||
/**
|
||||
* Max deviation (px) when dropping vertices of the dense laser outline for
|
||||
* collision. Perfect-freehand outlines are already thinned by `smoothing`.
|
||||
*/
|
||||
COLLISION_SIMPLIFY_TOLERANCE: 0.2,
|
||||
} 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);
|
||||
};
|
||||
|
||||
/**
|
||||
* Upper bound on how far a freedraw stroke's ink reaches past its centerline
|
||||
* points. Both stroke generators scale the stroke size by at most 1 (easing
|
||||
* or pressure), so the size itself bounds the radius, except for
|
||||
* perfect-freehand's short strokes: under 3px long, the start cap is drawn
|
||||
* around the first point from an outline point of the last one (reaching
|
||||
* sqrt(size² + 3²)), and a single point gets a synthetic neighbor 1px away.
|
||||
*/
|
||||
export const getFreedrawMaxStrokeRadius = (
|
||||
element: ExcalidrawFreeDrawElement,
|
||||
) =>
|
||||
element.strokeOptions?.variability === "constant"
|
||||
? element.strokeWidth * CONSTANT_WIDTH_FREEDRAW.SIZE_FACTOR
|
||||
: element.strokeWidth * VARIABLE_WIDTH_FREEDRAW.SIZE_FACTOR + 3;
|
||||
|
||||
/**
|
||||
* The streamline-smoothed centerline the freedraw stroke is rendered
|
||||
* around, in element-local coordinates. Boundary-sensitive consumers (e.g.
|
||||
* bucket fill) should use this instead of `element.points`: raw input
|
||||
* points can sit 20px+ apart and the rendered stroke is smoothed between
|
||||
* them, so raw chords visibly deviate from what's on screen.
|
||||
*
|
||||
* Constant-width ("laser geometry") strokes technically smooth via
|
||||
* `LaserPointer` instead; the perfect-freehand centerline with the same
|
||||
* `streamline` is a close approximation the stroke width hides.
|
||||
*/
|
||||
export const getFreedrawStrokeCenterPoints = (
|
||||
element: ExcalidrawFreeDrawElement,
|
||||
): [number, number][] =>
|
||||
getStrokePoints(element.points as unknown as number[][], {
|
||||
size: element.strokeWidth * VARIABLE_WIDTH_FREEDRAW.SIZE_FACTOR,
|
||||
streamline: getFreedrawStreamline(element),
|
||||
last: true,
|
||||
}).map((strokePoint) => strokePoint.point as [number, number]);
|
||||
|
||||
const med = (A: number[], B: number[]) => {
|
||||
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
|
||||
};
|
||||
|
||||
@@ -22,6 +22,37 @@ import {
|
||||
|
||||
import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||
|
||||
export type VerticalResizeAnchor = "top" | "bottom" | "center";
|
||||
|
||||
/** Keeps the chosen edge or center fixed when an element's height changes. */
|
||||
export const getPositionAfterHeightChange = (
|
||||
element: Pick<ExcalidrawElement, "x" | "y" | "height" | "angle">,
|
||||
nextHeight: number,
|
||||
anchor: VerticalResizeAnchor = "top",
|
||||
) => {
|
||||
const delta = (element.height - nextHeight) / 2;
|
||||
|
||||
if (anchor === "center") {
|
||||
// Rotation is about the center, so holding it is angle-independent.
|
||||
return { x: element.x, y: element.y + delta };
|
||||
}
|
||||
|
||||
const sin = Math.sin(element.angle);
|
||||
const cos = Math.cos(element.angle);
|
||||
|
||||
if (anchor === "bottom") {
|
||||
return {
|
||||
x: element.x - delta * sin,
|
||||
y: element.y + delta * (1 + cos),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
x: element.x + delta * sin,
|
||||
y: element.y + delta * (1 - cos),
|
||||
};
|
||||
};
|
||||
|
||||
export const INVISIBLY_SMALL_ELEMENT_SIZE = 0.1;
|
||||
|
||||
// TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -65,42 +62,43 @@ const normalizeGroupElementOrder = (elements: readonly ExcalidrawElement[]) => {
|
||||
* original z-index of container (i.e. it moves bound text elements after
|
||||
* containers).
|
||||
*/
|
||||
const normalizeBoundElementsOrder = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
export const normalizeBoundElementsOrder = <T extends ExcalidrawElement>(
|
||||
elements: readonly T[],
|
||||
) => {
|
||||
const elementsMap = arrayToMapWithIndex(elements);
|
||||
const elementsMap = arrayToMap(elements);
|
||||
|
||||
const origElements: (ExcalidrawElement | null)[] = elements.slice();
|
||||
const sortedElements = new Set<ExcalidrawElement>();
|
||||
const sortedElements = new Set<T>();
|
||||
|
||||
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));
|
||||
};
|
||||
|
||||
@@ -0,0 +1,971 @@
|
||||
import {
|
||||
DEFAULT_ELEMENT_PROPS,
|
||||
DEFAULT_STICKY_NOTE_BG,
|
||||
MIN_FONT_SIZE,
|
||||
arrayToMap,
|
||||
STICKY_NOTE_BODY_INSET_Y,
|
||||
STICKY_NOTE_FALLBACK_FONT_SIZE,
|
||||
STICKY_NOTE_FOOTER,
|
||||
STICKY_NOTE_FONT_STEP,
|
||||
STICKY_NOTE_MAX_FONT_SIZE,
|
||||
STICKY_NOTE_MIN_SIZE,
|
||||
STICKY_NOTE_MIN_FONT_SIZE,
|
||||
STICKY_NOTE_PADDING,
|
||||
STICKY_NOTE_SHADOW_OFFSET,
|
||||
getFontString,
|
||||
getLineHeight,
|
||||
isTransparent,
|
||||
seededRandom,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { clamp } from "@excalidraw/math";
|
||||
|
||||
import { updateBoundElements } from "./binding";
|
||||
import { newElementWith } from "./mutateElement";
|
||||
import { getPositionAfterHeightChange } from "./sizeHelpers";
|
||||
import { computeBoundTextPosition, getBoundTextElement } from "./textElement";
|
||||
import { measureText } from "./textMeasurements";
|
||||
import { wrapText } from "./textWrapping";
|
||||
import { isStickyNoteElement, isTextElement } from "./typeChecks";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
import type { VerticalResizeAnchor } from "./sizeHelpers";
|
||||
import type { TransformHandleDirection } from "./transformHandles";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawStickyNoteElement,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
|
||||
export type StickyNoteRenderPoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type StickyNotePathCommand =
|
||||
| {
|
||||
type: "move";
|
||||
point: StickyNoteRenderPoint;
|
||||
}
|
||||
| {
|
||||
type: "line";
|
||||
point: StickyNoteRenderPoint;
|
||||
}
|
||||
| {
|
||||
type: "quadratic";
|
||||
control: StickyNoteRenderPoint;
|
||||
point: StickyNoteRenderPoint;
|
||||
};
|
||||
|
||||
const STICKY_NOTE_RENDER_ROUGHNESS = [0, 1.5, 8] as const;
|
||||
const STICKY_NOTE_CORNER_RADIUS_RATIO = 0.04;
|
||||
const STICKY_NOTE_MAX_CORNER_RADIUS = 16;
|
||||
|
||||
export const normalizeStickyNoteStrokeColor = (
|
||||
strokeColor: string | null | undefined,
|
||||
) => {
|
||||
return !strokeColor || isTransparent(strokeColor)
|
||||
? DEFAULT_ELEMENT_PROPS.strokeColor
|
||||
: strokeColor;
|
||||
};
|
||||
|
||||
export const normalizeStickyNoteBackgroundColor = (
|
||||
backgroundColor: string | null | undefined,
|
||||
) => {
|
||||
return !backgroundColor || isTransparent(backgroundColor)
|
||||
? DEFAULT_STICKY_NOTE_BG
|
||||
: backgroundColor;
|
||||
};
|
||||
|
||||
/**
|
||||
* The update that applies a picked color to an element under the sticky
|
||||
* note policy: a note is always filled and its label — the visible text,
|
||||
* which the note's own `strokeColor` seeds — never goes transparent. Every
|
||||
* color surface (actions, both eyedroppers, paste styles, bind) routes
|
||||
* through this instead of re-deriving the rule.
|
||||
*/
|
||||
/**
|
||||
* The element a color pick on `element` lands on. A note's label has no fill
|
||||
* of its own, so a background pick on the label — the styles panel while
|
||||
* editing it — goes to the note; everything else colors itself.
|
||||
*/
|
||||
export const getColorTargetElement = (
|
||||
element: ExcalidrawElement,
|
||||
property: "strokeColor" | "backgroundColor",
|
||||
elementsMap: ElementsMap,
|
||||
): ExcalidrawElement => {
|
||||
if (
|
||||
property === "backgroundColor" &&
|
||||
isTextElement(element) &&
|
||||
isStickyNoteBoundText(element, elementsMap)
|
||||
) {
|
||||
return elementsMap.get(element.containerId!) ?? element;
|
||||
}
|
||||
return element;
|
||||
};
|
||||
|
||||
export const getColorUpdate = (
|
||||
element: ExcalidrawElement,
|
||||
property: "strokeColor" | "backgroundColor",
|
||||
color: string,
|
||||
elementsMap: ElementsMap,
|
||||
): { strokeColor: string } | { backgroundColor: string } => {
|
||||
if (isStickyNoteElement(element)) {
|
||||
return property === "backgroundColor"
|
||||
? { backgroundColor: normalizeStickyNoteBackgroundColor(color) }
|
||||
: { strokeColor: normalizeStickyNoteStrokeColor(color) };
|
||||
}
|
||||
if (
|
||||
property === "strokeColor" &&
|
||||
isTextElement(element) &&
|
||||
isStickyNoteBoundText(element, elementsMap)
|
||||
) {
|
||||
const container = elementsMap.get(element.containerId!);
|
||||
return {
|
||||
strokeColor: isTransparent(color)
|
||||
? normalizeStickyNoteStrokeColor(container?.strokeColor)
|
||||
: color,
|
||||
};
|
||||
}
|
||||
return property === "backgroundColor"
|
||||
? { backgroundColor: color }
|
||||
: { strokeColor: color };
|
||||
};
|
||||
|
||||
/**
|
||||
* A note's ink is one color: the container's `strokeColor` — the seed for a
|
||||
* new label and what the creation-date footer paints with — and its label's.
|
||||
* Run after a property write to copy the side that changed onto the other;
|
||||
* when both changed, or neither (data that drifted), the label wins: it is
|
||||
* the text the user styled. A transparent label always takes the note's
|
||||
* color. Returns the same array when nothing needs to change.
|
||||
*/
|
||||
export const syncStickyNoteInk = <T extends ExcalidrawElement>(
|
||||
elements: readonly T[],
|
||||
prevElementsMap: ElementsMap,
|
||||
): readonly T[] => {
|
||||
const elementsMap = arrayToMap(elements);
|
||||
const inkById = new Map<string, string>();
|
||||
for (const container of elements) {
|
||||
if (!isStickyNoteElement(container) || container.isDeleted) {
|
||||
continue;
|
||||
}
|
||||
const label = getBoundTextElement(container, elementsMap);
|
||||
if (!label || label.strokeColor === container.strokeColor) {
|
||||
continue;
|
||||
}
|
||||
const containerChanged =
|
||||
prevElementsMap.get(container.id)?.strokeColor !== container.strokeColor;
|
||||
const labelChanged =
|
||||
prevElementsMap.get(label.id)?.strokeColor !== label.strokeColor;
|
||||
if (
|
||||
isTransparent(label.strokeColor) ||
|
||||
(containerChanged && !labelChanged)
|
||||
) {
|
||||
inkById.set(
|
||||
label.id,
|
||||
normalizeStickyNoteStrokeColor(container.strokeColor),
|
||||
);
|
||||
} else {
|
||||
inkById.set(
|
||||
container.id,
|
||||
normalizeStickyNoteStrokeColor(label.strokeColor),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!inkById.size) {
|
||||
return elements;
|
||||
}
|
||||
return elements.map((element) => {
|
||||
const strokeColor = inkById.get(element.id);
|
||||
return strokeColor === undefined
|
||||
? element
|
||||
: (newElementWith(element as ExcalidrawElement, { strokeColor }) as T);
|
||||
});
|
||||
};
|
||||
|
||||
const jitter = (random: () => number, amount: number) =>
|
||||
(random() * 2 - 1) * amount;
|
||||
|
||||
const pointAtDistance = (
|
||||
from: StickyNoteRenderPoint,
|
||||
to: StickyNoteRenderPoint,
|
||||
distance: number,
|
||||
) => {
|
||||
const dx = to.x - from.x;
|
||||
const dy = to.y - from.y;
|
||||
const length = Math.hypot(dx, dy);
|
||||
|
||||
if (!length) {
|
||||
return from;
|
||||
}
|
||||
|
||||
const ratio = Math.min(distance / length, 1);
|
||||
return {
|
||||
x: from.x + dx * ratio,
|
||||
y: from.y + dy * ratio,
|
||||
};
|
||||
};
|
||||
|
||||
export const getStickyNoteCornerRadius = (
|
||||
element: ExcalidrawStickyNoteElement,
|
||||
) => {
|
||||
if (!element.roundness) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(
|
||||
Math.min(element.width, element.height) * STICKY_NOTE_CORNER_RADIUS_RATIO,
|
||||
STICKY_NOTE_MAX_CORNER_RADIUS,
|
||||
);
|
||||
};
|
||||
|
||||
export const getStickyNoteRenderPoints = (
|
||||
element: ExcalidrawStickyNoteElement,
|
||||
{
|
||||
offsetX = 0,
|
||||
offsetY = 0,
|
||||
seedOffset = 0,
|
||||
}: {
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
seedOffset?: number;
|
||||
} = {},
|
||||
): StickyNoteRenderPoint[] => {
|
||||
const roughness = Math.max(0, Math.min(2, Math.round(element.roughness)));
|
||||
const amount = Math.min(
|
||||
STICKY_NOTE_RENDER_ROUGHNESS[roughness],
|
||||
Math.min(element.width, element.height) * 0.012,
|
||||
);
|
||||
|
||||
if (!amount) {
|
||||
return [
|
||||
{ x: offsetX, y: offsetY },
|
||||
{ x: offsetX + element.width, y: offsetY },
|
||||
{ x: offsetX + element.width, y: offsetY + element.height },
|
||||
{ x: offsetX, y: offsetY + element.height },
|
||||
];
|
||||
}
|
||||
|
||||
const random = seededRandom(element.seed + seedOffset);
|
||||
|
||||
return [
|
||||
{
|
||||
x: offsetX + jitter(random, amount),
|
||||
y: offsetY + jitter(random, amount),
|
||||
},
|
||||
{
|
||||
x: offsetX + element.width + jitter(random, amount),
|
||||
y: offsetY + jitter(random, amount),
|
||||
},
|
||||
{
|
||||
x: offsetX + element.width + jitter(random, amount),
|
||||
y: offsetY + element.height + jitter(random, amount),
|
||||
},
|
||||
{
|
||||
x: offsetX + jitter(random, amount),
|
||||
y: offsetY + element.height + jitter(random, amount),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const getStickyNotePathCommands = (
|
||||
element: ExcalidrawStickyNoteElement,
|
||||
{ shadow = false }: { shadow?: boolean } = {},
|
||||
): StickyNotePathCommand[] => {
|
||||
const points = getStickyNoteRenderPoints(
|
||||
element,
|
||||
shadow
|
||||
? {
|
||||
offsetX: STICKY_NOTE_SHADOW_OFFSET,
|
||||
offsetY: STICKY_NOTE_SHADOW_OFFSET,
|
||||
seedOffset: 1,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
const radius = getStickyNoteCornerRadius(element);
|
||||
// The note's seed keeps the same corner for the paper and shadow through
|
||||
// redraws and resizes.
|
||||
const liftedCorner =
|
||||
element.roughness === 2
|
||||
? Math.floor(seededRandom(element.seed)() * points.length)
|
||||
: -1;
|
||||
|
||||
if (!radius && liftedCorner === -1) {
|
||||
return [
|
||||
{ type: "move", point: points[0] },
|
||||
...points.slice(1).map(
|
||||
(point): StickyNotePathCommand => ({
|
||||
type: "line",
|
||||
point,
|
||||
}),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const corners = points.map((point, index): StickyNotePathCommand[] => {
|
||||
const prev = points[(index + points.length - 1) % points.length];
|
||||
const next = points[(index + 1) % points.length];
|
||||
const cornerRadius = Math.min(
|
||||
radius,
|
||||
Math.hypot(point.x - prev.x, point.y - prev.y) / 2,
|
||||
Math.hypot(point.x - next.x, point.y - next.y) / 2,
|
||||
);
|
||||
|
||||
if (index === liftedCorner) {
|
||||
const size = Math.min(element.width, element.height);
|
||||
const reach = Math.min(size * 0.18, 40);
|
||||
// The shadow follows the paper inward at half the bend, keeping its
|
||||
// usual down-right offset from the light at the top left.
|
||||
const lift = Math.min(size * 0.02, 5) * (shadow ? 0.5 : 1);
|
||||
// The bend joins the straight edges before the corner, keeping it local.
|
||||
const tip = {
|
||||
x: point.x + (index === 1 || index === 2 ? -lift : lift),
|
||||
y: point.y + (index >= 2 ? -lift : lift),
|
||||
};
|
||||
const start = pointAtDistance(point, prev, reach);
|
||||
const end = pointAtDistance(point, next, reach);
|
||||
return [
|
||||
{ type: "line", point: start },
|
||||
{
|
||||
type: "quadratic",
|
||||
control: pointAtDistance(point, prev, reach / 2),
|
||||
point: pointAtDistance(tip, start, cornerRadius),
|
||||
},
|
||||
{
|
||||
type: "quadratic",
|
||||
control: tip,
|
||||
point: pointAtDistance(tip, end, cornerRadius),
|
||||
},
|
||||
{
|
||||
type: "quadratic",
|
||||
control: pointAtDistance(point, next, reach / 2),
|
||||
point: end,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{ type: "line", point: pointAtDistance(point, prev, cornerRadius) },
|
||||
{
|
||||
type: "quadratic",
|
||||
control: point,
|
||||
point: pointAtDistance(point, next, cornerRadius),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const commands: StickyNotePathCommand[] = [
|
||||
{ type: "move", point: corners[0][corners[0].length - 1].point },
|
||||
];
|
||||
|
||||
for (let index = 1; index <= corners.length; index++) {
|
||||
commands.push(...corners[index % corners.length]);
|
||||
}
|
||||
|
||||
return commands;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// font size — `baseFontSize` is the size the user picked, `fontSize` the fitted one
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// clamping the ceiling: the fit steps down from it in `STICKY_NOTE_FONT_STEP`
|
||||
// increments, and for values >= ~2^56 (1e20 - 2 === 1e20) that would never
|
||||
// progress and hang the editor
|
||||
export const normalizeStickyNoteFontSize = (fontSize: number) => {
|
||||
if (!Number.isFinite(fontSize)) {
|
||||
return STICKY_NOTE_FALLBACK_FONT_SIZE;
|
||||
}
|
||||
return Math.min(STICKY_NOTE_MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, fontSize));
|
||||
};
|
||||
|
||||
/** whether the text element is the label of a sticky note */
|
||||
export const isStickyNoteBoundText = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
return (
|
||||
!!textElement.containerId &&
|
||||
isStickyNoteElement(elementsMap.get(textElement.containerId))
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The font size the user picked: the ceiling the auto-fit shrinks from for a
|
||||
* sticky note label, plain `fontSize` for any other text.
|
||||
*
|
||||
* Container-aware on purpose — generic binding repair (duplication, history)
|
||||
* rewrites `containerId` without touching the ceiling, so a numeric
|
||||
* `baseFontSize` alone does not prove the text is still sticky-bound.
|
||||
*/
|
||||
export const getBaseFontSize = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
return isStickyNoteBoundText(textElement, elementsMap)
|
||||
? textElement.baseFontSize ?? textElement.fontSize
|
||||
: textElement.fontSize;
|
||||
};
|
||||
|
||||
/** the update that applies a user-picked font size (see `getBaseFontSize`) */
|
||||
export const getBaseFontSizeUpdate = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
fontSize: number,
|
||||
elementsMap: ElementsMap,
|
||||
): { fontSize: number } | { baseFontSize: number } => {
|
||||
return isStickyNoteBoundText(textElement, elementsMap)
|
||||
? { baseFontSize: normalizeStickyNoteFontSize(fontSize) }
|
||||
: { fontSize };
|
||||
};
|
||||
|
||||
const MONTHS = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
];
|
||||
|
||||
/**
|
||||
* The creation-date label: absolute, so painting needs no clock and exports
|
||||
* don't go stale, and short ("7 Sep") while the year is the current one.
|
||||
* `null` when the note has no usable timestamp (files restored without one).
|
||||
* Fixed English in the viewer's local time zone — the element package has no
|
||||
* locale, and the footer is chosen by width bucket rather than measured.
|
||||
*/
|
||||
export const getStickyNoteDateLabel = (
|
||||
created: ExcalidrawStickyNoteElement["created"],
|
||||
{ short = false, now = Date.now() }: { short?: boolean; now?: number } = {},
|
||||
): string | null => {
|
||||
if (created === null || !Number.isFinite(created)) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(created);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
const label = `${date.getDate()} ${MONTHS[date.getMonth()]}`;
|
||||
const year = date.getFullYear();
|
||||
return short || year === new Date(now).getFullYear()
|
||||
? label
|
||||
: `${label} ${year}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* What the footer paints and where, in note-local coordinates — shared by the
|
||||
* canvas and SVG renderers, which take the font and opacity from
|
||||
* `STICKY_NOTE_FOOTER`. `null` for the 0×0 creation draft and anything under
|
||||
* the data floor, where the band would overlap the top padding.
|
||||
*/
|
||||
export const getStickyNoteFooter = (
|
||||
element: Pick<ExcalidrawStickyNoteElement, "created" | "width" | "height">,
|
||||
now = Date.now(),
|
||||
) => {
|
||||
if (
|
||||
element.width < STICKY_NOTE_MIN_SIZE ||
|
||||
element.height < STICKY_NOTE_MIN_SIZE
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const text = getStickyNoteDateLabel(element.created, {
|
||||
short:
|
||||
element.width - STICKY_NOTE_PADDING * 2 <
|
||||
STICKY_NOTE_FOOTER.minBodyWidthForYear,
|
||||
now,
|
||||
});
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
text,
|
||||
x: element.width - STICKY_NOTE_PADDING,
|
||||
y: element.height - STICKY_NOTE_FOOTER.baselineFromBottom,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The smallest note the UI lets a user create or resize to: one line at the
|
||||
* label's font ceiling plus padding (and, vertically, the footer), never below
|
||||
* `STICKY_NOTE_MIN_SIZE`. Without the font term a fresh note would grow on
|
||||
* the very first keystroke. Data-level passes (restore, action post-passes)
|
||||
* only enforce the constant floor — the layout grows a note as needed.
|
||||
*/
|
||||
export const getStickyNoteMinSize = ({
|
||||
fontSize,
|
||||
fontFamily,
|
||||
}: Pick<ExcalidrawTextElement, "fontSize" | "fontFamily">) => {
|
||||
const lineHeightPx = Math.ceil(
|
||||
normalizeStickyNoteFontSize(fontSize) * getLineHeight(fontFamily),
|
||||
);
|
||||
return {
|
||||
width: Math.max(
|
||||
STICKY_NOTE_MIN_SIZE,
|
||||
lineHeightPx + STICKY_NOTE_PADDING * 2,
|
||||
),
|
||||
height: Math.max(
|
||||
STICKY_NOTE_MIN_SIZE,
|
||||
lineHeightPx + STICKY_NOTE_BODY_INSET_Y,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// layout — one pure calculation, two ways of applying it
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export type StickyNoteLayoutAnchor = VerticalResizeAnchor;
|
||||
|
||||
export type StickyNoteLayoutOpts = {
|
||||
/** unwrapped text to lay out; defaults to the label's `originalText` */
|
||||
originalText?: string;
|
||||
/**
|
||||
* absolute base-height intent — the gesture-start value to preserve, or the
|
||||
* height a resize asked for. Omitted = keep the note's live `baseHeight`
|
||||
* (typing, style changes, restore).
|
||||
*/
|
||||
baseHeight?: number;
|
||||
/**
|
||||
* absolute font-ceiling intent (a proportional resize passes
|
||||
* `gesture-start ceiling × scale`). Omitted = keep the label's live ceiling.
|
||||
* Never a multiplier on the live value — that compounds across pointer-moves.
|
||||
*/
|
||||
baseFontSize?: number;
|
||||
/** the edge that stays put when the content correction changes the height */
|
||||
anchor?: StickyNoteLayoutAnchor;
|
||||
};
|
||||
|
||||
export type StickyNoteLayout = {
|
||||
container: Pick<
|
||||
ExcalidrawStickyNoteElement,
|
||||
"x" | "y" | "width" | "height" | "baseHeight"
|
||||
>;
|
||||
text: Pick<
|
||||
ExcalidrawTextElement,
|
||||
| "text"
|
||||
| "fontSize"
|
||||
| "baseFontSize"
|
||||
| "width"
|
||||
| "height"
|
||||
| "x"
|
||||
| "y"
|
||||
| "angle"
|
||||
> | null;
|
||||
};
|
||||
|
||||
type FontFit = {
|
||||
text: string;
|
||||
fontSize: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
// `computeBoundTextPosition` only consults the map for arrow containers
|
||||
const NO_ELEMENTS: ElementsMap = new Map();
|
||||
|
||||
const getStickyNoteBaseWidth = (container: ExcalidrawStickyNoteElement) => {
|
||||
return Math.max(container.width, STICKY_NOTE_MIN_SIZE);
|
||||
};
|
||||
|
||||
/**
|
||||
* Largest font size on the grid `{ceiling − k·STEP} ∪ {min}` whose wrapped
|
||||
* text fits the note; `min` when nothing fits (the note then grows). The grid
|
||||
* is anchored at the ceiling so the result never depends on earlier edits.
|
||||
* Warm-started from the previously fitted size: the common keystroke costs one
|
||||
* or two wrap+measure passes, a cold search at most log2(steps) + 2.
|
||||
*/
|
||||
const fitStickyNoteFont = (
|
||||
fit: (fontSize: number) => FontFit,
|
||||
{
|
||||
baseFontSize,
|
||||
fontSizeMin,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
warmStart,
|
||||
}: {
|
||||
baseFontSize: number;
|
||||
fontSizeMin: number;
|
||||
maxWidth: number;
|
||||
maxHeight: number;
|
||||
warmStart: number;
|
||||
},
|
||||
): FontFit => {
|
||||
const steps = Math.max(
|
||||
0,
|
||||
Math.ceil((baseFontSize - fontSizeMin) / STICKY_NOTE_FONT_STEP),
|
||||
);
|
||||
const sizeAt = (index: number) =>
|
||||
index >= steps ? fontSizeMin : baseFontSize - index * STICKY_NOTE_FONT_STEP;
|
||||
|
||||
const fits = new Map<number, FontFit>();
|
||||
const at = (index: number) => {
|
||||
let result = fits.get(index);
|
||||
if (!result) {
|
||||
result = fit(sizeAt(index));
|
||||
fits.set(index, result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const doesFit = (index: number) => {
|
||||
const result = at(index);
|
||||
return result.width <= maxWidth && result.height <= maxHeight;
|
||||
};
|
||||
|
||||
if (steps === 0) {
|
||||
return at(0);
|
||||
}
|
||||
|
||||
// the previous fitted size, snapped onto the grid and clamped into the
|
||||
// current interval (a lowered ceiling must not keep the old larger size)
|
||||
const warm = clamp(
|
||||
Math.round((baseFontSize - warmStart) / STICKY_NOTE_FONT_STEP),
|
||||
0,
|
||||
steps,
|
||||
);
|
||||
|
||||
// binary search for the smallest index (largest font) that fits
|
||||
let lo: number;
|
||||
let hi: number;
|
||||
if (doesFit(warm)) {
|
||||
if (warm === 0 || !doesFit(warm - 1)) {
|
||||
return at(warm);
|
||||
}
|
||||
lo = 0;
|
||||
hi = warm - 1;
|
||||
} else {
|
||||
if (warm === steps) {
|
||||
return at(steps);
|
||||
}
|
||||
lo = warm + 1;
|
||||
hi = steps;
|
||||
}
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (doesFit(mid)) {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid + 1;
|
||||
}
|
||||
}
|
||||
return at(lo);
|
||||
};
|
||||
|
||||
/**
|
||||
* The single source of truth for a sticky note's geometry: wraps the label at
|
||||
* the note's width, fits the font under the ceiling, grows the note past
|
||||
* `baseHeight` only when the text still overflows at the minimum size, and
|
||||
* positions the label inside. Pure — returns the updates for both elements.
|
||||
*/
|
||||
export const getStickyNoteLayout = (
|
||||
container: ExcalidrawStickyNoteElement,
|
||||
textElement: ExcalidrawTextElement | null,
|
||||
opts: StickyNoteLayoutOpts = {},
|
||||
): StickyNoteLayout => {
|
||||
const baseWidth = getStickyNoteBaseWidth(container);
|
||||
const baseHeight = Math.max(
|
||||
opts.baseHeight ?? (container.baseHeight || container.height),
|
||||
STICKY_NOTE_MIN_SIZE,
|
||||
);
|
||||
const anchor = opts.anchor ?? "top";
|
||||
|
||||
if (!textElement) {
|
||||
// an empty note sits at its base height
|
||||
return {
|
||||
container: {
|
||||
...getPositionAfterHeightChange(container, baseHeight, anchor),
|
||||
width: baseWidth,
|
||||
height: baseHeight,
|
||||
baseHeight,
|
||||
},
|
||||
text: null,
|
||||
};
|
||||
}
|
||||
|
||||
const originalText = opts.originalText ?? textElement.originalText;
|
||||
const baseFontSize = normalizeStickyNoteFontSize(
|
||||
opts.baseFontSize ?? textElement.baseFontSize ?? textElement.fontSize,
|
||||
);
|
||||
const fontSizeMin = Math.min(STICKY_NOTE_MIN_FONT_SIZE, baseFontSize);
|
||||
const maxWidth = Math.max(baseWidth - STICKY_NOTE_PADDING * 2, 1);
|
||||
const maxHeight = Math.max(baseHeight - STICKY_NOTE_BODY_INSET_Y, 0);
|
||||
const { fontFamily, lineHeight } = textElement;
|
||||
|
||||
const fit = (fontSize: number): FontFit => {
|
||||
const font = getFontString({ fontFamily, fontSize });
|
||||
const text = wrapText(originalText, font, maxWidth);
|
||||
return { text, fontSize, ...measureText(text, font, lineHeight) };
|
||||
};
|
||||
|
||||
const isBlank = !originalText.trim();
|
||||
const fitted: FontFit = isBlank
|
||||
? {
|
||||
text: "",
|
||||
fontSize: baseFontSize,
|
||||
...measureText(
|
||||
"",
|
||||
getFontString({ fontFamily, fontSize: baseFontSize }),
|
||||
lineHeight,
|
||||
),
|
||||
}
|
||||
: fitStickyNoteFont(fit, {
|
||||
baseFontSize,
|
||||
fontSizeMin,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
warmStart: textElement.fontSize,
|
||||
});
|
||||
|
||||
const height = isBlank
|
||||
? baseHeight
|
||||
: Math.max(baseHeight, fitted.height + STICKY_NOTE_BODY_INSET_Y);
|
||||
const nextContainer = {
|
||||
...getPositionAfterHeightChange(container, height, anchor),
|
||||
width: baseWidth,
|
||||
height,
|
||||
baseHeight,
|
||||
};
|
||||
const { x, y } = computeBoundTextPosition(
|
||||
{ ...container, ...nextContainer },
|
||||
{
|
||||
...textElement,
|
||||
text: fitted.text,
|
||||
fontSize: fitted.fontSize,
|
||||
width: fitted.width,
|
||||
height: fitted.height,
|
||||
} as ExcalidrawTextElementWithContainer,
|
||||
NO_ELEMENTS,
|
||||
);
|
||||
|
||||
return {
|
||||
container: nextContainer,
|
||||
text: {
|
||||
text: fitted.text,
|
||||
fontSize: fitted.fontSize,
|
||||
baseFontSize,
|
||||
width: fitted.width,
|
||||
height: fitted.height,
|
||||
x,
|
||||
y,
|
||||
angle: container.angle,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Resize intents for a sticky note — the §3.3 matrix of the plan. Every value
|
||||
* is absolute and derived from the gesture-start snapshot on each pointer-move,
|
||||
* so releasing Shift (or Alt) mid-gesture restores the original base height
|
||||
* and ceiling instead of keeping values a previous move scaled.
|
||||
*
|
||||
* - width-only gestures (free E/W, Stats W) preserve the base height
|
||||
* - height-changing gestures use the requested height as the new base
|
||||
* - proportional gestures (Shift on any handle, aspect-locked multi-select,
|
||||
* Stats group) additionally scale the font ceiling with the note
|
||||
* - flips preserve everything
|
||||
* - the content correction anchors at the edge the gesture holds still
|
||||
*/
|
||||
export const getStickyNoteResizeIntent = (
|
||||
/** the note with the requested geometry already applied */
|
||||
container: ExcalidrawStickyNoteElement,
|
||||
originalElementsMap: ElementsMap,
|
||||
handleDirection: TransformHandleDirection,
|
||||
{
|
||||
proportional,
|
||||
fromCenter,
|
||||
flip = false,
|
||||
}: { proportional: boolean; fromCenter: boolean; flip?: boolean },
|
||||
): Pick<StickyNoteLayoutOpts, "baseHeight" | "baseFontSize" | "anchor"> => {
|
||||
const origContainer = originalElementsMap.get(container.id);
|
||||
const origSticky = isStickyNoteElement(origContainer)
|
||||
? origContainer
|
||||
: container;
|
||||
const origText = getBoundTextElement(origSticky, originalElementsMap);
|
||||
const ceiling = origText
|
||||
? origText.baseFontSize ?? origText.fontSize
|
||||
: undefined;
|
||||
|
||||
if (flip) {
|
||||
return { baseHeight: origSticky.baseHeight, baseFontSize: ceiling };
|
||||
}
|
||||
|
||||
const changesHeight =
|
||||
proportional ||
|
||||
handleDirection.includes("n") ||
|
||||
handleDirection.includes("s");
|
||||
const scale =
|
||||
proportional && origSticky.width ? container.width / origSticky.width : 1;
|
||||
const isSideHandle = handleDirection.length === 1;
|
||||
|
||||
return {
|
||||
baseHeight: changesHeight ? container.height : origSticky.baseHeight,
|
||||
baseFontSize: ceiling === undefined ? undefined : ceiling * scale,
|
||||
anchor: fromCenter
|
||||
? "center"
|
||||
: handleDirection.includes("n")
|
||||
? "bottom"
|
||||
: proportional &&
|
||||
isSideHandle &&
|
||||
(handleDirection === "e" || handleDirection === "w")
|
||||
? // Shift+E/W holds the opposite side's midpoint, i.e. the vertical center
|
||||
"center"
|
||||
: "top",
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies `getStickyNoteLayout` to a live scene, then runs the bound-arrow
|
||||
* pass unless the caller already owns one (`bindings: false`) or needs to
|
||||
* forward its context (`bindings: { simultaneouslyUpdated }` — arrows resized
|
||||
* in the same gesture must not be moved by the pass, `updateBoundElements`
|
||||
* skips them only when told).
|
||||
*/
|
||||
export const updateStickyNoteLayout = (
|
||||
container: ExcalidrawStickyNoteElement,
|
||||
scene: Scene,
|
||||
{
|
||||
text,
|
||||
bindings,
|
||||
...layoutOpts
|
||||
}: StickyNoteLayoutOpts & {
|
||||
/** the label to lay out when it is an uncommitted clone (font actions clone before install) */
|
||||
text?: ExcalidrawTextElement | null;
|
||||
bindings?:
|
||||
| { simultaneouslyUpdated?: readonly NonDeletedExcalidrawElement[] }
|
||||
| false;
|
||||
} = {},
|
||||
): StickyNoteLayout => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const textElement =
|
||||
text === undefined ? getBoundTextElement(container, elementsMap) : text;
|
||||
const layout = getStickyNoteLayout(container, textElement, layoutOpts);
|
||||
|
||||
scene.mutateElement(container, layout.container);
|
||||
if (textElement && layout.text) {
|
||||
scene.mutateElement(textElement, layout.text);
|
||||
}
|
||||
if (bindings !== false && !container.isDeleted) {
|
||||
updateBoundElements(
|
||||
container as NonDeletedExcalidrawElement,
|
||||
scene,
|
||||
bindings || undefined,
|
||||
);
|
||||
}
|
||||
|
||||
return layout;
|
||||
};
|
||||
|
||||
const STICKY_NOTE_LAYOUT_INPUTS = {
|
||||
container: ["x", "y", "width", "baseHeight", "angle"],
|
||||
text: [
|
||||
"originalText",
|
||||
"baseFontSize",
|
||||
"fontFamily",
|
||||
"lineHeight",
|
||||
"textAlign",
|
||||
"verticalAlign",
|
||||
],
|
||||
} as const;
|
||||
|
||||
const hasStickyNoteLayoutInputChanged = (
|
||||
container: ExcalidrawStickyNoteElement,
|
||||
textElement: ExcalidrawTextElement | null,
|
||||
prevElementsMap: ElementsMap,
|
||||
) => {
|
||||
const prevContainer = prevElementsMap.get(container.id);
|
||||
const prevText = textElement ? prevElementsMap.get(textElement.id) : null;
|
||||
if (
|
||||
!prevContainer ||
|
||||
(textElement && !prevText) ||
|
||||
(!textElement &&
|
||||
prevContainer.boundElements?.some(({ type }) => type === "text"))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
STICKY_NOTE_LAYOUT_INPUTS.container.some(
|
||||
(key) => (prevContainer as any)[key] !== container[key],
|
||||
) ||
|
||||
(!!textElement &&
|
||||
STICKY_NOTE_LAYOUT_INPUTS.text.some(
|
||||
(key) => (prevText as any)[key] !== textElement[key],
|
||||
))
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies `getStickyNoteLayout` inside an immutable elements array (property
|
||||
* actions, paste styles, restore): the notes among `affectedIds` — or the
|
||||
* notes whose labels are — get both halves merged into their clones. Elements
|
||||
* that need no change keep their identity and version; when `prevElementsMap`
|
||||
* is given, notes whose layout inputs did not change skip the fit entirely.
|
||||
* Bound arrows are the caller's job once the result is installed.
|
||||
*/
|
||||
export const relayoutStickyNotes = <T extends ExcalidrawElement>(
|
||||
elements: readonly T[],
|
||||
affectedIds: ReadonlySet<ExcalidrawElement["id"]>,
|
||||
opts?: { prevElementsMap?: ElementsMap },
|
||||
): readonly T[] => {
|
||||
if (!affectedIds.size) {
|
||||
return elements;
|
||||
}
|
||||
const elementsMap = arrayToMap(elements);
|
||||
const containerIds = new Set<ExcalidrawElement["id"]>();
|
||||
for (const id of affectedIds) {
|
||||
const element = elementsMap.get(id);
|
||||
if (!element || element.isDeleted) {
|
||||
continue;
|
||||
}
|
||||
if (isStickyNoteElement(element)) {
|
||||
containerIds.add(element.id);
|
||||
} else if (isTextElement(element) && element.containerId) {
|
||||
const container = elementsMap.get(element.containerId);
|
||||
if (container && !container.isDeleted && isStickyNoteElement(container)) {
|
||||
containerIds.add(container.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!containerIds.size) {
|
||||
return elements;
|
||||
}
|
||||
|
||||
const replacements = new Map<ExcalidrawElement["id"], T>();
|
||||
for (const id of containerIds) {
|
||||
const container = elementsMap.get(id) as ExcalidrawStickyNoteElement;
|
||||
const textElement = getBoundTextElement(container, elementsMap);
|
||||
if (
|
||||
opts?.prevElementsMap &&
|
||||
!hasStickyNoteLayoutInputChanged(
|
||||
container,
|
||||
textElement,
|
||||
opts.prevElementsMap,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const layout = getStickyNoteLayout(container, textElement);
|
||||
const nextContainer = newElementWith(container, layout.container);
|
||||
if (nextContainer !== container) {
|
||||
replacements.set(id, nextContainer as unknown as T);
|
||||
}
|
||||
if (textElement && layout.text) {
|
||||
const nextText = newElementWith(textElement, layout.text);
|
||||
if (nextText !== textElement) {
|
||||
replacements.set(textElement.id, nextText as unknown as T);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!replacements.size) {
|
||||
return elements;
|
||||
}
|
||||
return elements.map((element) => replacements.get(element.id) ?? element);
|
||||
};
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
ARROW_LABEL_WIDTH_FRACTION,
|
||||
BOUND_TEXT_PADDING,
|
||||
DEFAULT_FONT_SIZE,
|
||||
STICKY_NOTE_BODY_INSET_Y,
|
||||
STICKY_NOTE_PADDING,
|
||||
TEXT_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
getFontString,
|
||||
@@ -12,8 +14,6 @@ import {
|
||||
|
||||
import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { ExtractSetType } from "@excalidraw/common/utility-types";
|
||||
|
||||
import {
|
||||
@@ -21,15 +21,20 @@ import {
|
||||
updateOriginalContainerCache,
|
||||
} from "./containerCache";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { getPositionAfterHeightChange } from "./sizeHelpers";
|
||||
|
||||
import { updateStickyNoteLayout } from "./stickyNote";
|
||||
import { measureText } from "./textMeasurements";
|
||||
import { wrapText } from "./textWrapping";
|
||||
import {
|
||||
isBoundToContainer,
|
||||
isArrowElement,
|
||||
isStickyNoteElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import { isNonDeletedElement } from ".";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
import type { MaybeTransformHandleType } from "./transformHandles";
|
||||
@@ -40,7 +45,7 @@ import type {
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeleted,
|
||||
} from "./types";
|
||||
|
||||
export const redrawTextBoundingBox = (
|
||||
@@ -50,6 +55,14 @@ export const redrawTextBoundingBox = (
|
||||
) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
|
||||
if (container && isStickyNoteElement(container)) {
|
||||
// the sticky fit owns both halves (label + note geometry). `textElement`
|
||||
// may be an uncommitted clone (font actions clone before install), so it
|
||||
// is passed explicitly instead of looked up in the scene
|
||||
updateStickyNoteLayout(container, scene, { text: textElement });
|
||||
return;
|
||||
}
|
||||
|
||||
let maxWidth = undefined;
|
||||
|
||||
if (!isProdEnv()) {
|
||||
@@ -140,11 +153,19 @@ export const redrawTextBoundingBox = (
|
||||
};
|
||||
|
||||
export const handleBindTextResize = (
|
||||
container: NonDeletedExcalidrawElement,
|
||||
container: ExcalidrawElement,
|
||||
scene: Scene,
|
||||
transformHandleType: MaybeTransformHandleType,
|
||||
shouldMaintainAspectRatio = false,
|
||||
shouldResizeFromCenter = false,
|
||||
flipByY = false,
|
||||
) => {
|
||||
if (isStickyNoteElement(container)) {
|
||||
// resize callers pass their intents to `updateStickyNoteLayout` directly
|
||||
// and own the bound-arrow pass; this is the fallback for generic callers
|
||||
updateStickyNoteLayout(container, scene, { bindings: false });
|
||||
return;
|
||||
}
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const boundTextElementId = getBoundTextElementId(container);
|
||||
if (!boundTextElementId) {
|
||||
@@ -189,18 +210,24 @@ export const handleBindTextResize = (
|
||||
container.type,
|
||||
);
|
||||
|
||||
const diff = containerHeight - container.height;
|
||||
// fix the y coord when resizing from ne/nw/n
|
||||
const updatedY =
|
||||
!isArrowElement(container) &&
|
||||
(transformHandleType === "ne" ||
|
||||
transformHandleType === "nw" ||
|
||||
transformHandleType === "n")
|
||||
? container.y - diff
|
||||
: container.y;
|
||||
// Crossing the opposite edge swaps the anchor for text-driven growth.
|
||||
const shouldResizeFromTop =
|
||||
(transformHandleType === "n" ||
|
||||
transformHandleType === "ne" ||
|
||||
transformHandleType === "nw") !== flipByY;
|
||||
|
||||
scene.mutateElement(container, {
|
||||
height: containerHeight,
|
||||
y: updatedY,
|
||||
...(!isArrowElement(container) &&
|
||||
getPositionAfterHeightChange(
|
||||
container,
|
||||
containerHeight,
|
||||
shouldResizeFromCenter
|
||||
? "center"
|
||||
: shouldResizeFromTop
|
||||
? "bottom"
|
||||
: "top",
|
||||
)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -241,6 +268,18 @@ export const computeBoundTextPosition = (
|
||||
y = containerCoords.y;
|
||||
} else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
y = containerCoords.y + (maxContainerHeight - boundTextElement.height);
|
||||
} else if (isStickyNoteElement(container)) {
|
||||
// a note's label body ends above the creation-date footer, but a label
|
||||
// centered in that body sits visibly high — center it in the whole
|
||||
// padded note while it stays clear of the footer, and only push it up
|
||||
// against the body's bottom once it would overlap
|
||||
const paddedHeight = container.height - STICKY_NOTE_PADDING * 2;
|
||||
y =
|
||||
containerCoords.y +
|
||||
Math.min(
|
||||
(paddedHeight - boundTextElement.height) / 2,
|
||||
maxContainerHeight - boundTextElement.height,
|
||||
);
|
||||
} else {
|
||||
y =
|
||||
containerCoords.y +
|
||||
@@ -257,10 +296,17 @@ export const computeBoundTextPosition = (
|
||||
const angle = (container.angle ?? 0) as Radians;
|
||||
|
||||
if (angle !== 0) {
|
||||
const contentCenter = pointFrom(
|
||||
containerCoords.x + maxContainerWidth / 2,
|
||||
containerCoords.y + maxContainerHeight / 2,
|
||||
);
|
||||
// A sticky's footer makes its body asymmetric. The body still rotates
|
||||
// about the note's center, rather than about its own (higher) center.
|
||||
const contentCenter = isStickyNoteElement(container)
|
||||
? pointFrom(
|
||||
container.x + container.width / 2,
|
||||
container.y + container.height / 2,
|
||||
)
|
||||
: pointFrom(
|
||||
containerCoords.x + maxContainerWidth / 2,
|
||||
containerCoords.y + maxContainerHeight / 2,
|
||||
);
|
||||
const textCenter = pointFrom(
|
||||
x + boundTextElement.width / 2,
|
||||
y + boundTextElement.height / 2,
|
||||
@@ -286,36 +332,50 @@ export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
|
||||
export const getBoundTextElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
): NonDeleted<ExcalidrawTextElementWithContainer> | null => {
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
const boundTextElementId = getBoundTextElementId(element);
|
||||
|
||||
if (boundTextElementId) {
|
||||
return (elementsMap.get(boundTextElementId) ||
|
||||
null) as ExcalidrawTextElementWithContainer | null;
|
||||
const boundTextElement = (elementsMap.get(boundTextElementId) ||
|
||||
null) as NonDeleted<ExcalidrawTextElementWithContainer> | null;
|
||||
|
||||
// SAFETY: This should never happen, but log it just in case
|
||||
if (boundTextElement && !isNonDeletedElement(boundTextElement)) {
|
||||
console.error(
|
||||
"[NONDELETED][INVARIANT] Bound text element `isDeleted: true` which is not expected.",
|
||||
);
|
||||
}
|
||||
|
||||
return boundTextElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getContainerElement = (
|
||||
element: ExcalidrawTextElement | null,
|
||||
export const getContainerElement = <
|
||||
T extends ExcalidrawTextElement,
|
||||
R extends ExcalidrawTextContainer,
|
||||
>(
|
||||
element: T | null,
|
||||
elementsMap: ElementsMap,
|
||||
): ExcalidrawTextContainer | null => {
|
||||
): R | null => {
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
if (element.containerId) {
|
||||
return (elementsMap.get(element.containerId) ||
|
||||
null) as ExcalidrawTextContainer | null;
|
||||
return (elementsMap.get(element.containerId) || null) as R | null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The point a text bound to this container centers on — and, for arrows, the
|
||||
* point the text tool snaps a new label to.
|
||||
*/
|
||||
export const getContainerCenter = (
|
||||
container: ExcalidrawElement,
|
||||
appState: AppState,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
if (!isArrowElement(container)) {
|
||||
@@ -324,38 +384,21 @@ export const getContainerCenter = (
|
||||
y: container.y + container.height / 2,
|
||||
};
|
||||
}
|
||||
const points = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
|
||||
const center = LinearElementEditor.getBoundTextElementCenter(
|
||||
container,
|
||||
elementsMap,
|
||||
);
|
||||
if (points.length % 2 === 1) {
|
||||
const index = Math.floor(container.points.length / 2);
|
||||
const midPoint = LinearElementEditor.getPointGlobalCoordinates(
|
||||
container,
|
||||
container.points[index],
|
||||
elementsMap,
|
||||
);
|
||||
return { x: midPoint[0], y: midPoint[1] };
|
||||
}
|
||||
const index = container.points.length / 2 - 1;
|
||||
let midSegmentMidpoint = LinearElementEditor.getEditorMidPoints(
|
||||
container,
|
||||
elementsMap,
|
||||
appState,
|
||||
)[index];
|
||||
if (!midSegmentMidpoint) {
|
||||
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||
container,
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
|
||||
|
||||
return { x: center[0], y: center[1] };
|
||||
};
|
||||
|
||||
export const getContainerCoords = (container: NonDeletedExcalidrawElement) => {
|
||||
let offsetX = BOUND_TEXT_PADDING;
|
||||
let offsetY = BOUND_TEXT_PADDING;
|
||||
export const getContainerCoords = (container: ExcalidrawElement) => {
|
||||
const padding = isStickyNoteElement(container)
|
||||
? STICKY_NOTE_PADDING
|
||||
: BOUND_TEXT_PADDING;
|
||||
let offsetX = padding;
|
||||
let offsetY = padding;
|
||||
|
||||
if (container.type === "ellipse") {
|
||||
// The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6172
|
||||
@@ -400,8 +443,37 @@ export const getBoundTextElementPosition = (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the text element with its accurate coords. An arrow label's
|
||||
* position is derived from the arrow at render time rather than kept in
|
||||
* sync on the element itself (see getBoundTextElementPosition), so
|
||||
* consumers reading a label's coords directly must go through this helper.
|
||||
*/
|
||||
export const getTextElementWithAccuratePosition = <
|
||||
T extends ExcalidrawTextElement,
|
||||
>(
|
||||
textElement: T,
|
||||
elementsMap: ElementsMap,
|
||||
): T => {
|
||||
if (!textElement.containerId) {
|
||||
return textElement;
|
||||
}
|
||||
const container = elementsMap.get(textElement.containerId);
|
||||
if (!container || !isArrowElement(container)) {
|
||||
return textElement;
|
||||
}
|
||||
return {
|
||||
...textElement,
|
||||
...LinearElementEditor.getBoundTextElementPosition(
|
||||
container,
|
||||
textElement as ExcalidrawTextElementWithContainer,
|
||||
elementsMap,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const shouldAllowVerticalAlign = (
|
||||
selectedElements: NonDeletedExcalidrawElement[],
|
||||
selectedElements: readonly ExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
return selectedElements.some((element) => {
|
||||
@@ -417,7 +489,7 @@ export const shouldAllowVerticalAlign = (
|
||||
};
|
||||
|
||||
export const suppportsHorizontalAlign = (
|
||||
selectedElements: NonDeletedExcalidrawElement[],
|
||||
selectedElements: readonly ExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
return selectedElements.some((element) => {
|
||||
@@ -435,6 +507,7 @@ export const suppportsHorizontalAlign = (
|
||||
|
||||
const VALID_CONTAINER_TYPES = new Set([
|
||||
"rectangle",
|
||||
"stickynote",
|
||||
"ellipse",
|
||||
"diamond",
|
||||
"arrow",
|
||||
@@ -486,7 +559,13 @@ export const getBoundTextMaxWidth = (
|
||||
// Math.round(width / 2) - https://github.com/excalidraw/excalidraw/pull/6265
|
||||
return Math.round(width / 2) - BOUND_TEXT_PADDING * 2;
|
||||
}
|
||||
return width - BOUND_TEXT_PADDING * 2;
|
||||
return (
|
||||
width -
|
||||
(isStickyNoteElement(container)
|
||||
? STICKY_NOTE_PADDING
|
||||
: BOUND_TEXT_PADDING) *
|
||||
2
|
||||
);
|
||||
};
|
||||
|
||||
export const getBoundTextMaxHeight = (
|
||||
@@ -494,6 +573,10 @@ export const getBoundTextMaxHeight = (
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
) => {
|
||||
const { height } = container;
|
||||
if (isStickyNoteElement(container)) {
|
||||
// the label body ends above the creation-date footer
|
||||
return Math.max(0, height - STICKY_NOTE_BODY_INSET_Y);
|
||||
}
|
||||
if (isArrowElement(container)) {
|
||||
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
|
||||
if (containerHeight <= 0) {
|
||||
@@ -530,3 +613,5 @@ export const getTextFromElements = (
|
||||
.join(separator);
|
||||
return text;
|
||||
};
|
||||
|
||||
export const DEFAULT_BOUND_TEXT_LABEL_POSITION = 0.5;
|
||||
|
||||
@@ -11,30 +11,44 @@ import {
|
||||
assertNever,
|
||||
cloneJSON,
|
||||
getFontString,
|
||||
getUpdatedTimestamp,
|
||||
isDevEnv,
|
||||
toBrandedType,
|
||||
getLineHeight,
|
||||
DEFAULT_STICKY_NOTE_SIZE,
|
||||
isTransparent,
|
||||
DEFAULT_ZOOM,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { MarkOptional } from "@excalidraw/common/utility-types";
|
||||
import type { Zoom } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { bindBindingElement } from "./binding";
|
||||
import {
|
||||
newArrowElement,
|
||||
newElement,
|
||||
newEmbeddableElement,
|
||||
newFrameElement,
|
||||
newFreeDrawElement,
|
||||
newIframeElement,
|
||||
newImageElement,
|
||||
newLinearElement,
|
||||
newMagicFrameElement,
|
||||
newTextElement,
|
||||
type ElementConstructorOpts,
|
||||
newStickyNoteElement,
|
||||
normalizeStickyNoteGeometry,
|
||||
} from "./newElement";
|
||||
import { normalizeStickyNoteStrokeColor } from "./stickyNote";
|
||||
import { measureText, normalizeText } from "./textMeasurements";
|
||||
import { isArrowElement } from "./typeChecks";
|
||||
import { isArrowElement, isStickyNoteElement } from "./typeChecks";
|
||||
|
||||
import { syncInvalidIndices } from "./fractionalIndex";
|
||||
|
||||
import { redrawTextBoundingBox } from "./textElement";
|
||||
import {
|
||||
DEFAULT_BOUND_TEXT_LABEL_POSITION,
|
||||
redrawTextBoundingBox,
|
||||
} from "./textElement";
|
||||
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
|
||||
@@ -57,11 +71,25 @@ import type {
|
||||
ExcalidrawTextElement,
|
||||
FileId,
|
||||
FontFamilyValues,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
Ordered,
|
||||
TextAlign,
|
||||
VerticalAlign,
|
||||
ExcalidrawStickyNoteElement,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Options for elements generated from a skeleton fragment (bound labels and
|
||||
* binding endpoints). Such elements are always constructed fresh, hence they
|
||||
* never carry a creation time of their own.
|
||||
*/
|
||||
type FragmentConstructorOpts = MarkOptional<
|
||||
Omit<ElementConstructorOpts, "created">,
|
||||
"x" | "y"
|
||||
>;
|
||||
|
||||
export type ValidLinearElement = {
|
||||
type: "arrow" | "line";
|
||||
x: number;
|
||||
@@ -72,7 +100,7 @@ export type ValidLinearElement = {
|
||||
fontFamily?: FontFamilyValues;
|
||||
textAlign?: TextAlign;
|
||||
verticalAlign?: VerticalAlign;
|
||||
} & MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
} & FragmentConstructorOpts;
|
||||
end?:
|
||||
| (
|
||||
| (
|
||||
@@ -112,9 +140,9 @@ export type ValidLinearElement = {
|
||||
text: string;
|
||||
}
|
||||
) &
|
||||
Partial<ExcalidrawTextElement>)
|
||||
Partial<Omit<ExcalidrawTextElement, "created">>)
|
||||
) &
|
||||
MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
FragmentConstructorOpts;
|
||||
start?:
|
||||
| (
|
||||
| (
|
||||
@@ -154,9 +182,9 @@ export type ValidLinearElement = {
|
||||
text: string;
|
||||
}
|
||||
) &
|
||||
Partial<ExcalidrawTextElement>)
|
||||
Partial<Omit<ExcalidrawTextElement, "created">>)
|
||||
) &
|
||||
MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
FragmentConstructorOpts;
|
||||
} & Partial<ExcalidrawLinearElement>;
|
||||
|
||||
export type ValidContainer =
|
||||
@@ -169,9 +197,21 @@ export type ValidContainer =
|
||||
fontFamily?: FontFamilyValues;
|
||||
textAlign?: TextAlign;
|
||||
verticalAlign?: VerticalAlign;
|
||||
} & MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
} & FragmentConstructorOpts;
|
||||
} & ElementConstructorOpts;
|
||||
|
||||
/**
|
||||
* A sticky note: an always-filled note whose label auto-fits. `label.fontSize`
|
||||
* is the font ceiling the fit shrinks from; the note grows past its height
|
||||
* (kept as `baseHeight`) only once the label hits the minimum font size.
|
||||
*/
|
||||
export type ValidStickyNote = {
|
||||
type: "stickynote";
|
||||
id?: ExcalidrawStickyNoteElement["id"];
|
||||
label?: Extract<ValidContainer, { label?: unknown }>["label"];
|
||||
} & ElementConstructorOpts &
|
||||
Partial<Pick<ExcalidrawStickyNoteElement, "baseHeight">>;
|
||||
|
||||
export type ExcalidrawElementSkeleton =
|
||||
| Extract<
|
||||
Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
@@ -183,6 +223,7 @@ export type ExcalidrawElementSkeleton =
|
||||
y: number;
|
||||
} & Partial<ExcalidrawLinearElement>)
|
||||
| ValidContainer
|
||||
| ValidStickyNote
|
||||
| ValidLinearElement
|
||||
| ({
|
||||
type: "text";
|
||||
@@ -217,9 +258,19 @@ const DEFAULT_DIMENSION = 100;
|
||||
|
||||
const bindTextToContainer = (
|
||||
container: ExcalidrawElement,
|
||||
textProps: { text: string } & MarkOptional<ElementConstructorOpts, "x" | "y">,
|
||||
textProps: { text: string } & FragmentConstructorOpts,
|
||||
scene: Scene,
|
||||
) => {
|
||||
// a note and its label share one ink: a label that sets its own color
|
||||
// gives it to the note (the footer paints with it); transparent falls
|
||||
// back to the note's
|
||||
const stickyInk = isStickyNoteElement(container)
|
||||
? normalizeStickyNoteStrokeColor(
|
||||
textProps.strokeColor && !isTransparent(textProps.strokeColor)
|
||||
? textProps.strokeColor
|
||||
: container.strokeColor,
|
||||
)
|
||||
: null;
|
||||
const textElement: ExcalidrawTextElement = newTextElement({
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -227,7 +278,10 @@ const bindTextToContainer = (
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
...textProps,
|
||||
containerId: container.id,
|
||||
strokeColor: textProps.strokeColor || container.strokeColor,
|
||||
strokeColor: stickyInk ?? (textProps.strokeColor || container.strokeColor),
|
||||
labelPosition: isArrowElement(container)
|
||||
? DEFAULT_BOUND_TEXT_LABEL_POSITION
|
||||
: null,
|
||||
});
|
||||
|
||||
Object.assign(container, {
|
||||
@@ -235,6 +289,9 @@ const bindTextToContainer = (
|
||||
type: "text",
|
||||
id: textElement.id,
|
||||
}),
|
||||
...(stickyInk && stickyInk !== container.strokeColor
|
||||
? { strokeColor: stickyInk }
|
||||
: null),
|
||||
});
|
||||
|
||||
redrawTextBoundingBox(textElement, container, scene);
|
||||
@@ -243,11 +300,12 @@ const bindTextToContainer = (
|
||||
};
|
||||
|
||||
const bindLinearElementToElement = (
|
||||
linearElement: ExcalidrawArrowElement,
|
||||
linearElement: NonDeleted<ExcalidrawArrowElement>,
|
||||
start: ValidLinearElement["start"],
|
||||
end: ValidLinearElement["end"],
|
||||
elementStore: ElementStore,
|
||||
scene: Scene,
|
||||
zoom: Zoom,
|
||||
): {
|
||||
linearElement: ExcalidrawLinearElement;
|
||||
startBoundElement?: ExcalidrawElement;
|
||||
@@ -331,10 +389,11 @@ const bindLinearElementToElement = (
|
||||
|
||||
bindBindingElement(
|
||||
linearElement,
|
||||
startBoundElement as ExcalidrawBindableElement,
|
||||
startBoundElement as NonDeleted<ExcalidrawBindableElement>,
|
||||
"orbit",
|
||||
"start",
|
||||
scene,
|
||||
zoom,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -407,10 +466,11 @@ const bindLinearElementToElement = (
|
||||
|
||||
bindBindingElement(
|
||||
linearElement,
|
||||
endBoundElement as ExcalidrawBindableElement,
|
||||
endBoundElement as NonDeleted<ExcalidrawBindableElement>,
|
||||
"orbit",
|
||||
"end",
|
||||
scene,
|
||||
zoom,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -492,7 +552,10 @@ class ElementStore {
|
||||
};
|
||||
|
||||
getElements = () => {
|
||||
return syncInvalidIndices(Array.from(this.excalidrawElements.values()));
|
||||
// programmatically created elements are always non-deleted
|
||||
return syncInvalidIndices(
|
||||
Array.from(this.excalidrawElements.values()),
|
||||
) as Ordered<NonDeletedExcalidrawElement>[];
|
||||
};
|
||||
|
||||
getElementsMap = () => {
|
||||
@@ -519,11 +582,14 @@ export const convertToExcalidrawElements = (
|
||||
const oldToNewElementIdMap = new Map<string, string>();
|
||||
|
||||
// Create individual elements
|
||||
// regenerated ids mean new instances, hence a fresh creation time as well
|
||||
const created = getUpdatedTimestamp();
|
||||
|
||||
for (const element of elements) {
|
||||
let excalidrawElement: ExcalidrawElement;
|
||||
const originalId = element.id;
|
||||
if (opts?.regenerateIds !== false) {
|
||||
Object.assign(element, { id: randomId() });
|
||||
Object.assign(element, { id: randomId(), created });
|
||||
}
|
||||
|
||||
switch (element.type) {
|
||||
@@ -622,10 +688,32 @@ export const convertToExcalidrawElements = (
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "freedraw":
|
||||
case "iframe":
|
||||
case "freedraw": {
|
||||
excalidrawElement = newFreeDrawElement({ ...element });
|
||||
break;
|
||||
}
|
||||
case "iframe": {
|
||||
excalidrawElement = newIframeElement({ ...element });
|
||||
break;
|
||||
}
|
||||
case "stickynote": {
|
||||
const width = element.width || DEFAULT_STICKY_NOTE_SIZE;
|
||||
const height = element.height || DEFAULT_STICKY_NOTE_SIZE;
|
||||
// finalized geometry (min size, baseHeight ≤ height) — only a
|
||||
// pointer-down draft is exempt from it
|
||||
excalidrawElement = normalizeStickyNoteGeometry(
|
||||
newStickyNoteElement({
|
||||
...element,
|
||||
type: "stickynote",
|
||||
width,
|
||||
height,
|
||||
baseHeight: element.baseHeight ?? height,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "embeddable": {
|
||||
excalidrawElement = element;
|
||||
excalidrawElement = newEmbeddableElement({ ...element });
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -662,8 +750,11 @@ export const convertToExcalidrawElements = (
|
||||
case "rectangle":
|
||||
case "ellipse":
|
||||
case "diamond":
|
||||
case "stickynote":
|
||||
case "arrow": {
|
||||
if (element.label?.text) {
|
||||
// for a sticky note this runs the sticky fit (via
|
||||
// `redrawTextBoundingBox`): the label's font size becomes its ceiling
|
||||
let [container, text] = bindTextToContainer(
|
||||
excalidrawElement,
|
||||
element?.label,
|
||||
@@ -691,11 +782,12 @@ export const convertToExcalidrawElements = (
|
||||
}
|
||||
const { linearElement, startBoundElement, endBoundElement } =
|
||||
bindLinearElementToElement(
|
||||
container,
|
||||
container as NonDeleted<ExcalidrawArrowElement>,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
elementStore,
|
||||
scene,
|
||||
DEFAULT_ZOOM,
|
||||
);
|
||||
container = linearElement;
|
||||
elementStore.add(linearElement);
|
||||
@@ -716,11 +808,12 @@ export const convertToExcalidrawElements = (
|
||||
}
|
||||
const { linearElement, startBoundElement, endBoundElement } =
|
||||
bindLinearElementToElement(
|
||||
excalidrawElement as ExcalidrawArrowElement,
|
||||
excalidrawElement as NonDeleted<ExcalidrawArrowElement>,
|
||||
start,
|
||||
end,
|
||||
elementStore,
|
||||
scene,
|
||||
DEFAULT_ZOOM,
|
||||
);
|
||||
|
||||
elementStore.add(linearElement);
|
||||
|
||||
@@ -29,70 +29,77 @@ import type {
|
||||
ExcalidrawLineElement,
|
||||
ExcalidrawFlowchartNodeElement,
|
||||
ExcalidrawLinearElementSubType,
|
||||
ExcalidrawStickyNoteElement,
|
||||
} from "./types";
|
||||
|
||||
export const isInitializedImageElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is InitializedExcalidrawImageElement => {
|
||||
export const isInitializedImageElement = <T extends ExcalidrawElement>(
|
||||
element: T | null,
|
||||
): element is T & InitializedExcalidrawImageElement => {
|
||||
return !!element && element.type === "image" && !!element.fileId;
|
||||
};
|
||||
|
||||
export const isImageElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawImageElement => {
|
||||
export const isImageElement = <T extends ExcalidrawElement>(
|
||||
element: T | null,
|
||||
): element is T & ExcalidrawImageElement => {
|
||||
return !!element && element.type === "image";
|
||||
};
|
||||
|
||||
export const isEmbeddableElement = (
|
||||
element: ExcalidrawElement | null | undefined,
|
||||
): element is ExcalidrawEmbeddableElement => {
|
||||
export const isEmbeddableElement = <T extends ExcalidrawElement>(
|
||||
element: T | null | undefined,
|
||||
): element is T & ExcalidrawEmbeddableElement => {
|
||||
return !!element && element.type === "embeddable";
|
||||
};
|
||||
|
||||
export const isIframeElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawIframeElement => {
|
||||
export const isIframeElement = <T extends ExcalidrawElement>(
|
||||
element: T | null,
|
||||
): element is T & ExcalidrawIframeElement => {
|
||||
return !!element && element.type === "iframe";
|
||||
};
|
||||
|
||||
export const isIframeLikeElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawIframeLikeElement => {
|
||||
export const isIframeLikeElement = <T extends ExcalidrawElement>(
|
||||
element: T | null,
|
||||
): element is T & ExcalidrawIframeLikeElement => {
|
||||
return (
|
||||
!!element && (element.type === "iframe" || element.type === "embeddable")
|
||||
);
|
||||
};
|
||||
|
||||
export const isTextElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawTextElement => {
|
||||
export const isTextElement = <T extends ExcalidrawElement>(
|
||||
element: T | null,
|
||||
): element is T & ExcalidrawTextElement => {
|
||||
return element != null && element.type === "text";
|
||||
};
|
||||
|
||||
export const isFrameElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawFrameElement => {
|
||||
export const isStickyNoteElement = <T extends ExcalidrawElement>(
|
||||
element: T | null | undefined,
|
||||
): element is T & ExcalidrawStickyNoteElement => {
|
||||
return element != null && element.type === "stickynote";
|
||||
};
|
||||
|
||||
export const isFrameElement = <T extends ExcalidrawElement>(
|
||||
element: T | null,
|
||||
): element is T & ExcalidrawFrameElement => {
|
||||
return element != null && element.type === "frame";
|
||||
};
|
||||
|
||||
export const isMagicFrameElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawMagicFrameElement => {
|
||||
export const isMagicFrameElement = <T extends ExcalidrawElement>(
|
||||
element: T | null,
|
||||
): element is T & ExcalidrawMagicFrameElement => {
|
||||
return element != null && element.type === "magicframe";
|
||||
};
|
||||
|
||||
export const isFrameLikeElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawFrameLikeElement => {
|
||||
export const isFrameLikeElement = <T extends ExcalidrawElement>(
|
||||
element: T | null,
|
||||
): element is T & ExcalidrawFrameLikeElement => {
|
||||
return (
|
||||
element != null &&
|
||||
(element.type === "frame" || element.type === "magicframe")
|
||||
);
|
||||
};
|
||||
|
||||
export const isFreeDrawElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawFreeDrawElement => {
|
||||
export const isFreeDrawElement = <T extends ExcalidrawElement>(
|
||||
element?: T | null,
|
||||
): element is T & ExcalidrawFreeDrawElement => {
|
||||
return element != null && isFreeDrawElementType(element.type);
|
||||
};
|
||||
|
||||
@@ -102,48 +109,48 @@ export const isFreeDrawElementType = (
|
||||
return elementType === "freedraw";
|
||||
};
|
||||
|
||||
export const isLinearElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawLinearElement => {
|
||||
export const isLinearElement = <T extends ExcalidrawElement>(
|
||||
element?: T | null,
|
||||
): element is T & ExcalidrawLinearElement => {
|
||||
return element != null && isLinearElementType(element.type);
|
||||
};
|
||||
|
||||
export const isLineElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawLineElement => {
|
||||
export const isLineElement = <T extends ExcalidrawElement>(
|
||||
element?: T | null,
|
||||
): element is T & ExcalidrawLineElement => {
|
||||
return element != null && element.type === "line";
|
||||
};
|
||||
|
||||
export const isArrowElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawArrowElement => {
|
||||
export const isArrowElement = <T extends ExcalidrawElement>(
|
||||
element?: T | null,
|
||||
): element is T & ExcalidrawArrowElement => {
|
||||
return element != null && element.type === "arrow";
|
||||
};
|
||||
|
||||
export const isElbowArrow = (
|
||||
element?: ExcalidrawElement,
|
||||
): element is ExcalidrawElbowArrowElement => {
|
||||
export const isElbowArrow = <T extends ExcalidrawElement>(
|
||||
element?: T,
|
||||
): element is T & ExcalidrawElbowArrowElement => {
|
||||
return isArrowElement(element) && element.elbowed;
|
||||
};
|
||||
|
||||
/**
|
||||
* sharp or curved arrow, but not elbow
|
||||
*/
|
||||
export const isSimpleArrow = (
|
||||
element?: ExcalidrawElement,
|
||||
): element is ExcalidrawArrowElement => {
|
||||
export const isSimpleArrow = <T extends ExcalidrawElement>(
|
||||
element?: T,
|
||||
): element is T & ExcalidrawArrowElement => {
|
||||
return isArrowElement(element) && !element.elbowed;
|
||||
};
|
||||
|
||||
export const isSharpArrow = (
|
||||
element?: ExcalidrawElement,
|
||||
): element is ExcalidrawArrowElement => {
|
||||
export const isSharpArrow = <T extends ExcalidrawElement>(
|
||||
element?: T,
|
||||
): element is T & ExcalidrawArrowElement => {
|
||||
return isArrowElement(element) && !element.elbowed && !element.roundness;
|
||||
};
|
||||
|
||||
export const isCurvedArrow = (
|
||||
element?: ExcalidrawElement,
|
||||
): element is ExcalidrawArrowElement => {
|
||||
export const isCurvedArrow = <T extends ExcalidrawElement>(
|
||||
element?: T,
|
||||
): element is T & ExcalidrawArrowElement => {
|
||||
return (
|
||||
isArrowElement(element) && !element.elbowed && element.roundness !== null
|
||||
);
|
||||
@@ -157,10 +164,10 @@ export const isLinearElementType = (
|
||||
);
|
||||
};
|
||||
|
||||
export const isBindingElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
export const isBindingElement = <T extends ExcalidrawElement>(
|
||||
element?: T | null,
|
||||
includeLocked = true,
|
||||
): element is ExcalidrawArrowElement => {
|
||||
): element is T & ExcalidrawArrowElement => {
|
||||
return (
|
||||
element != null &&
|
||||
(!element.locked || includeLocked === true) &&
|
||||
@@ -174,14 +181,15 @@ export const isBindingElementType = (
|
||||
return elementType === "arrow";
|
||||
};
|
||||
|
||||
export const isBindableElement = (
|
||||
element: ExcalidrawElement | null | undefined,
|
||||
export const isBindableElement = <T extends ExcalidrawElement>(
|
||||
element: T | null | undefined,
|
||||
includeLocked = true,
|
||||
): element is ExcalidrawBindableElement => {
|
||||
): element is T & ExcalidrawBindableElement => {
|
||||
return (
|
||||
element != null &&
|
||||
(!element.locked || includeLocked === true) &&
|
||||
(element.type === "rectangle" ||
|
||||
element.type === "stickynote" ||
|
||||
element.type === "diamond" ||
|
||||
element.type === "ellipse" ||
|
||||
element.type === "image" ||
|
||||
@@ -193,12 +201,13 @@ export const isBindableElement = (
|
||||
);
|
||||
};
|
||||
|
||||
export const isRectanguloidElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawBindableElement => {
|
||||
export const isRectanguloidElement = <T extends ExcalidrawElement>(
|
||||
element?: T | null,
|
||||
): element is T & ExcalidrawBindableElement => {
|
||||
return (
|
||||
element != null &&
|
||||
(element.type === "rectangle" ||
|
||||
element.type === "stickynote" ||
|
||||
element.type === "diamond" ||
|
||||
element.type === "image" ||
|
||||
element.type === "iframe" ||
|
||||
@@ -211,12 +220,13 @@ export const isRectanguloidElement = (
|
||||
|
||||
// TODO: Remove this when proper distance calculation is introduced
|
||||
// @see binding.ts:distanceToBindableElement()
|
||||
export const isRectangularElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawBindableElement => {
|
||||
export const isRectangularElement = <T extends ExcalidrawElement>(
|
||||
element?: T | null,
|
||||
): element is T & ExcalidrawBindableElement => {
|
||||
return (
|
||||
element != null &&
|
||||
(element.type === "rectangle" ||
|
||||
element.type === "stickynote" ||
|
||||
element.type === "image" ||
|
||||
element.type === "text" ||
|
||||
element.type === "iframe" ||
|
||||
@@ -227,14 +237,15 @@ export const isRectangularElement = (
|
||||
);
|
||||
};
|
||||
|
||||
export const isTextBindableContainer = (
|
||||
element: ExcalidrawElement | null,
|
||||
export const isTextBindableContainer = <T extends ExcalidrawElement>(
|
||||
element: T | null,
|
||||
includeLocked = true,
|
||||
): element is ExcalidrawTextContainer => {
|
||||
): element is T & ExcalidrawTextContainer => {
|
||||
return (
|
||||
element != null &&
|
||||
(!element.locked || includeLocked === true) &&
|
||||
(element.type === "rectangle" ||
|
||||
element.type === "stickynote" ||
|
||||
element.type === "diamond" ||
|
||||
element.type === "ellipse" ||
|
||||
isArrowElement(element))
|
||||
@@ -252,6 +263,7 @@ export const isExcalidrawElement = (
|
||||
case "text":
|
||||
case "diamond":
|
||||
case "rectangle":
|
||||
case "stickynote":
|
||||
case "iframe":
|
||||
case "embeddable":
|
||||
case "ellipse":
|
||||
@@ -271,28 +283,30 @@ export const isExcalidrawElement = (
|
||||
}
|
||||
};
|
||||
|
||||
export const isFlowchartNodeElement = (
|
||||
element: ExcalidrawElement,
|
||||
): element is ExcalidrawFlowchartNodeElement => {
|
||||
export const isFlowchartNodeElement = <T extends ExcalidrawElement>(
|
||||
element: T,
|
||||
): element is T & ExcalidrawFlowchartNodeElement => {
|
||||
return (
|
||||
element.type === "rectangle" ||
|
||||
element.type === "stickynote" ||
|
||||
element.type === "ellipse" ||
|
||||
element.type === "diamond"
|
||||
);
|
||||
};
|
||||
|
||||
export const hasBoundTextElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is MarkNonNullable<ExcalidrawBindableElement, "boundElements"> => {
|
||||
export const hasBoundTextElement = <T extends ExcalidrawElement>(
|
||||
element: T | null,
|
||||
): element is T &
|
||||
MarkNonNullable<ExcalidrawBindableElement, "boundElements"> => {
|
||||
return (
|
||||
isTextBindableContainer(element) &&
|
||||
!!element.boundElements?.some(({ type }) => type === "text")
|
||||
);
|
||||
};
|
||||
|
||||
export const isBoundToContainer = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawTextElementWithContainer => {
|
||||
export const isBoundToContainer = <T extends ExcalidrawElement>(
|
||||
element: T | null,
|
||||
): element is T & ExcalidrawTextElementWithContainer => {
|
||||
return (
|
||||
element !== null &&
|
||||
"containerId" in element &&
|
||||
@@ -312,7 +326,10 @@ export const isUsingAdaptiveRadius = (type: string) =>
|
||||
type === "image";
|
||||
|
||||
export const isUsingProportionalRadius = (type: string) =>
|
||||
type === "line" || type === "arrow" || type === "diamond";
|
||||
type === "line" ||
|
||||
type === "arrow" ||
|
||||
type === "diamond" ||
|
||||
type === "stickynote";
|
||||
|
||||
export const canApplyRoundnessTypeToElement = (
|
||||
roundnessType: RoundnessType,
|
||||
@@ -392,3 +409,24 @@ export const canBecomePolygon = (
|
||||
(points.length === 3 && !pointsEqual(points[0], points[points.length - 1]))
|
||||
);
|
||||
};
|
||||
|
||||
export const isEligibleFrameChildType = (type: ElementOrToolType) => {
|
||||
switch (type) {
|
||||
case "rectangle":
|
||||
case "stickynote":
|
||||
case "diamond":
|
||||
case "ellipse":
|
||||
case "arrow":
|
||||
case "line":
|
||||
case "freedraw":
|
||||
case "text":
|
||||
case "image":
|
||||
case "frame":
|
||||
case "embeddable": {
|
||||
return true;
|
||||
}
|
||||
default: {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -76,6 +76,11 @@ type _ExcalidrawElementBase = Readonly<{
|
||||
boundElements: readonly BoundElement[] | null;
|
||||
/** epoch (ms) timestamp of last element update */
|
||||
updated: number;
|
||||
/** Client wall-clock creation time in epoch milliseconds; null if unknown.
|
||||
Preserved for this element's lifetime, including edits and undo/redo,
|
||||
and excluded from `ElementUpdate` (mutateElement / newElementWith).
|
||||
Duplicating an element starts a new lifetime. Not an ordering clock. */
|
||||
created: number | null;
|
||||
link: string | null;
|
||||
locked: boolean;
|
||||
customData?: Record<string, any>;
|
||||
@@ -89,6 +94,16 @@ export type ExcalidrawRectangleElement = _ExcalidrawElementBase & {
|
||||
type: "rectangle";
|
||||
};
|
||||
|
||||
export type ExcalidrawStickyNoteElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "stickynote";
|
||||
/**
|
||||
* The height the user set, from which the layout derives `height`: the
|
||||
* note grows above it to fit its label and never shrinks below it
|
||||
*/
|
||||
baseHeight: number;
|
||||
}>;
|
||||
|
||||
export type ExcalidrawDiamondElement = _ExcalidrawElementBase & {
|
||||
type: "diamond";
|
||||
};
|
||||
@@ -185,11 +200,13 @@ export type ExcalidrawGenericElement =
|
||||
|
||||
export type ExcalidrawFlowchartNodeElement =
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawStickyNoteElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement;
|
||||
|
||||
export type ExcalidrawRectanguloidElement =
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawStickyNoteElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
@@ -205,6 +222,7 @@ export type ExcalidrawRectanguloidElement =
|
||||
*/
|
||||
export type ExcalidrawElement =
|
||||
| ExcalidrawGenericElement
|
||||
| ExcalidrawStickyNoteElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawLinearElement
|
||||
| ExcalidrawArrowElement
|
||||
@@ -227,7 +245,7 @@ export type Ordered<TElement extends ExcalidrawElement> = TElement & {
|
||||
export type OrderedExcalidrawElement = Ordered<ExcalidrawElement>;
|
||||
|
||||
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
|
||||
isDeleted: boolean;
|
||||
isDeleted: false;
|
||||
};
|
||||
|
||||
export type NonDeletedExcalidrawElement = NonDeleted<ExcalidrawElement>;
|
||||
@@ -237,10 +255,19 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
|
||||
type: "text";
|
||||
fontSize: number;
|
||||
fontFamily: FontFamilyValues;
|
||||
/**
|
||||
* The font size the user picked, from which the layout derives `fontSize`.
|
||||
* Today only sticky note labels have one: the auto-fit shrinks below it
|
||||
* and never above it (compare `baseHeight`, which the note grows above).
|
||||
* `null` for every other text. Read it through `getBaseFontSize` —
|
||||
* generic binding repair can detach a label without clearing this, so
|
||||
* the container decides its meaning.
|
||||
*/
|
||||
baseFontSize: number | null;
|
||||
text: string;
|
||||
textAlign: TextAlign;
|
||||
verticalAlign: VerticalAlign;
|
||||
containerId: ExcalidrawGenericElement["id"] | null;
|
||||
containerId: ExcalidrawTextContainer["id"] | null;
|
||||
originalText: string;
|
||||
/**
|
||||
* If `true` the width will fit the text. If `false`, the text will
|
||||
@@ -254,10 +281,18 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
|
||||
* with font size (using `getLineHeightInPx` helper).
|
||||
*/
|
||||
lineHeight: number & { _brand: "unitlessLineHeight" };
|
||||
/**
|
||||
* Position of text bound to a linear element (such as an arrow),
|
||||
* expressed as a normalized arc-length parameter (0–1) along the
|
||||
* container's whole path. Independent of how the path is segmented,
|
||||
* so it survives midpoint insertion and other geometry changes.
|
||||
* */
|
||||
labelPosition?: number | null;
|
||||
}>;
|
||||
|
||||
export type ExcalidrawBindableElement =
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawStickyNoteElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement
|
||||
| ExcalidrawTextElement
|
||||
@@ -269,6 +304,7 @@ export type ExcalidrawBindableElement =
|
||||
|
||||
export type ExcalidrawTextContainer =
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawStickyNoteElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement
|
||||
| ExcalidrawArrowElement;
|
||||
@@ -279,7 +315,7 @@ export type ExcalidrawTextElementWithContainer = {
|
||||
|
||||
export type FixedPoint = [number, number];
|
||||
|
||||
export type BindMode = "inside" | "orbit" | "skip";
|
||||
export type BindMode = "inside" | "orbit";
|
||||
|
||||
export type FixedPointBinding = {
|
||||
elementId: ExcalidrawBindableElement["id"];
|
||||
@@ -384,12 +420,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" };
|
||||
@@ -437,6 +481,10 @@ export type ElementsMapOrArray =
|
||||
| readonly ExcalidrawElement[]
|
||||
| Readonly<ElementsMap>;
|
||||
|
||||
export type NonDeletedElementsMapOrArray =
|
||||
| readonly NonDeletedExcalidrawElement[]
|
||||
| Readonly<NonDeletedElementsMap | NonDeletedSceneElementsMap>;
|
||||
|
||||
export type ExcalidrawLinearElementSubType =
|
||||
| "line"
|
||||
| "sharpArrow"
|
||||
|
||||
+228
-69
@@ -8,6 +8,7 @@ import {
|
||||
|
||||
import {
|
||||
bezierEquation,
|
||||
clamp,
|
||||
curve,
|
||||
curveCatmullRomCubicApproxPoints,
|
||||
curveOffsetPoints,
|
||||
@@ -26,7 +27,7 @@ import {
|
||||
type GlobalPoint,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
|
||||
import type { Curve, LineSegment, LocalPoint, Radians } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
AppState,
|
||||
@@ -40,13 +41,14 @@ import { generateLinearCollisionShape } from "./shape";
|
||||
|
||||
import { hitElementItself, isPointInElement } from "./collision";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { isRectangularElement } from "./typeChecks";
|
||||
import { maxBindingDistance_simple } from "./binding";
|
||||
import { isElbowArrow, isRectangularElement } from "./typeChecks";
|
||||
import { getBindingGap, maxBindingDistance } from "./binding";
|
||||
|
||||
import {
|
||||
getGlobalFixedPointForBindableElement,
|
||||
normalizeFixedPoint,
|
||||
} from "./binding";
|
||||
import { getStickyNoteCornerRadius } from "./stickyNote";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
@@ -59,6 +61,8 @@ import type {
|
||||
ExcalidrawRectanguloidElement,
|
||||
} from "./types";
|
||||
|
||||
export type LinearPathSegment = LineSegment<GlobalPoint> | Curve<GlobalPoint>;
|
||||
|
||||
type ElementShape = [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]];
|
||||
|
||||
const ElementShapesCache = new WeakMap<
|
||||
@@ -125,7 +129,7 @@ const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
|
||||
export function deconstructLinearOrFreeDrawElement(
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
): ElementShape {
|
||||
const cachedShape = getElementShapesCacheEntry(element, 0);
|
||||
|
||||
if (cachedShape) {
|
||||
@@ -133,8 +137,8 @@ export function deconstructLinearOrFreeDrawElement(
|
||||
}
|
||||
|
||||
const ops = generateLinearCollisionShape(element, elementsMap);
|
||||
const lines = [];
|
||||
const curves = [];
|
||||
const lines: LineSegment<GlobalPoint>[] = [];
|
||||
const curves: Curve<GlobalPoint>[] = [];
|
||||
|
||||
for (let idx = 0; idx < ops.length; idx += 1) {
|
||||
const op = ops[idx];
|
||||
@@ -193,12 +197,43 @@ export function deconstructLinearOrFreeDrawElement(
|
||||
}
|
||||
}
|
||||
|
||||
const shape = [lines, curves] as ElementShape;
|
||||
const shape: ElementShape = [lines, curves];
|
||||
setElementShapesCacheEntry(element, shape, 0);
|
||||
|
||||
return shape;
|
||||
}
|
||||
|
||||
export function getLinearElementPathSegments(
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
): LinearPathSegment[] {
|
||||
// For now, model elbow arrows as their unrounded logical path. Rounded
|
||||
// joints can be incorporated once the path model supports mixed straight
|
||||
// and curved segments.
|
||||
if (isElbowArrow(element)) {
|
||||
return element.points
|
||||
.slice(1)
|
||||
.map((point, index) =>
|
||||
lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.points[index][0],
|
||||
element.y + element.points[index][1],
|
||||
),
|
||||
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
// Non-elbow paths currently contain only one segment type. Mixed paths
|
||||
// should consume an ordered operation stream instead of these type buckets.
|
||||
return curves.length > 0 ? curves : lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the building components of a rectanguloid element in the form of
|
||||
* line segments and curves **unrotated**.
|
||||
@@ -210,17 +245,17 @@ export function deconstructLinearOrFreeDrawElement(
|
||||
export function deconstructRectanguloidElement(
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
offset: number = 0,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
): ElementShape {
|
||||
const cachedShape = getElementShapesCacheEntry(element, offset);
|
||||
|
||||
if (cachedShape) {
|
||||
return cachedShape;
|
||||
}
|
||||
|
||||
let radius = getCornerRadius(
|
||||
Math.min(element.width, element.height),
|
||||
element,
|
||||
);
|
||||
let radius =
|
||||
element.type === "stickynote"
|
||||
? getStickyNoteCornerRadius(element)
|
||||
: getCornerRadius(Math.min(element.width, element.height), element);
|
||||
|
||||
if (radius === 0) {
|
||||
radius = 0.01;
|
||||
@@ -422,7 +457,7 @@ export function getDiamondBaseCorners(
|
||||
export function deconstructDiamondElement(
|
||||
element: ExcalidrawDiamondElement,
|
||||
offset: number = 0,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
): ElementShape {
|
||||
const cachedShape = getElementShapesCacheEntry(element, offset);
|
||||
|
||||
if (cachedShape) {
|
||||
@@ -431,10 +466,20 @@ export function deconstructDiamondElement(
|
||||
|
||||
const baseCorners = getDiamondBaseCorners(element, offset);
|
||||
|
||||
const corners = baseCorners.map(
|
||||
(corner) =>
|
||||
curveCatmullRomCubicApproxPoints(curveOffsetPoints(corner, offset))!,
|
||||
);
|
||||
const corners =
|
||||
offset > 0
|
||||
? baseCorners.map(
|
||||
(corner) =>
|
||||
curveCatmullRomCubicApproxPoints(
|
||||
curveOffsetPoints(corner, offset),
|
||||
)!,
|
||||
)
|
||||
: [
|
||||
[baseCorners[0]],
|
||||
[baseCorners[1]],
|
||||
[baseCorners[2]],
|
||||
[baseCorners[3]],
|
||||
];
|
||||
|
||||
const sides = [
|
||||
lineSegment<GlobalPoint>(
|
||||
@@ -587,67 +632,179 @@ const getDiagonalsForBindableElement = (
|
||||
return [diagonalOne, diagonalTwo];
|
||||
};
|
||||
|
||||
export const getSnapOutlineMidPoint = (
|
||||
point: GlobalPoint,
|
||||
/**
|
||||
* `onAxis` is true for the midpoints on the element's axes (the side midpoints,
|
||||
* or a diamond's vertices), whose direction from the center is unambiguous,
|
||||
* and false for a diamond's edge midpoints
|
||||
*/
|
||||
const getSnappedMidpointForElbowArrow = (
|
||||
element: ExcalidrawBindableElement,
|
||||
point: GlobalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
zoom: AppState["zoom"],
|
||||
) => {
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
const sideMidpoints =
|
||||
element.type === "diamond"
|
||||
? getDiamondBaseCorners(element).map((curve) => {
|
||||
const point = bezierEquation(curve, 0.5);
|
||||
const rotatedPoint = pointRotateRads(point, center, element.angle);
|
||||
center: GlobalPoint,
|
||||
horizontalThreshold: number,
|
||||
verticalThreshold: number,
|
||||
): { point: GlobalPoint; onAxis: boolean } | undefined => {
|
||||
const { x, y, width, height, angle } = element;
|
||||
const nonRotated = pointRotateRads(point, center, -angle as Radians);
|
||||
|
||||
return pointFrom<GlobalPoint>(rotatedPoint[0], rotatedPoint[1]);
|
||||
})
|
||||
: [
|
||||
// RIGHT midpoint
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.width,
|
||||
element.y + element.height / 2,
|
||||
),
|
||||
const bindingGap = getBindingGap(element);
|
||||
|
||||
if (pointDistance(center, nonRotated) < bindingGap) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [right, bottom, left, top] = getAllMidpoints(element, elementsMap);
|
||||
|
||||
if (
|
||||
nonRotated[0] <= x + width / 2 &&
|
||||
nonRotated[1] > center[1] - verticalThreshold &&
|
||||
nonRotated[1] < center[1] + verticalThreshold
|
||||
) {
|
||||
return { point: left, onAxis: true };
|
||||
} else if (
|
||||
nonRotated[1] <= y + height / 2 &&
|
||||
nonRotated[0] > center[0] - horizontalThreshold &&
|
||||
nonRotated[0] < center[0] + horizontalThreshold
|
||||
) {
|
||||
return { point: top, onAxis: true };
|
||||
} else if (
|
||||
nonRotated[0] >= x + width / 2 &&
|
||||
nonRotated[1] > center[1] - verticalThreshold &&
|
||||
nonRotated[1] < center[1] + verticalThreshold
|
||||
) {
|
||||
return { point: right, onAxis: true };
|
||||
} else if (
|
||||
nonRotated[1] >= y + height / 2 &&
|
||||
nonRotated[0] > center[0] - horizontalThreshold &&
|
||||
nonRotated[0] < center[0] + horizontalThreshold
|
||||
) {
|
||||
return { point: bottom, onAxis: true };
|
||||
} else if (element.type === "diamond") {
|
||||
// Elbow arrows can also snap to the midpoints of a diamond's edges
|
||||
const threshold = Math.max(horizontalThreshold, verticalThreshold);
|
||||
const edgeMidpoints = [
|
||||
// top-left, top-right, bottom-left, bottom-right
|
||||
[x + width / 4, y + height / 4, -1, -1],
|
||||
[x + (3 * width) / 4, y + height / 4, 1, -1],
|
||||
[x + width / 4, y + (3 * height) / 4, -1, 1],
|
||||
[x + (3 * width) / 4, y + (3 * height) / 4, 1, 1],
|
||||
] as const;
|
||||
|
||||
for (const [edgeX, edgeY, dirX, dirY] of edgeMidpoints) {
|
||||
// the snap zone sits a binding gap outside the edge
|
||||
const zoneCenter = pointFrom<GlobalPoint>(
|
||||
edgeX + dirX * bindingGap,
|
||||
edgeY + dirY * bindingGap,
|
||||
);
|
||||
|
||||
if (pointDistance(zoneCenter, nonRotated) < threshold) {
|
||||
return {
|
||||
point: pointRotateRads(
|
||||
pointFrom<GlobalPoint>(edgeX, edgeY),
|
||||
center,
|
||||
element.angle,
|
||||
angle,
|
||||
),
|
||||
// BOTTOM midpoint
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
// LEFT midpoint
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x, element.y + element.height / 2),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
// TOP midpoint
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x + element.width / 2, element.y),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
];
|
||||
const candidate = sideMidpoints.find(
|
||||
(midpoint) =>
|
||||
pointDistance(point, midpoint) <=
|
||||
maxBindingDistance_simple(zoom) + element.strokeWidth / 2 &&
|
||||
onAxis: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getSnappedMidpointIndexForSimpleArrow = (
|
||||
element: ExcalidrawBindableElement,
|
||||
point: GlobalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
threshold: number,
|
||||
) => {
|
||||
const baseMidpoints = getAllMidpoints(element, elementsMap);
|
||||
|
||||
for (let i = 0; i < baseMidpoints.length; i++) {
|
||||
if (
|
||||
pointDistance(baseMidpoints[i], point) <= threshold &&
|
||||
!hitElementItself({
|
||||
point,
|
||||
element,
|
||||
threshold: 0,
|
||||
elementsMap,
|
||||
overrideShouldTestInside: true,
|
||||
}),
|
||||
})
|
||||
) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
};
|
||||
|
||||
export const getAllMidpoints = (
|
||||
element: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
): GlobalPoint[] => {
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
|
||||
if (element.type === "diamond") {
|
||||
return getDiamondBaseCorners(element).map((curve) =>
|
||||
pointRotateRads(bezierEquation(curve, 0.5), center, element.angle),
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
pointFrom(element.width, element.height / 2),
|
||||
pointFrom(element.width / 2, element.height),
|
||||
pointFrom(0, element.height / 2),
|
||||
pointFrom(element.width / 2, 0),
|
||||
].map(([x, y]) =>
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x + x, element.y + y),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const getElbowArrowSnapMidPoint = (
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
zoom: AppState["zoom"],
|
||||
) => {
|
||||
const TOLERANCE = 0.05;
|
||||
const maxDistance = maxBindingDistance(zoom) + element.strokeWidth / 2;
|
||||
|
||||
return getSnappedMidpointForElbowArrow(
|
||||
element,
|
||||
point,
|
||||
elementsMap,
|
||||
elementCenterPoint(element, elementsMap),
|
||||
clamp(TOLERANCE * element.width, 5, maxDistance),
|
||||
clamp(TOLERANCE * element.height, 5, maxDistance),
|
||||
);
|
||||
};
|
||||
|
||||
export const getSnapOutlineMidPoint = (
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
zoom: AppState["zoom"],
|
||||
arrow: { elbowed: boolean },
|
||||
): GlobalPoint | undefined => {
|
||||
if (arrow.elbowed) {
|
||||
return getElbowArrowSnapMidPoint(point, element, elementsMap, zoom)?.point;
|
||||
}
|
||||
|
||||
const maxDistance = maxBindingDistance(zoom) + element.strokeWidth / 2;
|
||||
const idx = getSnappedMidpointIndexForSimpleArrow(
|
||||
element,
|
||||
point,
|
||||
elementsMap,
|
||||
maxDistance,
|
||||
);
|
||||
|
||||
return candidate;
|
||||
return idx === -1 ? undefined : getAllMidpoints(element, elementsMap)[idx];
|
||||
};
|
||||
|
||||
export const projectFixedPointOntoDiagonal = (
|
||||
@@ -660,22 +817,24 @@ export const projectFixedPointOntoDiagonal = (
|
||||
isMidpointSnappingEnabled: boolean = true,
|
||||
): GlobalPoint | null => {
|
||||
invariant(arrow.points.length >= 2, "Arrow must have at least two points");
|
||||
if (arrow.width < 3 && arrow.height < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isMidpointSnappingEnabled) {
|
||||
const sideMidPoint = getSnapOutlineMidPoint(
|
||||
point,
|
||||
element,
|
||||
elementsMap,
|
||||
zoom,
|
||||
arrow,
|
||||
);
|
||||
if (sideMidPoint) {
|
||||
return sideMidPoint;
|
||||
}
|
||||
}
|
||||
|
||||
// Projection needs a meaningful arrow direction
|
||||
if (arrow.width < 3 && arrow.height < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Do the projection onto the diagonals (or center lines
|
||||
// for non-rectangular shapes)
|
||||
const [diagonalOne, diagonalTwo] = getDiagonalsForBindableElement(
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -8,7 +10,6 @@ import { getElementsInGroup } from "./groups";
|
||||
import { syncMovedIndices } from "./fractionalIndex";
|
||||
import { getSelectedElements } from "./selection";
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import { getHoveredElementForBinding } from "./collision";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
import type {
|
||||
@@ -17,7 +18,6 @@ import type {
|
||||
ExcalidrawFrameLikeElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
Ordered,
|
||||
OrderedExcalidrawElement,
|
||||
} from "./types";
|
||||
|
||||
@@ -153,15 +153,11 @@ const getContiguousFrameRangeElements = (
|
||||
export const moveArrowAboveBindable = (
|
||||
point: GlobalPoint,
|
||||
arrow: ExcalidrawArrowElement,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
elements: readonly OrderedExcalidrawElement[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
scene: Scene,
|
||||
hit?: NonDeletedExcalidrawElement,
|
||||
hoveredElement: NonDeletedExcalidrawElement,
|
||||
): readonly OrderedExcalidrawElement[] => {
|
||||
const hoveredElement = hit
|
||||
? hit
|
||||
: getHoveredElementForBinding(point, elements, elementsMap);
|
||||
|
||||
if (!hoveredElement) {
|
||||
return elements;
|
||||
}
|
||||
@@ -313,12 +309,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 +419,10 @@ const shiftElementsByOne = (
|
||||
];
|
||||
});
|
||||
|
||||
if (!hasSameElementIds(originalElements, elements)) {
|
||||
return originalElements;
|
||||
}
|
||||
|
||||
syncMovedIndices(elements, targetElementsMap);
|
||||
|
||||
return elements;
|
||||
@@ -402,11 +436,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 +494,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 +531,10 @@ const shiftElementsToEnd = (
|
||||
...trailingElements,
|
||||
];
|
||||
|
||||
if (!hasSameElementIds(elements, nextElements)) {
|
||||
return elements;
|
||||
}
|
||||
|
||||
syncMovedIndices(nextElements, targetElementsMap);
|
||||
|
||||
return nextElements;
|
||||
@@ -543,7 +603,7 @@ function shiftElementsAccountingForFrames(
|
||||
|
||||
for (const [frameId, children] of frameChildrenSets) {
|
||||
nextElements = shiftFunction(
|
||||
allElements,
|
||||
nextElements,
|
||||
appState,
|
||||
direction,
|
||||
frameId,
|
||||
|
||||
@@ -17,7 +17,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
|
||||
class="excalidraw-wysiwyg"
|
||||
data-type="wysiwyg"
|
||||
dir="auto"
|
||||
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, sans-serif, Segoe UI Emoji;"
|
||||
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform-origin: 5px 12.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, sans-serif, Segoe UI Emoji;"
|
||||
tabindex="0"
|
||||
wrap="off"
|
||||
/>
|
||||
|
||||
@@ -1009,4 +1009,89 @@ describe("aligning", () => {
|
||||
expect(API.getSelectedElements()[1].x).toEqual(150);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(100);
|
||||
});
|
||||
|
||||
const createAndSelectGroupWithBoundTextAndRectangle = () => {
|
||||
const groupId = "group-with-bound-text";
|
||||
|
||||
// container is vertically/horizontally centered around its bound text
|
||||
const container = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "container",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
groupIds: [groupId],
|
||||
boundElements: [{ type: "text", id: "bound-text" }],
|
||||
});
|
||||
|
||||
const boundText = API.createElement({
|
||||
type: "text",
|
||||
id: "bound-text",
|
||||
x: 120,
|
||||
y: 140,
|
||||
width: 60,
|
||||
height: 20,
|
||||
containerId: container.id,
|
||||
groupIds: [groupId],
|
||||
});
|
||||
|
||||
const groupedRectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "grouped-rectangle",
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 100,
|
||||
height: 100,
|
||||
groupIds: [groupId],
|
||||
});
|
||||
|
||||
const standaloneRectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "standalone-rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
API.setElements([
|
||||
container,
|
||||
boundText,
|
||||
groupedRectangle,
|
||||
standaloneRectangle,
|
||||
]);
|
||||
|
||||
API.setSelectedElements([container, groupedRectangle, standaloneRectangle]);
|
||||
|
||||
return { container, boundText, groupedRectangle, standaloneRectangle };
|
||||
};
|
||||
|
||||
it("keeps a bound text within its container when aligning a group to the top", () => {
|
||||
const { container, boundText, groupedRectangle, standaloneRectangle } =
|
||||
createAndSelectGroupWithBoundTextAndRectangle();
|
||||
|
||||
API.executeAction(actionAlignTop);
|
||||
|
||||
expect(API.getElement(standaloneRectangle).y).toEqual(0);
|
||||
expect(API.getElement(container).y).toEqual(0);
|
||||
expect(API.getElement(groupedRectangle).y).toEqual(100);
|
||||
|
||||
expect(API.getElement(boundText).y).toEqual(40);
|
||||
expect(API.getElement(boundText).x).toEqual(120);
|
||||
});
|
||||
|
||||
it("keeps a bound text within its container when aligning a group to the left", () => {
|
||||
const { container, boundText, groupedRectangle, standaloneRectangle } =
|
||||
createAndSelectGroupWithBoundTextAndRectangle();
|
||||
|
||||
API.executeAction(actionAlignLeft);
|
||||
|
||||
expect(API.getElement(standaloneRectangle).x).toEqual(0);
|
||||
expect(API.getElement(container).x).toEqual(0);
|
||||
expect(API.getElement(groupedRectangle).x).toEqual(0);
|
||||
|
||||
expect(API.getElement(boundText).x).toEqual(20);
|
||||
expect(API.getElement(boundText).y).toEqual(140);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { KEYS, arrayToMap } from "@excalidraw/common";
|
||||
import { KEYS, ROUNDNESS, arrayToMap, DEFAULT_ZOOM } from "@excalidraw/common";
|
||||
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
|
||||
@@ -16,6 +16,14 @@ import {
|
||||
|
||||
import { defaultLang, setLanguage } from "@excalidraw/excalidraw/i18n";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
bindBindingElement,
|
||||
getBindingGap,
|
||||
updateBoundElements,
|
||||
} from "../src/binding";
|
||||
import { getAllMidpoints } from "../src/utils";
|
||||
import { getTransformHandles } from "../src/transformHandles";
|
||||
import {
|
||||
getTextEditor,
|
||||
@@ -24,8 +32,10 @@ import {
|
||||
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawLinearElement,
|
||||
FixedPointBinding,
|
||||
NonDeleted,
|
||||
} from "../src/types";
|
||||
|
||||
const { h } = window;
|
||||
@@ -178,6 +188,343 @@ 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");
|
||||
});
|
||||
|
||||
it("inside -> inside stays in multi-point mode when the pointer moves on", () => {
|
||||
const end: [number, number] = [INSIDE[0] + 50, INSIDE[1] + 50];
|
||||
drawSelfArrow(INSIDE, end);
|
||||
// nudge within the commit zone of the point just placed
|
||||
mouse.moveTo(end[0] + 2, end[1] + 1);
|
||||
|
||||
expect(h.state.multiElement).not.toBe(null);
|
||||
expect(h.state.activeTool.type).toBe("arrow");
|
||||
expect(h.state.multiElement!.points.length).toBe(3);
|
||||
});
|
||||
|
||||
it("bend point inside a shape the arrow doesn't start on stays in multi-point mode when the pointer moves on", () => {
|
||||
UI.clickTool("arrow");
|
||||
mouse.reset();
|
||||
mouse.clickAt(...MIDDLE);
|
||||
mouse.moveTo(...INSIDE);
|
||||
mouse.clickAt(...INSIDE);
|
||||
mouse.moveTo(INSIDE[0] + 2, INSIDE[1] + 1);
|
||||
|
||||
expect(h.state.multiElement).not.toBe(null);
|
||||
expect(h.state.activeTool.type).toBe("arrow");
|
||||
expect(h.state.multiElement!.points.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("binding to frames", () => {
|
||||
// frame spans x:200..600, y:200..600, child rect x:300..400, y:300..400
|
||||
beforeEach(async () => {
|
||||
mouse.reset();
|
||||
await act(() => setLanguage(defaultLang));
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
|
||||
const frame = API.createElement({
|
||||
id: "frame",
|
||||
type: "frame",
|
||||
x: 200,
|
||||
y: 200,
|
||||
width: 400,
|
||||
height: 400,
|
||||
});
|
||||
const child = API.createElement({
|
||||
id: "child",
|
||||
type: "rectangle",
|
||||
x: 300,
|
||||
y: 300,
|
||||
width: 100,
|
||||
height: 100,
|
||||
frameId: frame.id,
|
||||
});
|
||||
API.setElements([frame, child]);
|
||||
});
|
||||
|
||||
const drawArrow = (end: [number, number]) => {
|
||||
UI.clickTool("arrow");
|
||||
mouse.reset();
|
||||
mouse.downAt(50, 50);
|
||||
mouse.moveTo(end[0] - 10, end[1] - 10);
|
||||
mouse.moveTo(...end);
|
||||
mouse.up();
|
||||
|
||||
return h.elements[h.elements.length - 1] as ExcalidrawArrowElement;
|
||||
};
|
||||
|
||||
it("doesn't bind an arrow ending in the frame's empty interior", () => {
|
||||
const arrow = drawArrow([500, 500]);
|
||||
|
||||
expect(arrow.endBinding).toBe(null);
|
||||
});
|
||||
|
||||
it("binds an arrow ending on a frame child to the child", () => {
|
||||
const arrow = drawArrow([350, 350]);
|
||||
|
||||
expect(arrow.endBinding?.elementId).toBe("child");
|
||||
});
|
||||
|
||||
it("binds an arrow ending just outside the frame to the frame", () => {
|
||||
const arrow = drawArrow([500, 610]);
|
||||
|
||||
expect(arrow.endBinding?.elementId).toBe("frame");
|
||||
expect(arrow.endBinding?.mode).toBe("orbit");
|
||||
});
|
||||
|
||||
it("keeps an elbow arrow drawn inside the frame where it was drawn", () => {
|
||||
UI.clickTool("arrow");
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
mouse.reset();
|
||||
mouse.downAt(250, 250);
|
||||
mouse.moveTo(400, 450);
|
||||
mouse.moveTo(550, 550);
|
||||
mouse.up();
|
||||
|
||||
const arrow = h.elements[h.elements.length - 1] as ExcalidrawArrowElement;
|
||||
expect(arrow.elbowed).toBe(true);
|
||||
// no self-orbit binding to the frame, which would route the arrow
|
||||
// around the frame's outside
|
||||
expect(arrow.startBinding).toBe(null);
|
||||
expect(arrow.endBinding).toBe(null);
|
||||
expect(arrow.frameId).toBe("frame");
|
||||
expect([arrow.x, arrow.y]).toEqual([250, 250]);
|
||||
expect(arrow.points[arrow.points.length - 1]).toEqual([300, 300]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("binding at an ellipse's center", () => {
|
||||
beforeEach(async () => {
|
||||
mouse.reset();
|
||||
await act(() => setLanguage(defaultLang));
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
it("binds an arrow dropped at a circle's exact center", () => {
|
||||
const circle = API.createElement({
|
||||
type: "ellipse",
|
||||
x: 100,
|
||||
y: -100,
|
||||
width: 200,
|
||||
height: 200,
|
||||
});
|
||||
API.setElements([circle]);
|
||||
|
||||
UI.clickTool("arrow");
|
||||
mouse.reset();
|
||||
mouse.downAt(-100, 0);
|
||||
mouse.moveTo(100, 0);
|
||||
mouse.moveTo(200, 0);
|
||||
mouse.up();
|
||||
|
||||
const arrow = h.elements[h.elements.length - 1] as ExcalidrawArrowElement;
|
||||
expect(arrow.endBinding?.elementId).toBe(circle.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("midpoint snapping on diamonds", () => {
|
||||
beforeEach(async () => {
|
||||
mouse.reset();
|
||||
await act(() => setLanguage(defaultLang));
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
for (const rounded of [false, true]) {
|
||||
for (const elbowed of [false, true]) {
|
||||
it(`${
|
||||
elbowed ? "elbow" : "simple"
|
||||
} arrow ends a binding gap outside the ${
|
||||
rounded ? "rounded" : "sharp"
|
||||
} left vertex`, () => {
|
||||
const diamond = API.createElement({
|
||||
type: "diamond",
|
||||
x: 100,
|
||||
y: -100,
|
||||
width: 200,
|
||||
height: 200,
|
||||
roundness: rounded ? { type: ROUNDNESS.PROPORTIONAL_RADIUS } : null,
|
||||
}) as ExcalidrawBindableElement;
|
||||
API.setElements([diamond]);
|
||||
const [, , left] = getAllMidpoints(diamond, arrayToMap([diamond]));
|
||||
|
||||
UI.clickTool("arrow");
|
||||
if (elbowed) {
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
}
|
||||
mouse.reset();
|
||||
mouse.downAt(-200, left[1]);
|
||||
mouse.moveTo(left[0] - 50, left[1]);
|
||||
// near (not on) the vertex, so the end snaps to it
|
||||
mouse.moveTo(left[0] - 4, left[1] + 2);
|
||||
mouse.up();
|
||||
|
||||
const arrow = h.elements[
|
||||
h.elements.length - 1
|
||||
] as ExcalidrawArrowElement;
|
||||
const [endX, endY] = arrow.points[arrow.points.length - 1];
|
||||
|
||||
expect(arrow.endBinding?.elementId).toBe(diamond.id);
|
||||
expect(arrow.x + endX).toBeCloseTo(
|
||||
left[0] - getBindingGap(diamond),
|
||||
0,
|
||||
);
|
||||
expect(arrow.y + endY).toBeCloseTo(left[1], 0);
|
||||
});
|
||||
}
|
||||
|
||||
it(`elbow arrow binds above the ${
|
||||
rounded ? "rounded" : "sharp"
|
||||
} top vertex when the pointer is slightly inside`, () => {
|
||||
const diamond = API.createElement({
|
||||
type: "diamond",
|
||||
x: 100,
|
||||
y: -100,
|
||||
width: 300,
|
||||
height: 300,
|
||||
roundness: rounded ? { type: ROUNDNESS.PROPORTIONAL_RADIUS } : null,
|
||||
}) as ExcalidrawBindableElement;
|
||||
API.setElements([diamond]);
|
||||
const [, , , top] = getAllMidpoints(diamond, arrayToMap([diamond]));
|
||||
|
||||
UI.clickTool("arrow");
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
mouse.reset();
|
||||
mouse.downAt(top[0] - 300, top[1] - 100);
|
||||
mouse.moveTo(top[0] - 50, top[1] - 50);
|
||||
// inside the diamond, a bit right of the vertex
|
||||
mouse.moveTo(top[0] + 3, top[1] + 10);
|
||||
mouse.up();
|
||||
|
||||
const arrow = h.elements[
|
||||
h.elements.length - 1
|
||||
] as ExcalidrawArrowElement;
|
||||
const points = arrow.points;
|
||||
const [endX, endY] = points[points.length - 1];
|
||||
|
||||
expect(arrow.endBinding?.elementId).toBe(diamond.id);
|
||||
expect(arrow.x + endX).toBeCloseTo(top[0], 0);
|
||||
expect(arrow.y + endY).toBeCloseTo(top[1] - getBindingGap(diamond), 0);
|
||||
// the last segment comes straight down into the vertex
|
||||
expect(points[points.length - 2][0]).toBeCloseTo(endX);
|
||||
});
|
||||
}
|
||||
|
||||
it("elbow arrow binds next to the tip of a thin rotated diamond", () => {
|
||||
const diamond = API.createElement({
|
||||
type: "diamond",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 600,
|
||||
height: 100,
|
||||
angle: ((15 * Math.PI) / 180) as Radians,
|
||||
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
|
||||
}) as ExcalidrawBindableElement;
|
||||
API.setElements([diamond]);
|
||||
const [right] = getAllMidpoints(diamond, arrayToMap([diamond]));
|
||||
|
||||
UI.clickTool("arrow");
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
mouse.reset();
|
||||
mouse.downAt(right[0] + 300, right[1] + 300);
|
||||
mouse.moveTo(right[0] + 50, right[1] + 50);
|
||||
// just outside the right tip
|
||||
mouse.moveTo(right[0] + 4, right[1] + 1);
|
||||
mouse.up();
|
||||
|
||||
const arrow = h.elements[h.elements.length - 1] as ExcalidrawArrowElement;
|
||||
const [endX, endY] = arrow.points[arrow.points.length - 1];
|
||||
|
||||
expect(arrow.endBinding?.elementId).toBe(diamond.id);
|
||||
// a binding gap away, not on another edge of the diamond
|
||||
expect(
|
||||
Math.hypot(arrow.x + endX - right[0], arrow.y + endY - right[1]),
|
||||
).toBeLessThan(getBindingGap(diamond) + 2);
|
||||
});
|
||||
|
||||
it("elbow arrow snaps to a diamond's edge midpoint", () => {
|
||||
const diamond = API.createElement({
|
||||
type: "diamond",
|
||||
x: 100,
|
||||
y: -100,
|
||||
width: 200,
|
||||
height: 200,
|
||||
}) as ExcalidrawBindableElement;
|
||||
API.setElements([diamond]);
|
||||
// midpoint of the top-left edge
|
||||
const edgeMidpoint = [150, -50] as const;
|
||||
|
||||
UI.clickTool("arrow");
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
mouse.reset();
|
||||
mouse.downAt(-200, -50);
|
||||
mouse.moveTo(100, -60);
|
||||
// near (not on) the edge midpoint
|
||||
mouse.moveTo(edgeMidpoint[0] - 9, edgeMidpoint[1] - 3);
|
||||
mouse.up();
|
||||
|
||||
const arrow = h.elements[h.elements.length - 1] as ExcalidrawArrowElement;
|
||||
const [, endY] = arrow.points[arrow.points.length - 1];
|
||||
|
||||
expect(arrow.endBinding?.elementId).toBe(diamond.id);
|
||||
expect(arrow.y + endY).toBeCloseTo(edgeMidpoint[1], 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when arrow is outside of shape", () => {
|
||||
beforeEach(async () => {
|
||||
mouse.reset();
|
||||
@@ -403,6 +750,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);
|
||||
@@ -418,6 +766,9 @@ describe("binding for simple arrows", () => {
|
||||
|
||||
const elX = handles[0] + handles[2] / 2;
|
||||
const elY = handles[1] + handles[3] / 2;
|
||||
// move first, as a real pointer would — the hover state from the spot
|
||||
// where arrow creation finished must clear before the pointerdown
|
||||
mouse.moveTo(elX, elY);
|
||||
mouse.downAt(elX, elY);
|
||||
mouse.moveTo(300, 400);
|
||||
mouse.up();
|
||||
@@ -447,6 +798,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;
|
||||
|
||||
@@ -695,3 +1047,57 @@ describe("binding for simple arrows", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("binding to a point-like (sub-pixel) element", () => {
|
||||
beforeEach(async () => {
|
||||
mouse.reset();
|
||||
await act(() => setLanguage(defaultLang));
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
// Regression: binding to a zero/near-zero-size element used to yield an
|
||||
// enormous fixedPoint ratio (offset / ~0), which exploded into an arrow
|
||||
// hundreds of thousands of px wide once the element was resized to a normal
|
||||
// size
|
||||
it.each([
|
||||
["simple", false],
|
||||
["elbow", true],
|
||||
])(
|
||||
"%s arrow binds to the center and stays put when the element grows",
|
||||
(_label, elbowed) => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 200,
|
||||
y: 50,
|
||||
width: 0.00005,
|
||||
height: 0.00005,
|
||||
}) as NonDeleted<ExcalidrawBindableElement>;
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
elbowed,
|
||||
x: 0,
|
||||
y: 50,
|
||||
width: 195,
|
||||
height: 0,
|
||||
points: [pointFrom(0, 0), pointFrom(195, 0)],
|
||||
}) as NonDeleted<ExcalidrawArrowElement>;
|
||||
API.setElements([rect, arrow]);
|
||||
|
||||
bindBindingElement(arrow, rect, "orbit", "end", h.scene, DEFAULT_ZOOM);
|
||||
|
||||
const endBinding = arrow.endBinding as FixedPointBinding;
|
||||
expect(endBinding.elementId).toBe(rect.id);
|
||||
// Point-like element => bind to center (0.5 is normalized to 0.5001)
|
||||
expect(endBinding.fixedPoint[0]).toBeCloseTo(0.5001);
|
||||
expect(endBinding.fixedPoint[1]).toBeCloseTo(0.5001);
|
||||
|
||||
// Grow the element to a normal size and let bound arrows follow
|
||||
h.scene.mutateElement(rect, { width: 300, height: 200 });
|
||||
updateBoundElements(rect, h.scene);
|
||||
|
||||
// The arrow endpoint must track the element (its center), not fly off
|
||||
expect(Math.abs(arrow.width)).toBeLessThan(1000);
|
||||
expect(Math.abs(arrow.height)).toBeLessThan(1000);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user